EC-CUBE4で商品登録画面等にあるテキストエリアにTinyMCEを利用して入力させる方法

EC-CUBE Advent Calendar 2021 20日目の記事です。

EC-CUBE4の管理画面では商品登録画面での商品説明やフリーエリアなどは、HTMLタグなど自由に入力可能となっているのですが、HTMLタグを知らない方や苦手な方にとっては、記入し辛くなっています。

WordPressのようなリッチエディタを使って記入したいという方に今回はその方法を説明します。

リッチエディタを実現するために色々なライブラリが存在していますが、今回はTinyMCEを利用します。

www.tiny.cloud

利用方法ですが、CDNではなくダウンロードをして導入する方法とします。

ダウンロード画面より、「Self-hosted Releases」を選択して、「Download TinyMCE Community」をダウンロードします。

www.tiny.cloud

TinyMCEの導入

ダウンロードしたファイルを解凍後、「tinymce」→「js」ディレクトリ直下にある「tinymce」ディレクトリ毎、html/template/admin/assets/js/vendor直下に配置します。

日本語を利用したい方は、

www.tiny.cloud

より「Japanese」をダウンロード後、解答したja.jsファイルを、html/template/admin/assets/js/vendor/tinymce/langsへコピーしてください。

配置後、src/Eccube/Resource/template/admin/Product/product.twigファイルをapp/template/admin/Product/product.twigへコピーします。ディレクトリがなければ作成してください。

商品管理画面の修正

product.twigファイルに対して、ダウンロードしたTinyMCEファイルを読み込ませます。{% block javascript %}内に、以下の内容を記入してください。 記入する箇所は、

<script src="{{ asset('assets/js/vendor/fileupload/jquery.fileupload-validate.js', 'admin') }}"></script>

の次の行で問題ありません。

<script src="{{ asset('assets/js/vendor/tinymce/tinymce.min.js', 'admin') }}"></script>

次に、<script> </script>内に対して、TinyMCEの内容を記述します。今回は各オプションの説明は行いませんが、以下の内容を記述してください。

tinymce.init({
    selector: 'textarea',
    language: 'ja',
    height: 300,
    // 改行時にbrタグを適用させたくない場合、forced_root_blockをコメントアウトすること
    forced_root_block: false,
    paste_as_text: true,
    paste_data_images: true,
    image_advtab: true,
    relative_urls: false,
    setup: function(editor) {
        editor.on('init', function(e) {
            this.getDoc().body.style.fontSize = '16px';
            this.getDoc().body.style.lineHeight = '1.2';
        });
    },
    plugins: [
        'advlist autolink lists link image charmap print preview anchor',
        'searchreplace visualblocks code fullscreen',
        'insertdatetime media table contextmenu paste imagetools'
    ],
    toolbar: 'insertfile undo redo | styleselect fontsizeselect | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image paste code'
})

上記の内容だけでリッチエディタが利用可能となります。実際に入力してみてフロント画面へ反映されているか確認してみてください。

画像ファイルをアップロードしたい方は下記のURLを参照すれば実装可能です。

www.tiny.cloud

また、Youtubeの埋め込み動画などもプラグインとして探せばあると思います。

他のリッチエディタを利用したい方も手順は同じなので、今回の記事を参考にしてみてください。

2系にあったCSV出力項目設定の「高度な設定」機能をEC-CUBE4で実装する方法

EC-CUBE Advent Calendar 2021 19日目の記事です。

EC-CUBE4では、2系に存在していた「CSV出力項目設定」→「高度な設定の機能」を利用してSQLを作成し、CSVファイルをダウンロードする機能が存在しません。今回はその機能を作成する方法を説明します。

EC-CUBE3をご利用の方はこちらのプラグインを利用すると同様のことが実現可能です。

www.ec-cube.net

実現方法ですが、カスタマイズで作成しても良いのですが、他のサイトでも利用できるようにプラグイン形式として作成します。

元となる実装方法は3系で用意されている

3.0系|カスタムCSV出力プラグイン|株式会社イーシーキューブ

を流用して作成します。

プラグインの作成方法はこちらを参考にしてください。

doc4.ec-cube.net

今回作成するプラグインGitHubにも公開しています。

github.com

プラグインディレクトリの作成

[EC-CUBEルートディレクトリ]/app/Pluginディレクトリ直下にCustomCsvExport4ディレクトリを作成します。

composer.jsonの作成

最初にcomposer.jsonCustomCsvExport4ディレクトリ直下に作成します。ここにはプラグインの情報を記述します。

{
  "name": "ec-cube/customcsvexport4",
  "version": "1.0.0",
  "description": "カスタムCSV出力プラグイン",
  "type": "eccube-plugin",
  "require": {
    "ec-cube/plugin-installer": "~0.0.6"
  },
  "extra": {
    "code": "CustomCsvExport4"
  }
}

PluginManager.phpの作成

初期データの投入・更新・削除を行うためのPluginManager.phpCustomCsvExport4ディレクトリ直下に作成します。今回は特に実装は行いません。

<?php

namespace Plugin\CustomCsvExport4;

use Eccube\Application;
use Eccube\Plugin\AbstractPluginManager;

class PluginManager extends AbstractPluginManager
{
}

CustomCsvExportNav.phpの作成

管理画面メニューへ表示させるためのCustomCsvExportNav.phpCustomCsvExport4ディレクトリ直下に作成します。

<?php

namespace Plugin\CustomCsvExport4;

use Eccube\Common\EccubeNav;

class CustomCsvExportNav implements EccubeNav
{
    /**
     * {@inheritdoc}
     *
     * @return array
     */
    public static function getNav()
    {
        return [
            'setting' => [
                'children' => [
                    'shop' => [
                        'children' => [
                            'admin_custom_csv_export' => [
                                'name' => 'カスタムCSV出力',
                                'url' => 'plugin_custom_csv_export',
                            ],
                        ],
                    ],
                ],
            ],
        ];
    }
}

Controllerの作成

CustomCsvExportController.phpCustomCsvExport4/Controllerディレクトリ直下に作成します。

<?php

namespace Plugin\CustomCsvExport4\Controller;

use Eccube\Application;
use Eccube\Controller\AbstractController;
use Plugin\CustomCsvExport4\Entity\CustomCsvExport;
use Plugin\CustomCsvExport4\Form\Type\CustomCsvExportType;
use Plugin\CustomCsvExport4\Repository\CustomCsvExportRepository;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;

class CustomCsvExportController extends AbstractController
{

    /**
     * @var CustomCsvExportRepository
     */
    private $customCsvExportRepository;

    /**
     * CustomCsvExportController constructor.
     *
     * @param CustomCsvExportRepository $customCsvExportRepository
     */
    public function __construct(CustomCsvExportRepository $customCsvExportRepository)
    {
        $this->customCsvExportRepository = $customCsvExportRepository;
    }


    /**
     * CSV一覧画面
     *
     * @param Request $request
     * @param null $id
     * @return array|\Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
     *
     * @Route("/%eccube_admin_route%/plugin/custom_csv_export", name="plugin_custom_csv_export")
     * @Route("/%eccube_admin_route%/plugin/custom_csv_export/{id}/edit", requirements={"id" = "\d+"}, name="plugin_custom_csv_export_edit")
     * @Template("@CustomCsvExport4/admin/index.twig")
     */
    public function index(Request $request, $id = null)
    {
        if ($id) {
            $TargetCustomCsvExport = $this->customCsvExportRepository->find($id);
            if (!$TargetCustomCsvExport) {
                throw new NotFoundHttpException();
            }
        } else {
            $TargetCustomCsvExport = new CustomCsvExport();
        }

        $builder = $this->formFactory->createBuilder(CustomCsvExportType::class, $TargetCustomCsvExport);

        $form = $builder->getForm();

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {

            $sql = 'SELECT '.$form['custom_sql']->getData();
            try {
                $result = $this->customCsvExportRepository->query($sql);

                if ($result) {
                    $status = $this->customCsvExportRepository->save($TargetCustomCsvExport);

                    if ($status) {
                        $this->addSuccess('SQLを保存しました。', 'admin');

                        return $this->redirectToRoute('plugin_custom_csv_export');
                    } else {
                        $this->addError('SQLを保存できませんでした。', 'admin');
                    }
                } else {
                    $this->addError('SQLを保存できませんでした。', 'admin');
                }
            } catch (\Exception $e) {
                $this->addError('SQLを保存できませんでした。SQL文を正しく入力してください。', 'admin');
            }
        }

        $CustomCsvExports = $this->customCsvExportRepository->getList();

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

    /**
     * CSV削除
     *
     * @param Application $app
     * @param Request $request
     * @param $id
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     * @throws \Doctrine\DBAL\ConnectionException
     *
     * @Route("/%eccube_admin_route%/plugin/custom_csv_export/{id}/delete", requirements={"id" = "\d+"}, name="plugin_custom_csv_export_delete")
     */
    public function delete(Request $request, $id)
    {
        $this->isTokenValid();

        $TargetCustomCsvExport = $this->customCsvExportRepository->find($id);

        if (!$TargetCustomCsvExport) {
            $this->deleteMessage();

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

        $status = $this->customCsvExportRepository->delete($TargetCustomCsvExport);

        if ($status) {
            $this->addSuccess('SQLを削除しました。', 'admin');
        } else {
            $this->addError('SQLを削除できませんでした。', 'admin');
        }

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

    /**
     * CSV出力.
     *
     * @param Request $request
     * @param null $id
     * @return \Symfony\Component\HttpFoundation\RedirectResponse|StreamedResponse
     * @throws \Doctrine\DBAL\DBALException
     *
     * @Route("/%eccube_admin_route%/plugin/custom_csv_export/{id}/output", requirements={"id" = "\d+"}, name="plugin_custom_csv_export_output")
     */
    public function csvOutput(Request $request, $id = null)
    {
        // タイムアウトを無効にする.
        set_time_limit(0);

        // sql loggerを無効にする.
        $em = $this->entityManager;
        $em->getConfiguration()->setSQLLogger(null);

        $TargetCustomCsvExport = $this->customCsvExportRepository->find($id);
        if (!$TargetCustomCsvExport) {
            throw new NotFoundHttpException();
        }

        $response = new StreamedResponse();

        $csv_data = $this->customCsvExportRepository->getArrayList($TargetCustomCsvExport->getCustomSql());

        if (count($csv_data) > 0) {

            // ヘッダー行の抽出
            $csv_header = array();
            foreach ($csv_data as $csv_row) {

                foreach ($csv_row as $key => $value) {
                    $csv_header[$key] = mb_convert_encoding($key, $this->eccubeConfig['eccube_csv_export_encoding'], 'UTF-8');
                }
                break;
            }

            $response->setCallback(function() use ($request, $csv_header, $csv_data) {

                $fp = fopen('php://output', 'w');
                // ヘッダー行の出力
                fputcsv($fp, $csv_header, $this->eccubeConfig['eccube_csv_export_separator']);

                // データを出力
                foreach ($csv_data as $csv_row) {
                    $row = array();
                    foreach ($csv_header as $headerKey => $header_name) {
                        mb_convert_variables($this->eccubeConfig['eccube_csv_export_encoding'], 'UTF-8', $csv_row[$headerKey]);
                        $row[] = $csv_row[$headerKey];
                    }
                    fputcsv($fp, $row, $this->eccubeConfig['eccube_csv_export_separator']);
                }

                fclose($fp);

            });

            $now = new \DateTime();
            $filename = 'csv_'.$now->format('YmdHis').'.csv';
            $response->headers->set('Content-Type', 'application/octet-stream');
            $response->headers->set('Content-Disposition', 'attachment; filename='.$filename);
            $response->send();

            return $response;
        }

        $this->addError('CSVを出力できませんでした。', 'admin');

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

    /**
     * SQL確認.
     *
     * @param Request $request
     * @param null $id
     * @return array
     *
     * @Route("/%eccube_admin_route%/plugin/custom_csv_export/{id}/confirm", requirements={"id" = "\d+"}, name="plugin_custom_csv_export_edit_confirm")
     * @Route("/%eccube_admin_route%/plugin/custom_csv_export/confirm", name="plugin_custom_csv_export_confirm")
     * @Template("@CustomCsvExport4/admin/index.twig")
     */
    public function sqlConfirm(Request $request, $id = null)
    {
        if ($id) {
            $TargetCustomCsvExport = $this->customCsvExportRepository->find($id);
            if (!$TargetCustomCsvExport) {
                throw new NotFoundHttpException();
            }
        } else {
            $TargetCustomCsvExport = new CustomCsvExport();
        }

        $builder = $this->formFactory->createBuilder(CustomCsvExportType::class, $TargetCustomCsvExport);

        $form = $builder->getForm();

        $form->handleRequest($request);

        $message = null;
        if ($form->isSubmitted() && $form->isValid()) {

            if (!is_null($form['custom_sql']->getData())) {
                $sql = 'SELECT '.$form['custom_sql']->getData();
                try {
                    $result = $this->customCsvExportRepository->query($sql);
                    if ($result) {
                        $message = 'エラーはありません。';
                    } else {
                        $message = 'エラーが発生しました。';
                    }
                } catch (\Exception $e) {
                    $message = $e->getMessage();
                }
            }
        }

        $CustomCsvExports = $this->customCsvExportRepository->getList();

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

Entityの作成

作成したSQLを保持するためのCustomCsvExport.phpCustomCsvExport4/Entityディレクトリ直下に作成します。

<?php

namespace Plugin\CustomCsvExport4\Entity;

use Doctrine\ORM\Mapping as ORM;
use Eccube\Entity\AbstractEntity;

/**
 * CustomCsvExport
 *
 * @ORM\Table(name="plg_custom_csv_export")
 * @ORM\InheritanceType("SINGLE_TABLE")
 * @ORM\DiscriminatorColumn(name="discriminator_type", type="string", length=255)
 * @ORM\HasLifecycleCallbacks()
 * @ORM\Entity(repositoryClass="Plugin\CustomCsvExport4\Repository\CustomCsvExportRepository")
 */
class CustomCsvExport extends AbstractEntity
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="integer", options={"unsigned":true})
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="sql_name", type="string", length=255)
     */
    private $sql_name;

    /**
     * @var string
     *
     * @ORM\Column(name="custom_sql", type="text")
     */
    private $custom_sql;

    /**
     * @var boolean
     *
     * @ORM\Column(name="deletable", type="boolean", options={"default":false})
     */
    private $deletable = false;

    /**
     * @var \DateTime
     *
     * @ORM\Column(name="create_date", type="datetimetz")
     */
    private $create_date;

    /**
     * @var \DateTime
     *
     * @ORM\Column(name="update_date", type="datetimetz")
     */
    private $update_date;


    /**
     * Get id.
     *
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set sqlName.
     *
     * @param string $sqlName
     *
     * @return CustomCsvExport
     */
    public function setSqlName($sqlName)
    {
        $this->sql_name = $sqlName;

        return $this;
    }

    /**
     * Get sqlName.
     *
     * @return string
     */
    public function getSqlName()
    {
        return $this->sql_name;
    }

    /**
     * Set customSql.
     *
     * @param string $customSql
     *
     * @return CustomCsvExport
     */
    public function setCustomSql($customSql)
    {
        $this->custom_sql = $customSql;

        return $this;
    }

    /**
     * Get customSql.
     *
     * @return string
     */
    public function getCustomSql()
    {
        return $this->custom_sql;
    }

    /**
     * Set deletable.
     *
     * @param bool $deletable
     *
     * @return CustomCsvExport
     */
    public function setDeletable($deletable)
    {
        $this->deletable = $deletable;

        return $this;
    }

    /**
     * Get deletable.
     *
     * @return bool
     */
    public function getDeletable()
    {
        return $this->deletable;
    }

    /**
     * Set createDate.
     *
     * @param \DateTime $createDate
     *
     * @return CustomCsvExport
     */
    public function setCreateDate($createDate)
    {
        $this->create_date = $createDate;

        return $this;
    }

    /**
     * Get createDate.
     *
     * @return \DateTime
     */
    public function getCreateDate()
    {
        return $this->create_date;
    }

    /**
     * Set updateDate.
     *
     * @param \DateTime $updateDate
     *
     * @return CustomCsvExport
     */
    public function setUpdateDate($updateDate)
    {
        $this->update_date = $updateDate;

        return $this;
    }

    /**
     * Get updateDate.
     *
     * @return \DateTime
     */
    public function getUpdateDate()
    {
        return $this->update_date;
    }
}

FormTypeの作成

フォーム画面用のCustomCsvExportType.phpCustomCsvExport4/Form/Typeディレクトリ直下に作成します。

<?php

namespace Plugin\CustomCsvExport4\Form\Type;

use Eccube\Common\EccubeConfig;
use Plugin\CustomCsvExport4\Validator as Asserts;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Validator\Constraints as Assert;

class CustomCsvExportType extends AbstractType
{

    /**
     * @var EccubeConfig
     */
    protected $eccubeConfig;

    /**
     * CustomCsvExportType constructor.
     *
     * @param EccubeConfig $eccubeConfig
     */
    public function __construct(EccubeConfig $eccubeConfig)
    {
        $this->eccubeConfig = $eccubeConfig;
    }


    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {

        $builder
            ->add('sql_name', TextType::class, array(
                'label' => '名称',
                'constraints' => array(
                    new Assert\NotBlank(),
                    new Assert\Length(array(
                        'max' => $this->eccubeConfig['eccube_stext_len'],
                    )),
                ),
            ))
            ->add('custom_sql', TextareaType::class, array(
                'label' => 'SQL文(最初のSELECTは記述しないでください。最後の;(セミコロン)も不要です。)',
                'constraints' => array(
                    new Asserts\SqlCheck(),
                ),
            ));
    }

    /**
     * {@inheritdoc}
     */
    public function getName()
    {
        return 'admin_custom_csv_export';
    }
}

Repositoryの作成

DBアクセス用のCustomCsvExportRepository.phpCustomCsvExport4/Repositoryディレクトリ直下に作成します。

<?php

namespace Plugin\CustomCsvExport4\Repository;

use Eccube\Repository\AbstractRepository;
use Plugin\CustomCsvExport4\Entity\CustomCsvExport;
use Symfony\Bridge\Doctrine\RegistryInterface;

class CustomCsvExportRepository extends AbstractRepository
{
    /**
     * CouponRepository constructor.
     *
     * @param RegistryInterface $registry
     */
    public function __construct(RegistryInterface $registry)
    {
        parent::__construct($registry, CustomCsvExport::class);
    }

    /**
     * 設定SQL一覧を取得する.
     *
     * @return mixed 設定SQLの配列
     */
    public function getList()
    {
        $qb = $this->createQueryBuilder('cs')
            ->where('cs.deletable = 0');
        $CustomCsvExports = $qb->getQuery()->getResult();

        return $CustomCsvExports;
    }

    /**
     * CSV出力用の設定SQL実行結果を取得する.
     *
     * @param $custom_csv_export SQL文
     * @return mixed[]
     * @throws \Doctrine\DBAL\DBALException
     */
    public function getArrayList($custom_csv_export)
    {
        $em = $this->getEntityManager();
        $qb = $em->getConnection()->prepare('SELECT '.$custom_csv_export);
        $qb->execute();
        $result = $qb->fetchAll();

        return $result;
    }

    /**
     * 入力されたSQL文が正しいかどうか判定する
     *
     * @param $sql SQL文
     * @return bool SQLの実行結果
     * @throws \Doctrine\DBAL\DBALException
     */
    public function query($sql)
    {
        $em = $this->getEntityManager();
        $qb = $em->getConnection()->prepare($sql);

        $result = $qb->execute();

        return $result;
    }

    /**
     * 設定SQLを保存する.
     *
     * @param CustomCsvExport $CustomCsvExport 設定SQL
     * @return bool 成功した場合 true
     * @throws \Doctrine\DBAL\ConnectionException
     */
    public function save($CustomCsvExport)
    {
        $em = $this->getEntityManager();
        $em->getConnection()->beginTransaction();
        try {
            if (!$CustomCsvExport->getId()) {
                $CustomCsvExport->setDeletable(false);

                $em->createQueryBuilder()
                    ->update('Plugin\CustomCsvExport4\Entity\CustomCsvExport', 'cs')
                    ->getQuery();
            }

            $em->persist($CustomCsvExport);
            $em->flush();

            $em->getConnection()->commit();
        } catch (\Exception $e) {
            $em->getConnection()->rollback();

            return false;
        }

        return true;
    }

    /**
     * 設定SQLを削除する.
     *
     * @param CustomCsvExport $CustomCsvExport 削除対象の設定SQL
     * @return bool 成功した場合 true
     * @throws \Doctrine\DBAL\ConnectionException
     */
    public function delete($CustomCsvExport)
    {
        $em = $this->getEntityManager();
        $em->getConnection()->beginTransaction();
        try {
            $CustomCsvExport->setDeletable(true);
            $em->flush($CustomCsvExport);

            $em->getConnection()->commit();
        } catch (\Exception $e) {
            $em->getConnection()->rollback();

            return false;
        }

        return true;
    }
}

twigの作成

管理画面であるindex.twigCustomCsvExport4/Resource/template/adminディレクトリ直下に作成します。

{% extends '@admin/default_frame.twig' %}

{% set menus = ['setting', 'shop', 'admin_custom_csv_export'] %}

{% block title %}カスタムCSV出力{% endblock %}
{% block sub_title %}店舗設定{% endblock %}

{% form_theme form '@admin/Form/bootstrap_4_horizontal_layout.html.twig' %}

{% block javascript %}
    <script>
        $(function() {
            $('#register_btn').click(function() {
                $('#form1').attr('action', "{% if TargetCustomCsvExport.id %}{{ path('plugin_custom_csv_export_edit', {id: TargetCustomCsvExport.id}) }}{% else %}{{ url('plugin_custom_csv_export') }}{% endif %}");
                $('#form1').submit();
                return false;
            });

            $('#check_btn').click(function() {
                $('#form1').attr('action', "{% if TargetCustomCsvExport.id %}{{ path('plugin_custom_csv_export_edit_confirm', {id: TargetCustomCsvExport.id}) }}{% else %}{{ url('plugin_custom_csv_export_confirm') }}{% endif %}");
                $('#form1').submit();
                return false;
            });

            // 削除モーダルのhrefとmessageの変更
            $('#DeleteModal').on('shown.bs.modal', function(event) {
                var target = $(event.relatedTarget);
                // hrefの変更
                $(this).find('[data-method="delete"]').attr('href', target.data('url'));

                // messageの変更
                $(this).find('p.modal-message').text(target.data('message'));
            });
        });
    </script>
{% endblock %}

{% block main %}

    <form id="form1" name="form1" method="post">
        {{ form_widget(form._token) }}
        <div class="c-contentsArea__cols">
            <div class="c-contentsArea__primaryCol">
                <div class="c-primaryCol">
                    <div class="card rounded border-0 mb-4">
                        <div class="card-body p-0">
                            <div class="card rounded border-0">
                                <ul class="list-group list-group-flush sortable-container">
                                    {% if CustomCsvExports|length > 0 %}
                                        {% for CustomCsvExport in CustomCsvExports %}
                                            <li id="ex-delivery-{{ CustomCsvExport.id }}" class="list-group-item" data-id="{{ CustomCsvExport.id }}">
                                                <div class="row justify-content-around">
                                                    <div class="col d-flex align-items-center">
                                                        <a href="{{ url('plugin_custom_csv_export_edit', {id: CustomCsvExport.id}) }}">{{ CustomCsvExport.sql_name }}</a>
                                                    </div>
                                                    <div class="col-auto text-right">
                                                        <div class="d-inline-block mr-2" data-tooltip="true" data-placement="top" title="CSV出力">
                                                            <a class="btn btn-ec-actionIcon text-body" href="{{ url('plugin_custom_csv_export_output', {id: CustomCsvExport.id}) }}">
                                                                <i class="fas fa-file-export fa-lg"></i>
                                                            </a>
                                                        </div>
                                                        <div class="d-inline-block mr-2" data-tooltip="true" data-placement="top" title="{{ 'admin.common.delete'|trans }}">
                                                            <a class="btn btn-ec-actionIcon" data-toggle="modal" data-target="#DeleteModal"
                                                               data-url="{{ url('plugin_custom_csv_export_delete', {id: CustomCsvExport.id}) }}"
                                                               data-message="{{ 'admin.common.delete_modal__message'|trans({ "%name%" : CustomCsvExport.sql_name }) }}">
                                                                <i class="fas fa-close fa-lg"></i>
                                                            </a>
                                                        </div>
                                                    </div>
                                                </div>
                                            </li>
                                        {% endfor %}
                                    {% else %}
                                        <li class="list-group-item">
                                            <div class="row justify-content-around">
                                                <div class="col d-flex align-items-center">
                                                    データはありません。
                                                </div>
                                            </div>
                                        </li>
                                    {% endif %}
                                </ul>
                                <!-- 削除モーダル -->
                                <div class="modal fade" id="DeleteModal" tabindex="-1" role="dialog"
                                     aria-labelledby="DeleteModal" aria-hidden="true">
                                    <div class="modal-dialog" role="document">
                                        <div class="modal-content">
                                            <div class="modal-header">
                                                <h5 class="modal-title font-weight-bold">
                                                    {{ 'admin.common.delete_modal__title'|trans }}
                                                </h5>
                                                <button class="close" type="button" data-dismiss="modal" aria-label="Close">
                                                    <span aria-hidden="true">×</span>
                                                </button>
                                            </div>
                                            <div class="modal-body text-left">
                                                <p class="text-left modal-message"><!-- jsでメッセージを挿入 --></p>
                                            </div>
                                            <div class="modal-footer">
                                                <button class="btn btn-ec-sub" type="button" data-dismiss="modal">
                                                    {{ 'admin.common.cancel'|trans }}
                                                </button>
                                                <a class="btn btn-ec-delete" href="#" {{ csrf_token_for_anchor() }}
                                                   data-method="delete" data-confirm="false">
                                                    {{ 'admin.common.delete'|trans }}
                                                </a>
                                            </div>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>


                    <div class="card rounded border-0 mb-4">
                        <div class="card-header">
                            <div class="row">
                                <div class="col-8">
                                    <div class="d-inline-block">
                                        <span class="card-title">SQL設定
                                            {% if TargetCustomCsvExport.id %}
                                                (編集中:{{ TargetCustomCsvExport.sql_name }})
                                            {% else %}
                                                (新規入力)
                                            {% endif %}</span>
                                    </div>
                                </div>
                                <div class="col-4 text-right">
                                    <a data-toggle="collapse" href="#product-info" aria-expanded="false"
                                       aria-controls="freeArea">
                                        <i class="fa fa-angle-up fa-lg"></i>
                                    </a>
                                </div>
                            </div>
                        </div>
                        <div class="collapse show ec-cardCollapse" id="product-info">
                            <div class="card-body">
                                {% if message is defined and message is not null %}
                                    <div class="row">
                                        <div class="col-3">
                                            <div class="d-inline-block">
                                                <span>SQL確認結果</span>
                                            </div>
                                        </div>
                                        <div class="col mb-2 text-success">
                                            {{ message }}
                                        </div>
                                    </div>
                                {% endif %}

                                {{ form_widget(form._token) }}
                                {{ form_row(form.sql_name, {attr: {placeholder: '保存するSQL名を入力'}}) }}
                                {{ form_row(form.custom_sql, {attr: {'rows': 10, placeholder: 'SQL文を入力、SQL文には読み込み関係以外のSQLコマンドおよび";"記号は入力できません。'}}) }}
                                <div class="col-sm-0 col-sm-offset-3 col-md-12 col-md-offset-0">
                                    <button id="check_btn" class="btn btn-ec-conversion btn-block px-5">SQLチェック</button>
                                </div>

                            </div>
                        </div>
                    </div>
                </div>
            </div>

            <div class="c-conversionArea">
                <div class="c-conversionArea__container">
                    <div class="row justify-content-between align-items-center">
                        <div class="col-6">
                            <div class="c-conversionArea__leftBlockItem"></div>
                        </div>
                        <div class="col-6">
                            <div id="ex-conversion-action" class="row align-items-center justify-content-end">
                                <div class="col-auto">
                                    <button id="register_btn" type="submit" class="btn btn-ec-conversion px-5">{{ 'admin.common.registration'|trans }}</button>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </form>

{% endblock %}

入力チェックの作成

入力チェック用クラスSqlCheckValidator.phpSqlCheck.phpCustomCsvExport4/Validatorディレクトリ直下に作成します。

<?php

namespace Plugin\CustomCsvExport4\Validator;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

class SqlCheckValidator extends ConstraintValidator
{
    /**
     * {@inheritdoc}
     */
    public function validate($value, Constraint $constraint)
    {
        if (!$constraint instanceof SqlCheck) {
            throw new UnexpectedTypeException($constraint, __NAMESPACE__.'\SqlCheck');
        }

        $error = $this->sqlValidation($value);
        if ($error) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $this->formatValue($value))
                ->addViolation();
        }

        if (false === $value || (empty($value) && '0' != $value)) {
            if ($this->context instanceof ExecutionContextInterface) {
                $this->context->buildViolation($constraint->message)
                    ->setParameter('{{ value }}', $this->formatValue($value))
                    ->addViolation();
            } else {
                $constraint->message = 'SQL文を入力してください。';
                $this->context->buildViolation($constraint->message)
                    ->setParameter('{{ value }}', $this->formatValue($value))
                    ->addViolation();
            }
        }
    }

    /**
     * SQLの入力チェック.
     *
     * @param $sql SQL文
     * @return bool
     */
    private function sqlValidation($sql)
    {
        // 入力チェック
        $error = false;

        $denyList = $this->lfGetSqlDenyList();

        $prohibitedStr = str_replace(array('|', '/'), array('\|', '\/'), $denyList);
        $pattern = '/'.join('|', $prohibitedStr).'/i';
        if (preg_match_all($pattern, $sql, $matches)) {
            $error = true;
        }

        return $error;
    }

    /**
     * SQL文に含めることを許可しないSQLキーワード
     * 基本的にEC-CUBEのデータを取得するために必要なコマンドしか許可しない。複数クエリも不可.
     *
     * FIXME: キーワードの精査。危険な部分なのでプログラム埋め込みで実装しました。mtb化の有無判断必要。
     *
     * @return string[] 不許可ワード配列
     */
    private function lfGetSqlDenyList()
    {
        $arrList = array(
            ';',
            'CREATE\s',
            'INSERT\s',
            'UPDATE\s',
            'DELETE\s',
            'ALTER\s',
            'ABORT\s',
            'ANALYZE\s',
            'CLUSTER\s',
            'COMMENT\s',
            'COPY\s',
            'DECLARE\s',
            'DISCARD\s',
            'DO\s',
            'DROP\s',
            'EXECUTE\s',
            'EXPLAIN\s',
            'GRANT\s',
            'LISTEN\s',
            'LOAD\s',
            'LOCK\s',
            'NOTIFY\s',
            'PREPARE\s',
            'REASSIGN\s',
            'RELEASE\sSAVEPOINT',
            'RENAME\s',
            'REST\s',
            'REVOKE\s',
            'SAVEPOINT\s',
            '\sSET\s', // OFFSETを誤検知しないように先頭・末尾に\sを指定
            'SHOW\s',
            'START\sTRANSACTION',
            'TRUNCATE\s',
            'UNLISTEN\s',
            'VACCUM\s',
            'HANDLER\s',
            'LOAD\sDATA\s',
            'LOAD\sXML\s',
            'REPLACE\s',
            'OPTIMIZE\s',
            'REPAIR\s',
            'INSTALL\sPLUGIN\s',
            'UNINSTALL\sPLUGIN\s',
            'BINLOG\s',
            'KILL\s',
            'RESET\s',
            'PURGE\s',
            'CHANGE\sMASTER',
            'START\sSLAVE',
            'STOP\sSLAVE',
            'MASTER\sPOS\sWAIT',
            'SIGNAL\s',
            'RESIGNAL\s',
            'RETURN\s',
            'USE\s',
            'HELP\s',
        );

        return $arrList;
    }
}
<?php

namespace Plugin\CustomCsvExport4\Validator;

use Symfony\Component\Validator\Constraint;

class SqlCheck extends Constraint
{
    public $message = 'SQL文が不正です。SQL文を見直してください';
}

ディレクトリ構成

今回作成したディレクトリ構成は以下のようになります。

CustomCsvExport4
├── Controller
│ └── CustomCsvExportController.php
├── CustomCsvExportNav.php
├── Entity
│ └── CustomCsvExport.php
├── Form
│ └── Type
│     └── CustomCsvExportType.php
├── PluginManager.php
├── Repository
│ └── CustomCsvExportRepository.php
├── Resource
│ └── template
│     └── admin
│         └── index.twig
├── Validator
│ ├── SqlCheck.php
│ └── SqlCheckValidator.php
└── composer.json

プラグインのインストール

プラグインをインストールする方法は2種類あり、

がありますが、今回はコマンドラインからインストールする方法とします。

コマンドプロンプトやターミナルで、EC-CUBEルートディレクトリまで移動後、

php bin/console eccube:plugin:install --code=CustomCsvExport4

を実行後、Installed.と表示されるとプラグインがインストールされます。

プラグインをインストール後、管理画面の「オーナーズストア」→「プラグイン」→「プラグイン一覧」から有効化してください。

「設定」→「店舗設定」に「カスタムCSV出力」が表示されるようになります。

GitHubからDownloadされた方はそのままでは追加できないため、解凍後に以下のURLを参考にして再圧縮後、

doc4.ec-cube.net

管理画面から「オーナーズストア」→「プラグイン」→「プラグイン一覧」から「アップロードして新規追加」で登録してください。

カスタムCSV出力が必要な方はこちらを参考にしてください。

EC-CUBE3でhttpキャッシュを有効にする方法

EC-CUBE Advent Calendar 2021 18日目の記事です。

元々ec-cube.coについて書くつもりでしたが、諸事情により別記事を掲載します。

EC-CUBE3では、パフォーマンスを上げるためにhttpキャッシュという機能が用意されています。

doc.ec-cube.net

4系にはこちらの機能はありませんのでご注意ください。

こちらを有効にする事でレスポンスが良くなるのですが、httpキャッシュを有効にすると正常に会員登録がされないという不具合があります。それを回避するために以下の修正を行なってください。

index.phpの修正

  • html/index.php
if ($app['config']['http_cache']['enabled']) {
    $app['http_cache']->run();
} else {
    $app->run();
}

と記載されている箇所を、

$url = parse_url($_SERVER['REQUEST_URI']);
$cacheRoute = $app['config']['http_cache']['route'];

if (in_array($url['path'], $cacheRoute) === true) {
    $app['http_cache']->run();
} else {
    $app->run();
}

に変更してください。

Application.phpの修正

  • src/Eccube/Application.php
// フロント画面
$cacheRoute = $app['config']['http_cache']['route'];

if (in_array($route, $cacheRoute) === true) {

と記載されている箇所を

// フロント画面
$cacheRoute = $app['config']['http_cache']['route'];

$path = $request->getPathInfo();
if (in_array($path, $cacheRoute) === true) {

に変更してください。

http_cache.yml.distの修正

route名で記載されていた内容を実際のURLに変更します。

  • src/Eccube/Resource/config/http_cache.yml.dist
http_cache:
    enabled: true
    age: 10
    # フロントでキャッシュを適用させる画面のrouteを設定
    route:
        - /
        - /products/list
        - /block/category
        - /block/news
        - /block/search_product
        - /help/about
        - /help/guide
        - /help/privacy
        - /help/tradelaw
        - /help/agreement

こちらは、app/config/eccube/http_cache.ymlに保存しても問題ありません。

以上の修正でhttpキャッシュが正常に動作するようになります。ただし、ドキュメントにも記載があるように

  • Tokenを利用している画面にはhttpキャッシュを適用させない

という事は必ずお守りください。 もしパフォーマンスにお悩みの方はhttpキャッシュをお試しください。

ec-cube.coについての記事が気になる方はコメントしてください。個別に対応させていただきます。

2系にあった商品並び替え機能をEC-CUBE4系で実装する方法

EC-CUBE Advent Calendar 2021 17日目の記事です。

EC-CUBE2では「商品並び替え」機能が存在していましたが、EC-CUBE3系からはその機能が無くなっています。

今回は「商品並び替え」機能をEC-CUBE4系で実装する方法を説明します。EC-CUBE3系も読み替えれば実現可能と思いますが、今回は説明を割愛します。

ProductCategoryクラスの修正

ProductCategoryクラスに並び順という項目を追加します。

  • src/Eccube/Entity/ProductCategory.php
<?php
〜
〜

/**
 * @var int
 *
 * @ORM\Column(name="sort_no", type="smallint", options={"unsigned":true, "default":0})
 */
private $sort_no = 0;

/**
 * Set sortNo.
 *
 * @param int $sortNo
 *
 * @return ProductCategory
 */
public function setSortNo($sortNo)
{
    $this->sort_no = $sortNo;

    return $this;
}

/**
 * Get sortNo.
 *
 * @return int
 */
public function getSortNo()
{
    return $this->sort_no;
}

追加後、doctrineコマンドを利用してdtb_product_categoryテーブルへカラムを追加します。

EC-CUBEディレクトリ直下にコマンドプロンプトやターミナルで移動します。

先ず、どのようなSQL文が実行されるかを以下のコマンドで確認します。

php bin/console doctrine:schema:update --dump-sql

実際にSQL文をするために、以下のコマンドを実行します。

php bin/console doctrine:schema:update --force

コマンド実行後、エラーが発生しなければDBにdtb_product_categoryテーブルにsort_noというカラムが作成されます。

商品並び替え管理画面の作成

次に、商品並び替え機能用の管理画面を作成します。以下のController、FormType、twigファイルを作成してください。

  • src/Eccube/Controller/Admin/Product/ProductSortController.php
<?php

namespace Eccube\Controller\Admin\Product;

use Eccube\Controller\AbstractController;
use Eccube\Entity\Category;
use Eccube\Entity\ProductCategory;
use Eccube\Form\Type\Admin\ProductCategoryType;
use Eccube\Repository\CategoryRepository;
use Eccube\Repository\ProductCategoryRepository;
use Eccube\Util\CacheUtil;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;

class ProductSortController extends AbstractController
{
    /**
     * @var ProductCategoryRepository
     */
    protected $productCategoryRepository;

    /**
     * @var CategoryRepository
     */
    protected $categoryRepository;

    /**
     * ProductSortController constructor.
     *
     * @param ProductCategoryRepository $productCategoryRepository
     * @param CategoryRepository $categoryRepository
     */
    public function __construct(ProductCategoryRepository $productCategoryRepository, CategoryRepository $categoryRepository)
    {
        $this->productCategoryRepository = $productCategoryRepository;
        $this->categoryRepository = $categoryRepository;
    }


    /**
     * @Route("/%eccube_admin_route%/product/sort", name="admin_product_sort")
     * @Route("/%eccube_admin_route%/product/sort/{parent_id}", requirements={"parent_id" = "\d+"}, name="admin_product_sort_show")
     * @Route("/%eccube_admin_route%/product/sort/{id}/edit", requirements={"id" = "\d+"}, name="admin_product_sort_edit")
     * @Template("@admin/Product/product_sort.twig")
     */
    public function index(Request $request, $parent_id = null, $id = null, CacheUtil $cacheUtil)
    {
        if ($parent_id) {
            /** @var Category $Parent */
            $Parent = $this->categoryRepository->find($parent_id);
            if (!$Parent) {
                throw new NotFoundHttpException();
            }
        } else {
            $Parent = null;
        }
        if ($id) {
            $TargetCategory = $this->categoryRepository->find($id);
            if (!$TargetCategory) {
                throw new NotFoundHttpException();
            }
            $Parent = $TargetCategory->getParent();
        } else {
            $TargetCategory = new Category();
            $TargetCategory->setParent($Parent);
            if ($Parent) {
                $TargetCategory->setHierarchy($Parent->getHierarchy() + 1);
            } else {
                $TargetCategory->setHierarchy(1);
            }
        }

        $ProductCategories = [];

        if ($Parent) {
            $ProductCategories = $this->productCategoryRepository->findBy(['Category' => $Parent], ['sort_no' => 'DESC']);
        }

        // ツリー表示のため、ルートからのカテゴリを取得
        $TopCategories = $this->categoryRepository->getList(null);

        $builder = $this->formFactory
            ->createBuilder(ProductCategoryType::class);

        $form = $builder->getForm();

        $forms = [];
        /** @var ProductCategory $ProductCategory */
        foreach ($ProductCategories as $ProductCategory) {
            $forms[$ProductCategory->getProductId()] = $this->formFactory
                ->createNamed('product_category_'.$ProductCategory->getProductId(), ProductCategoryType::class, $ProductCategory);
        }

        if ($request->getMethod() === 'POST') {
            $form->handleRequest($request);
            if ($form->isValid()) {
                if ($this->eccubeConfig['eccube_category_nest_level'] < $TargetCategory->getHierarchy()) {
                    throw new BadRequestHttpException();
                }
                log_info('カテゴリ登録開始', [$id]);

                $this->productCategoryRepository->save($TargetCategory);
                $this->entityManager->flush();

                log_info('カテゴリ登録完了', [$id]);

                $this->addSuccess('admin.common.save_complete', 'admin');

                $cacheUtil->clearDoctrineCache();

                if ($Parent) {
                    return $this->redirectToRoute('admin_product_sort_show', ['parent_id' => $Parent->getId()]);
                } else {
                    return $this->redirectToRoute('admin_product_sort');
                }
            }

            foreach ($forms as $editForm) {
                $editForm->handleRequest($request);
                if ($editForm->isSubmitted() && $editForm->isValid()) {
                    $this->productCategoryRepository->save($editForm->getData());
                    $this->entityManager->flush();

                    $this->addSuccess('admin.common.save_complete', 'admin');

                    $cacheUtil->clearDoctrineCache();

                    if ($Parent) {
                        return $this->redirectToRoute('admin_product_sort_show', ['parent_id' => $Parent->getId()]);
                    } else {
                        return $this->redirectToRoute('admin_product_sort');
                    }
                }
            }
        }

        $formViews = [];
        foreach ($forms as $key => $value) {
            $formViews[$key] = $value->createView();
        }

        $Ids = [];
        if ($Parent && $Parent->getParents()) {
            foreach ($Parent->getParents() as $item) {
                $Ids[] = $item['id'];
            }
        }
        $Ids[] = intval($parent_id);

        return [
            'form' => $form->createView(),
            'Parent' => $Parent,
            'Ids' => $Ids,
            'ProductCategories' => $ProductCategories,
            'TopCategories' => $TopCategories,
            'TargetCategory' => $TargetCategory,
            'forms' => $formViews,
            'parent_id' => $parent_id,
        ];
    }

    /**
     * @Route("/%eccube_admin_route%/product/sort/sort_no/move/{category_id}", name="admin_product_sort_sort_no_move", methods={"POST"})
     */
    public function moveSortNo(Request $request, CacheUtil $cacheUtil, $category_id = null)
    {
        if (!$request->isXmlHttpRequest()) {
            throw new BadRequestHttpException();
        }

        if ($this->isTokenValid()) {
            $sortNos = $request->request->all();
            foreach ($sortNos as $id => $sortNo) {
                $ProductCategory = $this->productCategoryRepository
                    ->findOneBy(['product_id' => $id, 'category_id' => $category_id]);
                $ProductCategory->setSortNo($sortNo);
                $this->entityManager->persist($ProductCategory);
            }
            $this->entityManager->flush();

            $cacheUtil->clearDoctrineCache();

            return new Response('Successful');
        }
    }

}
  • src/Eccube/Form/Type/Admin/ProductCategoryType.php
<?php

namespace Eccube\Form\Type\Admin;

use Eccube\Common\EccubeConfig;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

class ProductCategoryType extends AbstractType
{
    /**
     * @var EccubeConfig
     */
    protected $eccubeConfig;

    /**
     * ProductCategoryType constructor.
     *
     * @param EccubeConfig $eccubeConfig
     */
    public function __construct(EccubeConfig $eccubeConfig)
    {
        $this->eccubeConfig = $eccubeConfig;
    }

    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('sort_no', IntegerType::class, [
                'label' => '並び順(数字が大きいほど先に表示)',
                'required' => false,
                'constraints' => [
                    new Assert\Length([
                        'max' => $this->eccubeConfig['eccube_int_len'],
                    ]),
                    new Assert\Regex([
                        'pattern' => '/^[0-9\-]+$/u',
                        'message' => 'form_error.numeric_only',
                    ]),
                ],
            ]);
    }

    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'Eccube\Entity\ProductCategory',
        ]);
    }

    /**
     * {@inheritdoc}
     */
    public function getBlockPrefix()
    {
        return 'admin_product_category';
    }
}
  • app/template/admin/Product/product_sort.twig
{% extends '@admin/default_frame.twig' %}

{% set menus = ['product', 'product_sort'] %}

{% block title %}商品並び替え{% endblock %}
{% block sub_title %}{{ 'admin.product.product_management'|trans }}{% endblock %}

{% form_theme form '@admin/Form/bootstrap_4_horizontal_layout.html.twig' %}

{% block javascript %}
    <script src="{{ asset('assets/js/vendor/jquery.ui/jquery.ui.core.min.js', 'admin') }}"></script>
    <script src="{{ asset('assets/js/vendor/jquery.ui/jquery.ui.widget.min.js', 'admin') }}"></script>
    <script src="{{ asset('assets/js/vendor/jquery.ui/jquery.ui.mouse.min.js', 'admin') }}"></script>
    <script src="{{ asset('assets/js/vendor/jquery.ui/jquery.ui.sortable.min.js', 'admin') }}"></script>
    <script>
        $(function() {
            // 初期表示時のsort noを保持.
            var oldSortNos = [];
            $('.sortable-item').each(function() {
                oldSortNos.push(this.dataset.sortNo);
            });
            oldSortNos.sort(function(a, b) {
                return a - b;
            }).reverse();
            // 並び替え後にsort noを更新
            var updateSortNo = function() {
                var newSortNos = {};
                var i = 0;
                $('.sortable-item').each(function() {
                    newSortNos[this.dataset.id] = oldSortNos[i];
                    $('.sort-no', $(this)).text(oldSortNos[i]);
                    i++;
                });
                $.ajax({
                    url: '{{ url('admin_product_sort_sort_no_move', {'category_id': parent_id}) }}',
                    type: 'POST',
                    data: newSortNos
                }).always(function() {
                    $(".modal-backdrop").remove();
                });
            };
            // 最初と最後の↑↓を再描画
            var redrawDisableAllows = function() {
                var items = $('.sortable-item');
                items.find('a.action-up').removeClass('disabled');
                items.find('a.action-down').removeClass('disabled');
                items.first().find('a.action-up').addClass('disabled');
                items.last().find('a.action-down').addClass('disabled');
            };
            // オーバレイ後, 表示順の更新を行う
            var moveSortNo = function() {
                $('body').append($('<div class="modal-backdrop show"></div>'));
                updateSortNo();
                redrawDisableAllows();
            };
            // Drag and Drop
            $('.sortable-container').sortable({
                items: '> .sortable-item',
                cursor: 'move',
                update: function(e, ui) {
                    moveSortNo();
                }
            });
            // Up
            $('.sortable-item').on('click', 'a.action-up', function(e) {
                e.preventDefault();
                var current = $(this).parents("li");
                if (current.prev().hasClass('sortable-item')) {
                    current.prev().before(current);
                    moveSortNo();
                }
            });
            // Down
            $('.sortable-item').on('click', 'a.action-down', function(e) {
                e.preventDefault();
                var current = $(this).parents("li");
                if (current.next().hasClass('sortable-item')) {
                    current.next().after(current);
                    moveSortNo();
                }
            });

            var groupItem = $('.list-group-item');
            groupItem.on('click', 'a.action-edit', function(e) {
                e.preventDefault();
                var current = $(this).parents('li');
                current.find('.mode-view').addClass('d-none');
                current.find('.mode-edit').removeClass('d-none');
            });

            groupItem.on('click', 'button.action-edit-cancel', function(e) {
                e.preventDefault();
                var current = $(this).parents('li');
                current.find('[data-origin-value]').each(function(e) {
                    $(this).val($(this).attr('data-origin-value'));
                });
                current.find('.mode-view').removeClass('d-none');
                current.find('.mode-edit').addClass('d-none');
            });

            groupItem.find('.is-invalid').each(function(e) {
                e.preventDefault();
                var current = $(this).parents("li");
                current.find('.mode-view').addClass('d-none');
                current.find('.mode-edit').removeClass('d-none');
            });

        });
    </script>
{% endblock %}

{% block stylesheet %}
    {#TODO: Move to css file#}
    <style>
        .c-directoryTree ul > li > ul li:not(:last-of-type) > label:before,
        .c-directoryTree ul > li > ul li:last-of-type > label:before {
            margin-right: 1.6em;
        }
    </style>
{% endblock %}

{% block main %}
    <div class="c-outsideBlock">
        <div class="c-outsideBlock__contents mb-2">
            <div class="row">
                <div class="col-6">
                    <nav aria-label="breadcrumb" role="navigation">
                        <ol class="breadcrumb mb-2 p-0">
                            <li class="breadcrumb-item">
                                <a href="{{ url('admin_product_sort') }}">
                                    {{ 'admin.product.category_all'|trans }}
                                </a>
                            </li>
                            {% for ParentCategory in TargetCategory.path %}
                                {% if ParentCategory.id is not null %}
                                    <li class="breadcrumb-item active" aria-current="page">
                                        <a href="{{ url('admin_product_sort_show', { parent_id : ParentCategory.id }) }}">
                                            {{ ParentCategory.name }}
                                        </a>
                                    </li>
                                {% endif %}
                            {% endfor %}
                        </ol>
                    </nav>
                </div>
            </div>
        </div>
    </div>
    <div class="c-contentsArea__cols">
        <div class="c-contentsArea__primaryCol">
            <div id="ex-primaryCol" class="c-primaryCol">
                <div class="card rounded border-0 mb-4">
                    <div class="card-body p-0">
                        <div class="card rounded border-0">
                            <ul class="list-group list-group-flush sortable-container">
                                <li class="list-group-item">
                                    <div class="row">
                                        <div class="col-1"><strong>&nbsp;</strong></div>
                                        <div class="col-1"><strong>ID</strong></div>
                                        <div class="col-2"><strong>コード</strong></div>
                                        <div class="col-3"><strong>商品名</strong></div>
                                        <div class="col-1"><strong>価格</strong></div>
                                        <div class="col-1"><strong>順番</strong></div>
                                        <div class="col-3"><strong>&nbsp;</strong></div>
                                    </div>
                                </li>
                                {% if ProductCategories|length > 0 %}
                                    {% for ProductCategory in ProductCategories %}
                                        <li id="ex-category-{{ ProductCategory.product_id }}" class="list-group-item sortable-item" data-id="{{ ProductCategory.product_id }}" data-sort-no="{{ ProductCategory.sort_no }}">
                                            {% if ProductCategory.category_id != TargetCategory.id %}
                                                <div class="row justify-content-around mode-view">
                                                    <div class="col-1 d-flex align-items-center"><i class="fa fa-bars text-ec-gray"></i></div>
                                                    <div class="col-1 d-flex align-items-center">{{ ProductCategory.product_id }}</div>
                                                    <div class="col-2 d-flex align-items-center">
                                                        {{ ProductCategory.Product.code_min }}
                                                        {% if ProductCategory.Product.code_min != ProductCategory.Product.code_max %}<br>{{ 'admin.common.separator__range'|trans }}<br>{{ ProductCategory.Product.code_max }}
                                                        {% endif %}
                                                    </div>
                                                    <div class="col-3 d-flex align-items-center">
                                                        <a href="{{ url('admin_product_product_edit',  {'id': ProductCategory.product_id}) }}">{{ ProductCategory.Product.name }}</a>
                                                    </div>
                                                    <div class="col-1 d-flex align-items-center">
                                                        {{ ProductCategory.Product.price02_min|price }}
                                                        {% if ProductCategory.Product.price02_min != ProductCategory.Product.price02_max %}<br>{{ 'admin.common.separator__range'|trans }}<br>{{ ProductCategory.Product.price02_max|price }}
                                                        {% endif %}
                                                    </div>
                                                    <div class="col-1 d-flex align-items-center sort-no">{{ ProductCategory.sort_no }}</div>
                                                    <div class="col-3 text-right">
                                                        <a class="btn btn-ec-actionIcon action-up mr-2 {% if loop.first %} disabled {% endif %}" href=""
                                                           data-tooltip="true" data-placement="top"
                                                           title="{{ 'admin.common.up'|trans }}">
                                                            <i class="fa fa-arrow-up fa-lg text-secondary"></i>
                                                        </a>
                                                        <a class="btn btn-ec-actionIcon action-down mr-2 {% if loop.last %} disabled {% endif %}" href=""
                                                           data-tooltip="true" data-placement="top"
                                                           title="{{ 'admin.common.down'|trans }}">
                                                            <i class="fa fa-arrow-down fa-lg text-secondary"></i>
                                                        </a>
                                                        <a class="btn btn-ec-actionIcon mr-2 action-edit"
                                                           href="{{ url('admin_product_category_edit', {id: ProductCategory.product_id}) }}"
                                                           data-tooltip="true" data-placement="top"
                                                           title="{{ 'admin.common.edit'|trans }}">
                                                            <i class="fa fa-pencil fa-lg text-secondary"></i>
                                                        </a>
                                                    </div>
                                                </div>
                                                <form class="form-row d-none mode-edit" method="POST" action="{{ (Parent and Parent.id) ? url('admin_product_sort_show', {'parent_id': Parent.id}) : url('admin_product_sort') }}" enctype="multipart/form-data">
                                                    {{ form_widget(forms[ProductCategory.product_id]._token) }}
                                                    <div class="col-auto align-items-center">
                                                        {{ form_widget(forms[ProductCategory.product_id].sort_no, {'attr': {'data-origin-value': forms[ProductCategory.product_id].sort_no.vars.value}}) }}
                                                        {{ form_errors(forms[ProductCategory.product_id].sort_no) }}
                                                    </div>
                                                    <div class="col-auto align-items-center">
                                                        <button class="btn btn-ec-conversion" type="submit">{{ 'admin.common.decision'|trans }}</button>
                                                    </div>
                                                    <div class="col-auto align-items-center">
                                                        <button class="btn btn-ec-sub action-edit-cancel" type="button">{{ 'admin.common.cancel'|trans }}</button>
                                                    </div>
                                                </form>
                                            {% endif %}
                                        </li>
                                    {% endfor %}
                                {% else %}
                                    <li class="list-group-item">
                                        カテゴリを選択してください。
                                    </li>
                                {% endif %}
                            </ul>

                        </div>
                    </div>
                </div>
                <p>{{ 'admin.common.drag_and_drop_description'|trans }}</p>
            </div>
        </div>

        {% macro tree(Category, TargetId, level, Ids) %}
            {% import _self as selfMacro %}
            {% set level = level + 1 %}
            <li>
                <label {% if (Category.children|length > 0) and (Category.id not in Ids) %}class="collapsed"
                       {% endif %}data-toggle="collapse"
                       href="#directory_category{{ Category.id }}"
                       aria-expanded="{% if Category.id in Ids %}true{% endif %}"
                       aria-controls="directory_category{{ Category.id }}"></label>
                <span>
                    <a href="{{ url('admin_product_sort_show', { parent_id : Category.id }) }}"{% if (Category.id == TargetId) %} class="font-weight-bold"{% endif %}>{{ Category.name }}
                        ({{ Category.children|length }})</a></span>
                {% if Category.children|length > 0 %}
                    <ul class="collapse list-unstyled {% if Category.id in Ids %}show{% endif %}"
                        id="directory_category{{ Category.id }}">
                        {% for ChildCategory in Category.children %}
                            {{ selfMacro.tree(ChildCategory, TargetId, level, Ids) }}
                        {% endfor %}
                    </ul>
                {% endif %}
            </li>
        {% endmacro %}

        <div class="c-contentsArea__secondaryCol">
            <div id="ex-secondaryCol" class="c-secondaryCol">
                <div class="card rounded border-0 mb-4">
                    <div class="card-header">
                        <span class="card-title"><a href="{{ url('admin_product_sort') }}">{{ 'admin.product.category_all'|trans }}</a></span>
                    </div>
                    <div class="card-body">
                        <div class="c-directoryTree mb-3">
                            {% import _self as renderMacro %}
                            {% for TopCategory in TopCategories %}
                                <ul class="list-unstyled">
                                    {{ renderMacro.tree(TopCategory, TargetCategory.Parent.id | default(null), 0, Ids) }}
                                </ul>
                            {% endfor %}
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
{% endblock %}

それぞれファイルを作成後、管理画面のメニューを表示させるために以下のファイルを修正します。

  • app/config/eccube/packages/eccube_nav.yaml
parameters:
    eccube_nav:
        product:
            name: admin.product.product_management
            icon: fa-cube
            children:
                product_master:
                    name: admin.product.product_list
                    url: admin_product
                product_edit:
                    name: admin.product.product_registration
                    url: admin_product_product_new
                class_name:
                    name: admin.product.class_management
                    url: admin_product_class_name
                class_category:
                    name: admin.product.category_management
                    url: admin_product_category
                product_tag:
                    name: admin.product.tag_management
                    url: admin_product_tag
                product_csv_import:
                    name: admin.product.product_csv_upload
                    url: admin_product_csv_import
                category_csv_import:
                    name: admin.product.category_csv_upload
                    url: admin_product_category_csv_import
                product_sort:
                    name: 商品並び替え
                    url: admin_product_sort

eccube_nav.yamlファイルに対して、

product_sort:
    name: 商品並び替え
    url: admin_product_sort

を追加します。

以上で商品並び替え管理画面が作成されます。

ProductRepositoryの変更

商品並び替え機能を動作させるために、ProductRepositoryの変更を行います。

  • src/Eccube/Repository/ProductRepository.php
<?php
〜
〜
〜

/**
 * get query builder.
 *
 * @param  array $searchData
 *
 * @return \Doctrine\ORM\QueryBuilder
 */
public function getQueryBuilderBySearchData($searchData)
{
〜
〜
〜
    // category
    $categoryJoin = false;
    if (!empty($searchData['category_id']) && $searchData['category_id']) {
        $Categories = $searchData['category_id']->getSelfAndDescendants();
        if ($Categories) {
            $qb
                ->innerJoin('p.ProductCategories', 'pct')
                ->innerJoin('pct.Category', 'c')
                ->andWhere($qb->expr()->in('pct.Category', ':Categories'))
                ->setParameter('Categories', $Categories)
                ->addOrderBy('c.hierarchy', 'DESC')
                ->addOrderBy('c.sort_no', 'DESC')
                ->addOrderBy('pct.sort_no', 'DESC');
            $categoryJoin = true;
        }
    }
〜
〜
〜
    // Order By
    // 標準
    if ($categoryJoin === false) {
        $qb
            ->leftJoin('p.ProductCategories', 'pct')
            ->leftJoin('pct.Category', 'c');

        $qb->addOrderBy('c.sort_no', 'DESC');
        $qb->addOrderBy('pct.sort_no', 'DESC');
        $qb->addOrderBy('c.hierarchy', 'ASC');
    }
    // 価格低い順
    $config = $this->eccubeConfig;
    if (!empty($searchData['orderby']) && $searchData['orderby']->getId() == $config['eccube_product_order_price_lower']) {
〜
〜
        $qb->addOrderBy('price02_min', 'ASC');
〜
〜
        // 価格高い順
    } elseif (!empty($searchData['orderby']) && $searchData['orderby']->getId() == $config['eccube_product_order_price_higher']) {
〜
〜
        $qb->addOrderBy('price02_max', 'DESC');
〜
〜
        // 新着順
    } elseif (!empty($searchData['orderby']) && $searchData['orderby']->getId() == $config['eccube_product_order_newer']) {
〜
〜
        $qb->addOrderBy('p.create_date', 'DESC');
〜
〜

こちらの修正では何を行っているかというと、dtb_product_categoryテーブルに追加したsort_noをソート順として追加して並び替えするように変更し、$qb->orderByから$qb->addOrderByに修正を行なっています。

ProductControllerの変更

検索結果が表示されるようにProdutControllerの変更を行います。変更箇所は以下の通りです。

  • src/Eccube/Controller/ProductController.php
<?php
〜
〜
/** @var SlidingPagination $pagination */
$pagination = $paginator->paginate(
    $query,
    !empty($searchData['pageno']) ? $searchData['pageno'] : 1,
    !empty($searchData['disp_number']) ? $searchData['disp_number']->getId() : $this->productListMaxRepository->findOneBy([], ['sort_no' => 'ASC'])->getId(),
    ['wrap-queries' => true]
);
〜
〜

index関数内にある$pagination変数に設定する値に対して、['wrap-queries' => true]を追加しています。

管理画面用ProductControllerの変更

管理画面で商品登録された時にも修正が必要ですので、以下の修正を行います。

  • src/Eccube/Controller/Admin/Product/ProductController.php
<?php
〜
〜
// 追加
$ProductCategories = clone $Product->getProductCategories();

// カテゴリの登録
// 一度クリア
〜
〜
〜
〜
    if (!isset($categoriesIdList[$ParentCategory->getId()])) {
        // 引数を追加
        $ProductCategory = $this->createProductCategory($Product, $ParentCategory, $count, $ProductCategories);
〜
〜
    if (!isset($categoriesIdList[$Category->getId()])) {
        // 引数を追加
        $ProductCategory = $this->createProductCategory($Product, $Category, $count, $ProductCategories);
〜
〜
〜
〜
/**
 * ProductCategory作成
 *
 * @param \Eccube\Entity\Product $Product
 * @param \Eccube\Entity\Category $Category
 * @param integer $count
 * @param $ProductCategories
 *
 * @return \Eccube\Entity\ProductCategory
 */
private function createProductCategory($Product, $Category, $count, $ProductCategories)
{
    $ProductCategory = new ProductCategory();
    $ProductCategory->setProduct($Product);
    $ProductCategory->setProductId($Product->getId());
    $ProductCategory->setCategory($Category);
    $ProductCategory->setCategoryId($Category->getId());

    /** @var ProductCategory $item */
    foreach ($ProductCategories as $item) {
        if ($item->getCategory()->getId() == $Category->getId()) {
            $ProductCategory->setSortNo($item->getSortNo());
            break;
        } else {
            $ProductCategory->setSortNo($count);
        }
    }

    return $ProductCategory;
}

edit関数内で、sort_noを追加したことによる修正を行います。

以上で商品並び替え機能が実装できます。商品CSV登録にも対応が必要となりますが、管理画面の商品登録時と同じような実装をsrc/Eccube/Controller/Admin/Product/CsvImportController.phpに対して行えば対応可能です。

もし動作しないとかありましたらコメントしてください。

データ移行プラグインで対応していないプラグインの移行方法カスタマイズ

EC-CUBE Advent Calendar 2021 15日目の記事です。

EC-CUBE2系や3系からデータ移行する場合、お手軽な方法としてデータ移行プラグインというプラグインが存在しています。

www.ec-cube.net

このプラグインは便利なのですが、

が移行対象となっていません。こちらの移行対応方法を説明します。現在公開されている最新のプラグインバージョン4.1.0を対象に説明します。

今回は2系からのデータ移行を対象としているのでご注意ください。

対象となるプラグインをインストール

今回利用したいプラグインを先にインストールしてください。対象となるプラグインの対比は以下となります。

移行元テーブル 利用プラグイン
dtb_maker メーカープラグイン
dtb_best_products おすすめ商品管理プラグイン
dtb_review 商品レビュープラグイン
dtb_recommend_products 関連商品プラグイン

※利用されていない方はインストールする必要はありません。

また、dtb_news(新着情報)については既存機能のため、プラグインのインストールは不要です。

データ移行プラグインのカスタマイズ

オーナーズストアからデータ移行プラグインをインストール後、以下のファイルに対してカスタマイズを行います。

  • app/Plugin/DataMigration4/Controller/Admin/ConfigController.php
<?php
~
~
~
// 会員・受注のみ移行
if ($form['customer_order_only']->getData()) {
    $this->saveCustomerAndOrder($em, $csvDir);
// 全データ移行
} else {
    $this->saveCustomer($em, $csvDir);
    $this->saveProduct($em, $csvDir);
    $this->saveOrder($em, $csvDir);
    $this->saveOther($em, $csvDir);
    $this->savePlugin($em, $csvDir);
}
~
~
~

138行目にある// 会員・受注のみ移行にのif文に対して$this->saveOther($em, $csvDir)$this->savePlugin($em, $csvDir)という関数を追加します。その後、以下の内容を記述します。

  • app/Plugin/DataMigration4/Controller/Admin/ConfigController.php
<?php
~
~
~
private function saveOther($em, $csvDir)
{
    // プラグイン系
    $em->beginTransaction();

    $platform = $em->getDatabasePlatform()->getName();

    if ($platform == 'mysql') {
        $em->exec('SET FOREIGN_KEY_CHECKS = 0;');
        $em->exec("SET SESSION sql_mode = 'NO_AUTO_VALUE_ON_ZERO'"); // STRICT_TRANS_TABLESを無効にする。
    } else {
        $em->exec('SET session_replication_role = replica;'); // need super user
    }

    $this->saveToOther($em, $csvDir, 'dtb_news');

    if ($platform == 'mysql') {
        $em->exec('SET FOREIGN_KEY_CHECKS = 1;');
    } else {
        $this->setIdSeq($em, 'dtb_news');
    }

    $em->commit();

    $this->addSuccess('その他データを登録しました。', 'admin');
}

private function saveToOther($em, $tmpDir, $csvName, $tableName = null, $allow_zero = false, $i = 1)
{
    $tableName = ($tableName) ? $tableName : $csvName;
    $this->resetTable($em, $tableName);

    if (file_exists($tmpDir.$csvName.'.csv') == false) {
        // 無視する
        //$this->addDanger($csvName.'.csv が見つかりませんでした' , 'admin');
        return;
    }
    if (filesize($tmpDir.$csvName.'.csv') == 0) {
        // 無視する
        return;
    }

    if (($handle = fopen($tmpDir.$csvName.'.csv', 'r')) !== false) {
        // 文字コード問題が起きる可能性が高いので後で調整が必要になると思う
        $key = fgetcsv($handle);
        $keySize = count($key);

        $columns = $em->getSchemaManager()->listTableColumns($tableName);
        foreach ($columns as $column) {
            $listTableColumns[] = $column->getName();
        }

        $builder = new BulkInsertQuery($em, $tableName, 20);
        $builder->setColumns($listTableColumns);

        $batchSize = 20;

        while (($row = fgetcsv($handle)) !== false) {
            $value = [];

            // 1行目をkeyとした配列を作る
            $data = array_combine($key, $row);

            // Schemaにあわせた配列を作成する
            foreach ($listTableColumns as $column) {
                if ($column == 'id' && $tableName == 'dtb_news') {
                    $value[$column] = $data['news_id'];
                } elseif ($column == 'name') {
                    $value[$column] = $data['name'];
                } elseif ($column == 'product_id') {
                    $value[$column] = $data['product_id'];
                } elseif ($column == 'publish_date') {
                    $value[$column] = $data['news_date'];
                } elseif ($column == 'title' && $tableName == 'dtb_news') {
                    $value[$column] = isset($data['news_title'])
                        ? mb_substr($data['news_title'], 0, 255)
                        : null;
                } elseif ($column == 'description') {
                    $value[$column] = $data['news_comment'];
                } elseif ($column == 'url') {
                    $value[$column] = empty($data['news_url']) ? null : $data['news_url'];
                } elseif ($column == 'link_method') {
                    $value[$column] = empty($data['link_method']) ? null : $data['link_method'];
                } elseif ($column == 'eyecatch_image') {
                    $value[$column] = isset($data['main_large_image'])
                        ? $data['main_large_image']
                        : null;
                } elseif ($column == 'creator_id') {
                    $value[$column] = !empty($data[$column]) ? $data[$column] : 1;
                } elseif ($column == 'sort_no') {
                    $value[$column] = $data['rank'];
                } elseif ($column == 'visible') {
                    $value[$column] = ($data['del_flg']) ? 0 : 1;
                } elseif ($column == 'create_date' || $column == 'update_date') {
                    $value[$column] = (isset($data[$column]) && $data[$column] != '0000-00-00 00:00:00') ? self::convertTz($data[$column]) : date('Y-m-d H:i:s');
                } elseif ($column == 'discriminator_type') {
                    $search = ['dtb_', 'mtb_', 'plg_', '_'];
                    $value[$column] = str_replace($search, '', $tableName);
                } elseif ($allow_zero) {
                    $value[$column] = isset($data[$column]) ? $data[$column] : null;
                } else {
                    $value[$column] = !empty($data[$column]) ? $data[$column] : null;
                }
            }

            $builder->setValues($value);

            if (($i % $batchSize) === 0) {
                $builder->execute();
            }

            $i++;
        }

        if (count($builder->getValues()) > 0) {
            $builder->execute();
        }

        fclose($handle);

        return $i; // indexを返す
    }
}


private function savePlugin($em, $csvDir)
{
    // プラグイン系
    $em->beginTransaction();

    $platform = $em->getDatabasePlatform()->getName();

    if ($platform == 'mysql') {
        $em->exec('SET FOREIGN_KEY_CHECKS = 0;');
        $em->exec("SET SESSION sql_mode = 'NO_AUTO_VALUE_ON_ZERO'"); // STRICT_TRANS_TABLESを無効にする。
    } else {
        $em->exec('SET session_replication_role = replica;'); // need super user
    }

    $this->saveToPlugin($em, $csvDir, 'dtb_maker', 'plg_maker');
    $this->saveToPlugin($em, $csvDir, 'dtb_best_products', 'plg_recommend_product');
    $this->saveToPlugin($em, $csvDir, 'dtb_review', 'plg_product_review');
    $this->saveToPlugin($em, $csvDir, 'dtb_recommend_products', 'plg_related_product');

    if ($platform == 'mysql') {
        $em->exec('SET FOREIGN_KEY_CHECKS = 1;');
    } else {
        $this->setIdSeq($em, 'plg_maker');
        $this->setIdSeq($em, 'plg_recommend_product');
        $this->setIdSeq($em, 'plg_product_review');
        $this->setIdSeq($em, 'plg_related_product');
    }

    $em->commit();

    $this->addSuccess('各種プラグインデータを登録しました。', 'admin');
}

private function saveToPlugin($em, $tmpDir, $csvName, $tableName = null, $allow_zero = false, $i = 1)
{
    $tableName = ($tableName) ? $tableName : $csvName;
    $this->resetTable($em, $tableName);

    if (file_exists($tmpDir.$csvName.'.csv') == false) {
        // 無視する
        //$this->addDanger($csvName.'.csv が見つかりませんでした' , 'admin');
        return;
    }
    if (filesize($tmpDir.$csvName.'.csv') == 0) {
        // 無視する
        return;
    }

    if (($handle = fopen($tmpDir.$csvName.'.csv', 'r')) !== false) {
        // 文字コード問題が起きる可能性が高いので後で調整が必要になると思う
        $key = fgetcsv($handle);
        $keySize = count($key);

        $columns = $em->getSchemaManager()->listTableColumns($tableName);
        foreach ($columns as $column) {
            $listTableColumns[] = $column->getName();
        }

        $builder = new BulkInsertQuery($em, $tableName, 20);
        $builder->setColumns($listTableColumns);

        $batchSize = 20;

        while (($row = fgetcsv($handle)) !== false) {
            $value = [];

            // 1行目をkeyとした配列を作る
            $data = array_combine($key, $row);

            // Schemaにあわせた配列を作成する
            foreach ($listTableColumns as $column) {
                if ($column == 'id' && $tableName == 'plg_maker') {
                    $value[$column] = $data['maker_id'];
                } elseif ($column == 'recommend_id' && $tableName == 'plg_recommend_product') {
                    $value[$column] = $data['best_id'];
                } elseif ($column == 'id' && $tableName == 'plg_product_review') {
                    $value[$column] = $data['review_id'];
                    // } elseif ($column == 'id' && $tableName == 'plg_related_product') {
                    //     $value[$column] = $data['recommend_product_id'];
                } elseif ($column == 'name') {
                    $value[$column] = $data['name'];
                } elseif ($column == 'product_id') {
                    $value[$column] = $data['product_id'];
                } elseif ($column == 'child_product_id') {
                    $value[$column] = $data['recommend_product_id'];
                } elseif ($column == 'comment') {
                    $value[$column] = $data['comment'];
                } elseif ($column == 'sex_id') {
                    $value[$column] = empty($data['sex']) ? null : $data['sex'];
                } elseif ($column == 'customer_id') {
                    $value[$column] = empty($data['customer_id']) ? null : $data['customer_id'];
                } elseif ($column == 'status_id') {
                    $value[$column] = empty($data['status']) ? null : $data['status'];
                } elseif ($column == 'reviewer_name') {
                    $value[$column] = empty($data['reviewer_name']) ? null : $data['reviewer_name'];
                } elseif ($column == 'reviewer_url') {
                    $value[$column] = empty($data['reviewer_url']) ? null : $data['reviewer_url'];
                } elseif ($column == 'title' && $tableName == 'plg_product_review') {
                    $value[$column] = isset($data['title'])
                        ? mb_substr($data['title'], 0, 50)
                        : null;
                } elseif ($column == 'recommend_level') {
                    $value[$column] = empty($data['recommend_level']) ? null : $data['recommend_level'];
                } elseif ($column == 'content') {
                    $value[$column] = isset($data['comment'])
                        ? mb_substr($data['comment'], 0, 3999)
                        : null;
                } elseif ($column == 'sort_no') {
                    $value[$column] = $data['rank'];
                } elseif ($column == 'visible') {
                    $value[$column] = ($data['del_flg']) ? 0 : 1;
                } elseif ($column == 'create_date' || $column == 'update_date') {
                    $value[$column] = (isset($data[$column]) && $data[$column] != '0000-00-00 00:00:00') ? self::convertTz($data[$column]) : date('Y-m-d H:i:s');
                } elseif ($column == 'discriminator_type') {
                    $search = ['dtb_', 'mtb_', 'plg_', '_'];
                    $value[$column] = str_replace($search, '', $tableName);
                } elseif ($allow_zero) {
                    $value[$column] = isset($data[$column]) ? $data[$column] : null;
                } else {
                    $value[$column] = !empty($data[$column]) ? $data[$column] : null;
                }
            }

            $builder->setValues($value);

            if (($i % $batchSize) === 0) {
                $builder->execute();
            }

            $i++;
        }

        if (count($builder->getValues()) > 0) {
            $builder->execute();
        }

        fclose($handle);

        return $i; // indexを返す
    }
}

一部不要なコードが含まれていますが、上記関数を追加することでデータ移行が可能となります。 3系からの移行については少しカスタマイズする事で対応可能になると思います。

以上で各種必要となるデータが移行されるようになります。もし他のテーブルもデータ移行を行いたい方はカスタマイズ内容は簡単なので適宜修正して試してください。

EC-CUBE4 Web APIの項目追加方法

EC-CUBE Advent Calendar 2021 13日目の記事です。

EC-CUBE4では外部システムと連携する時にお手軽な方法としてEC-CUBE Web APIというプラグインが存在しています。

www.ec-cube.net

EC-CUBE4 Web API プラグインの使い方はこちらを参考にしてください。

doc.ec-cube.net

EC-CUBEをカスタマイズして利用している方は、DBに項目を追加するカスタマイズも行っていると思いますが、API連携時に追加した項目を渡したい時はどうすれば良いかの説明をします。

方法はそのものズバリの説明が開発ドキュメントにあり、app/Plugin/Api/Resource/config/services.yamlに対して追加したEntityへ記述すれば可能です。

取得可能なデータの追加 - EC-CUBE4 Web API プラグイン

上記の方法でも良いのですが、プラグインに対して修正を行いたくない方は、下記の方法でも対応可能です。

まず、app/Customize/Resource/config配下にservices.yamlファイルを作成します。

ファイルを作成後以下のように内容を記述します。

  • app/Customize/Resource/config/services.yaml
services:
    custom.api.allow_list:
        class: ArrayObject
        tags: ['eccube.api.allow_list']
        arguments:
            - #
                Eccube\Entity\Order: ['sample1']
                Eccube\Entity\OrderItem: ['aaaaa', 'bbbb']

追加したい項目名を記述することで連携が可能となります。

custom.api.allow_list:と記述している箇所のcustomは何でも構いません。それ以外は変更する必要はありません。

開発ドキュメントには、

プラグインに許可リストが含まれない場合は、 Customize ディレクトリ以下の services.yaml でも定義できます。

と一文だけ記述されており、どのようにすれば良いのか説明が無かったので参考にしてください。

EC-CUBE4で簡易的に領収書や請求書をマイページに表示させる方法

EC-CUBE Advent Calendar 2021 12日目の記事です。

EC-CUBE4では管理画面から納品書を出力する機能はありますが、領収書や請求書を出力する機能がありません。 納品書の機能で件名を変更すれば対応をしようと思えば可能ですが、購入した方のマイページにも領収書や請求書を出力できるようにしたいという要望があります。

今回はその方法を説明します。

領収書・請求書のフォーマット作成

管理画面では納品書はPDFで出力されますが、今回は領収書と請求書はHTMLとしてブラウザに出力後、ブラウザの機能を利用して印刷させる方法とします。Amazonでお馴染みの方法となります。

領収書と納品書のフォーマットですが、twigとして作成する必要があります。以下にサンプルとして載せておきますので自由に改変してください。 それぞれのファイルの配置場所もapp/template/default/Mypage直下となります。

  • app/template/default/Mypage/print_receipt.twig (領収書フォーマット)
<!doctype html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
    <title>領収書 | {{ BaseInfo.shop_name }}</title>
    <style>
        .container {
            width: 900px !important;
            font-size: 12px;
            margin-top: 20px;
        }

        .logo {
            float: left;
            font-size: 20px;
            font-weight: bold;
        }

        .inshi {
            border: 2px solid #ddd;
            padding: 10px;
            text-align: center;
            margin-right: 5%;
        }

    </style>
    <style media="print">
        #print-footer {
            display: none;
        }

        input[type="text"] {
            outline: 0;
            border: none;
        }

        button,
        #edit,
        #mod {
            display: none;
        }
    </style>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
    <script src="{{ asset('assets/js/vendor/printThis.js') }}"></script>
    <script>
        $(function() {

            $('#print').click(function() {
                $('body').printThis({
                    importStyle: true
                });
            });

            $('#edit').click(function() {
                var name = $('.print_name').text();
                var input = $('<input type="text" id="input-name">').val(name);
                $('.print_name').empty().append(input);
                $('#edit').hide();
                $('#mod').show();
            });

            $('#mod').click(function() {
                var newname = $('#input-name').val();
                $('.print_name').empty().text(newname);
                $('#input-name').remove();
                $('#edit').show();
                $('#edit').removeAttr('style');
                $('#mod').hide();
                showEx();
            });

            function showEx() {
                var tmp = $('.print_name').text();

                if (tmp == '') {
                    $('.print_name_ex').hide();
                } else {
                    $('.print_name_ex').show();
                }
            }

        })
    </script>
</head>
<body>
<div class="container">
    <div class="logo">
        {{ BaseInfo.shop_name }}
    </div>
    <div class="text-end">No. {{ Order.order_no }}</div>
    <div class="text-end">注文日 : {{ Order.order_date|date_format(null, 'Y年m月d日') }}</div>
    <h1 class="text-center">領収書</h1>
    <div class="row">
        <div class="col-7">
            <h2 id="name"><span class="print_name">{% if Order.company_name %}{{ Order.company_name }} 御中{% else %}{{ Order.name01 }} {{ Order.name02 }} </span><span class="print_name_ex"></span>{% endif %}</h2>
            <button type="button" id="edit">名称変更</button>
            <button type="button" id="mod" style="display: none;">適用</button>
        </div>
    </div>
    <hr>

    <table class="table text-center">
        <tbody>
        <tr>
            <td class="h3">{{ Order.payment_total|price }} (税込)</td>
        </tr>
        <tr>
            <td>商品購入代として、上記正に領収いたしました。</td>
        </tr>
        <tr>
            <td>{{ Order.payment_method }}</td>
        </tr>
        </tbody>
    </table>
    <div class="shop-footer">
        <div class="row">
            <div class="col-2">
                <div class="inshi">
                    電子領収書<br>につき<br>印紙不要
                </div>
            </div>
            <div class="col-7">
                {% if BaseInfo.company_name %}
                    {{ BaseInfo.company_name }}<br>
                {% endif %}
                {{ BaseInfo.shop_name }}<br>
                〒100-0001<br>
                東京都千代田区千代田1-1-1<br>
                千代田ビル 12F<br>
                TEL 03-0000-0000<br>
                MAIL xxxxxxxx@example.com
            </div>
        </div>
    </div>

    <div id="print-footer" class="text-center mt-5">
        <button type="button" id="print">プリントアウトする</button>
        <button onclick="window.close();">閉じる</button>
    </div>
</div>
</body>
</html>
  • app/template/default/Mypage/print_invoice.twig (請求書フォーマット)
<!doctype html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
    <title>請求書 | {{ BaseInfo.shop_name }}</title>
    <style>
        .container {
            width: 900px !important;
            font-size: 12px;
            margin-top: 20px;
        }

        .logo {
            float: left;
            font-size: 20px;
            font-weight: bold;
        }

        .inkan {
            position: relative;
            padding-right: 80px;
        }

        .inkan-image {
            position: absolute;
            right: 0;
            top: 0;
            z-index: 1;
            width: 70px;
        }

        table thead {
            background-color: #eee !important;
        }

        table tbody {
            border-top: none !important;
        }

    </style>
    <style media="print">
        #print-footer {
            display: none;
        }

        input[type="text"] {
            outline: 0;
            border: none;
        }

        button,
        #edit,
        #mod {
            display: none;
        }
    </style>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
    <script src="{{ asset('assets/js/vendor/printThis.js') }}"></script>
    <script>
        $(function() {

            $('#print').click(function() {
                $('body').printThis({
                    importStyle: true
                });
            });

            $('#edit').click(function() {
                var name = $('.print_name').text();
                var input = $('<input type="text" id="input-name">').val(name);
                $('.print_name').empty().append(input);
                $('#edit').hide();
                $('#mod').show();
            });

            $('#mod').click(function() {
                var newname = $('#input-name').val();
                $('.print_name').empty().text(newname);
                $('#input-name').remove();
                $('#edit').show();
                $('#edit').removeAttr('style');
                $('#mod').hide();
                showEx();
            });

            function showEx() {
                var tmp = $('.print_name').text();

                if (tmp == '') {
                    $('.print_name_ex').hide();
                } else {
                    $('.print_name_ex').show();
                }
            }

        })
    </script>
</head>
<body>
<div class="container">
    <div class="logo">
        {{ BaseInfo.shop_name }}
    </div>
    <div class="text-end">No. {{ Order.order_no }}</div>
    <div class="text-end">発行日 {{ Order.Shippings[0].shipping_date|date_format(null, 'Y年m月d日') }}</div>
    <h1 class="text-center">請求書</h1>
    <div class="row">
        <div class="col-6">
            <h2 id="name"><span class="print_name">{% if Order.company_name %}{{ Order.company_name }} 御中{% else %}{{ Order.name01 }} {{ Order.name02 }} </span><span class="print_name_ex"></span>{% endif %}</h2>
            <button type="button" id="edit">名称変更</button>
            <button type="button" id="mod" style="display: none;">適用</button>
            <div>
                このたびはお買上げいただきありがとうございます。<br>
                下記の内容にて請求させていただきます。
            </div>
        </div>
        <div class="col-6">
            <div class="text-end">
                <div class="inkan">
                    {% if BaseInfo.company_name %}
                        {{ BaseInfo.company_name }}<br>
                    {% endif %}
                    {{ BaseInfo.shop_name }}<br>
                    〒100-0001 東京都千代田区千代田1-1-1<br>
                    千代田ビル 12F<br>
                    TEL 03-0000-0000<br>
                    <img src="{{ asset('assets/img/common/inkan.jpg') }}" alt="" class="inkan-image">
                </div>
            </div>
        </div>
    </div>

    <div class="row my-4">
        <div class="text-center">
            <h4 class="d-inline-block border-bottom pb-2">合計金額(税込) <span class="ms-5">{{ Order.payment_total|price }}</span></h4>
        </div>
    </div>

    <div class="row">
        <div class="col-12">
            下記のとおり納品いたします。
        </div>
    </div>

    <table class="table table-bordered">
        <thead>
        <tr>
            <th class="text-end" style="width:4em">項番</th>
            <th>品名</th>
            <th class="text-end" style="width:4em">数量</th>
            <th class="text-end">単価</th>
            <th class="text-end">金額(税込)</th>
        </tr>
        </thead>
        <tbody>

        {% set index = 1 %}
        {% for OrderItem in Order.MergedProductOrderItems %}
            <tr>
                <td class="text-end">{{ index }}</td>
                <td>
                    {{ OrderItem.product_name }}  {{ OrderItem.classcategory_name1 }}  {{ OrderItem.classcategory_name2 }}
                </td>
                <td class="text-end">{{ OrderItem.quantity|number_format }}</td>
                <td class="text-end">{{ OrderItem.price|price }}</td>
                <td class="text-end">{{ OrderItem.total_price|price }}</td>
            </tr>
            {% set index = index + 1 %}
        {% endfor %}
        <tr>
            <td class="text-end">{{ index }}</td>
            <td>送料</td>
            <td class="text-end">1</td>
            <td class="text-end">{{ Order.delivery_fee_total|price }}</td>
            <td class="text-end">{{ Order.delivery_fee_total|price }}</td>
        </tr>
        {% set index = index + 1 %}
        {% if Order.charge > 0 %}
            <tr>
                <td class="text-end">{{ index }}</td>
                <td>手数料</td>
                <td class="text-end">1</td>
                <td class="text-end">{{ Order.charge|price }}</td>
                <td class="text-end">{{ Order.charge|price }}</td>
            </tr>
            {% set index = index + 1 %}
        {% endif %}
        {% for OrderItem in Order.OrderItems %}
            {% if OrderItem.order_item_type_id == 4 %}
                <tr>
                    <td class="text-end">{{ index }}</td>
                    <td>{{ OrderItem.product_name }}</td>
                    <td class="text-end">1</td>
                    <td class="text-end">{{ OrderItem.priceIncTax|price }}</td>
                    <td class="text-end">{{ OrderItem.total_price|price }}</td>
                </tr>
                {% set index = index + 1 %}
            {% endif %}
        {% endfor %}

        <tr>
            <td colspan="3" rowspan="3"></td>
            <td class="text-center">合計</td>
            <td class="text-end h5">{{ Order.payment_total|price }}</td>
        </tr>

        </tbody>
    </table>

    {% if Order.note %}
        <table class="table table-bordered">
            <thead>
            <tr>
                <th>備考</th>
            </tr>
            </thead>
            <tbody>
            <tr>
                <td>{{ Order.note|nl2br }}</td>
            </tr>
            </tbody>
        </table>
    {% endif %}
    <div class="row">
        <div class="col-8">
            【お振込先】<br>
            XXXX銀行 XXXX支店 普通 9999999 XXXX株式会社<br>
            振込手数料は御社ご負担でお願い申し上げます。
        </div>
        <div class="col-4 text-end">
            ご注文商品についてのお問い合わせ・ご意見はこちら<br>
            TEL: {{ BaseInfo.phone_number }}|{{ BaseInfo.business_hour }}
        </div>
    </div>

    <div id="print-footer" class="text-center mt-5">
        <button type="button" id="print">プリントアウトする</button>
        <button onclick="window.close();">閉じる</button>
    </div>
</div>
</body>
</html>

領収書、請求書ともに宛先名も変更できるようにしています。

印刷用ライブラリの導入

画面から簡単に印刷できるようにするため、下記ライブラリを導入します。

jasonday.github.io

こちらのライブラリをダウンロード後、[EC-CUBE ROOT]/html/template/default/assets/js/vendor/printThis.js へ配置してください。

印刷用関数の作成

印刷画面を表示させるためにカスタマイズを行います。

MypageControllerクラスを変更します。変更内容ですが、印刷するための画面を表示させるための関数を追加するだけです。

  • src/Eccube/Service/MypageController.php
/**
 * 印刷画面を表示する.
 *
 * @Route("/mypage/{order_no}/{print}/print", name="mypage_print")
 *
 * @param Request $request
 * @param $order_no
 * @param $print
 * @return \Symfony\Component\HttpFoundation\Response
 */
public function exportPrint(Request $request, $order_no, $print)
{
    $Customer = $this->getUser();

    /* @var $Order \Eccube\Entity\Order */
    $Order = $this->orderRepository->findOneBy(
        [
            'order_no' => $order_no,
            'Customer' => $Customer,
        ]
    );

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

    return $this->render('Mypage/print_'.$print.'.twig', [
        'Order' => $Order,
    ]);
}

印刷用ボタンを表示

マイページの購入詳細画面で印刷用ボタンを表示させます。既存のファイルに対して修正しても良いのですが、普段修正する場合、app/template/default直下にコピーしてから修正することが多いので、今回もそのようにします。

src/Eccube/Resource/template/default/Mypage/history.twigファイルをapp/template/default/default/Mypage/history.twigへコピー後に以下の内容を追加します。

  • app/template/default/default/Mypage/history.twig
<a href="{{ url('mypage_print', {'order_no': Order.order_no, 'print': 'receipt'}) }}" class="ec-inlineBtn" target="_blank">領収書</a>
<a href="{{ url('mypage_print', {'order_no': Order.order_no, 'print': 'invoice'}) }}" class="ec-inlineBtn" target="_blank">請求書</a>

上記内容は表示させたい位置へ追加してください。

以上で表示可能となります。

それぞれ、「発送済み」の時にしか表示させたくないのであれば、以下のように変更してください。

  • app/template/default/default/Mypage/history.twig
{% if Order.OrderStatus.id == constant('Eccube\\Entity\\Master\\OrderStatus::DELIVERED') %}
    <a href="{{ url('mypage_print', {'order_no': Order.order_no, 'print': 'receipt'}) }}" class="ec-inlineBtn" target="_blank">領収書</a>
    <a href="{{ url('mypage_print', {'order_no': Order.order_no, 'print': 'invoice'}) }}" class="ec-inlineBtn" target="_blank">請求書</a>
{% endif %}

また、一度領収書ボタンが押されたらAmazonのように「再発行」を表示させたい場合、DBを変更する必要があります。
その方法は今回割愛しますが、必要な方はコメントに欲しいと記述してください。

なお、領収書については税込5万円を超えた場合、収入印紙が必要じゃないのかと聞かれることが多いのですが、 今回の方法は電子書類扱いとなるため非課税扱いとなります。 そのため、領収書ファイルをメール、FAXなどで送った場合などは印紙が不要です。
電子領収書をプリントアウトした場合も、そこに印鑑を押印しない限りは非課税扱いですので収入印紙は不要となります。

以上で領収書と請求書の作成方法となりますので必要な方は参考にしてください。