EC-CUBE4、EC-CUBE3でお届け日指定を年末年始は除外する方法

年末年始はお届け日指定を避けたいという要望が時々あります。

避けるためには発送日目安を変更すれば対応可能ですが、商品が数千件となると対応するのがめんどくさいです。

その場合、以下の方法をお試しください。

EC-CUBE4の場合

176行目付近にある、for文の内容を以下に変更する事で対応可能です。

  • src/Eccube/Form/Type/Shopping/ShippingType.php
<?php
〜
〜
foreach ($period as $day) {
    // $deliveryDurations[$day->format('Y/m/d')] = $day->format('Y/m/d').'('.$dateFormatter->format($day).')';
    $tmp = $day->format('Ymd');
    if (
        $tmp == '20211229' ||
        $tmp == '20211230' ||
        $tmp == '20211231' ||
        $tmp == '20220101' ||
        $tmp == '20220102' ||
        $tmp == '20220103' ||
        $tmp == '20220104' ||
        $tmp == '20220105'
    ) {
        // 選択できないようにするためセットしない
    } else {
        $deliveryDurations[$day->format('Y/m/d')] = $day->format('Y/m/d').'('.$dateFormatter->format($day).')';
    }
}
〜
〜

EC-CUBE3の場合

ShoppingService.phpgetFormDeliveryDates関数内にある、for文の内容を以下に変更する事で対応可能です。

  • src/Eccube/Service/ShoppingService.php
<?php
〜
〜
foreach ($period as $day) {
    // $deliveryDates[$day->format('Y/m/d')] = $day->format('Y/m/d');
    $tmp = $day->format('Ymd');
    if (
        $tmp == '20211229' ||
        $tmp == '20211230' ||
        $tmp == '20211231' ||
        $tmp == '20220101' ||
        $tmp == '20220102' ||
        $tmp == '20220103' ||
        $tmp == '20220104' ||
        $tmp == '20220105'
    ) {
        // 選択できないようにするためセットしない
    } else {
        $deliveryDates[$day->format('Y/m/d')] = $day->format('Y/m/d');
    }
}
〜
〜

該当する年月日がなければif文の中へ追加してください。

EC-CUBE4の商品一覧画面で該当するカテゴリページに画像や説明文を表示させる方法

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

EC-CUBE4では、カテゴリを設定すると、カテゴリ毎に商品一覧を表示させる機能があります。ただ、そのカテゴリ商品一覧画面ですが画像や説明文も何もありません。今回はカテゴリが選択されたら、カテゴリページに説明文や画像などのコンテンツを追加することができる方法を説明します。

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

www.ec-cube.net

Categoryクラスの修正

Categoryクラスに説明文と商品画像のファイル名を保存する項目を追加します。

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

/**
 * @var string|null
 *
 * @ORM\Column(name="free_area", type="text", nullable=true)
 */
private $free_area;

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

/**
 * Set freeArea.
 *
 * @param string|null $freeArea
 *
 * @return Category
 */
public function setFreeArea($freeArea = null)
{
    $this->free_area = $freeArea;

    return $this;
}

/**
 * Get freeArea.
 *
 * @return string|null
 */
public function getFreeArea()
{
    return $this->free_area;
}

/**
 * Set categoryImage.
 *
 * @param string|null $categoryImage
 *
 * @return Category
 */
public function setCategoryImage($categoryImage = null)
{
    $this->category_image = $categoryImage;

    return $this;
}

/**
 * Get categoryImage.
 *
 * @return string|null
 */
public function getCategoryImage()
{
    return $this->category_image;
}

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

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

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

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

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

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

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

カテゴリ管理画面の修正

次に、カテゴリ管理画面の修正をします。以下のController、FormType、twigファイルを修正してください。

  • src/Eccube/Controller/Admin/Product/CategoryController.php
<?php
〜
〜
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
〜
〜

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

// ファイルアップロード
$file = $form['category_image']->getData();
$fs = new Filesystem();
if ($file && $fs->exists($this->getParameter('eccube_temp_image_dir').'/'.$file)) {
    $fs->rename(
        $this->getParameter('eccube_temp_image_dir').'/'.$file,
        $this->getParameter('eccube_save_image_dir').'/'.$file
    );
}


$this->categoryRepository->save($TargetCategory);

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

〜
〜

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

    // ファイルアップロード
    $file = $editForm['category_image']->getData();
    $fs = new Filesystem();
    if ($file && $fs->exists($this->getParameter('eccube_temp_image_dir').'/'.$file)) {
        $fs->rename(
            $this->getParameter('eccube_temp_image_dir').'/'.$file,
            $this->getParameter('eccube_save_image_dir').'/'.$file
        );
    }

    $this->categoryRepository->save($editForm->getData());

    // $editFormが保存されたフォーム
    // 上の新規登録用フォームの場合とイベント名が共通のため
    // このイベントのリスナーではsubmitされているフォームを判定する必要がある


〜
〜



/**
 * @Route("/%eccube_admin_route%/product/category/image/add", name="admin_product_category_image_add")
 */
public function imageAdd(Request $request)
{
    if (!$request->isXmlHttpRequest()) {
        throw new BadRequestHttpException();
    }

    $allowExtensions = ['gif', 'jpg', 'jpeg', 'png'];
    $filename = null;

    $files = $request->files->all();
    foreach ($files as $images) {
        if (isset($images['category_file'])) {
            $image = $images['category_file'];

            //ファイルフォーマット検証
            $mimeType = $image->getMimeType();
            if (0 !== strpos($mimeType, 'image')) {
                throw new UnsupportedMediaTypeHttpException();
            }

            // 拡張子
            $extension = $image->getClientOriginalExtension();
            if (!in_array(strtolower($extension), $allowExtensions)) {
                throw new UnsupportedMediaTypeHttpException();
            }

            $filename = date('mdHis').uniqid('_').'.'.$extension;
            $image->move($this->getParameter('eccube_temp_image_dir'), $filename);
        }
    }

    return $this->json(['filename' => $filename], 200);
}
  • src/Eccube/Form/Type/Admin/CategoryType.php
<?php
〜
〜
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;

〜
〜

->add('free_area', TextareaType::class, [
    'label' => '詳細',
    'required' => false,
    'constraints' => [
        new Assert\Length([
            'max' => $this->eccubeConfig['eccube_ltext_len'],
        ]),
    ],
])
->add('category_file', FileType::class, [
    'label' => 'カテゴリ画像',
    'mapped' => false,
    'required' => false,
])
->add('category_image', HiddenType::class, [
    'required' => false,
]);

twigファイルは修正箇所が多いので、全て載せます。app/template/admin/Product/category.twigまでコピー後、以下の内容に修正します。

  • app/template/admin/Product/category.twig
{% extends '@admin/default_frame.twig' %}

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

{% block title %}{{ 'admin.product.category_management'|trans }}{% endblock %}
{% block sub_title %}{{ 'admin.product.product_management'|trans }}{% endblock %}

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

{% block stylesheet %}
    <link rel="stylesheet" href="{{ asset('assets/css/fileupload/jquery.fileupload.css', 'admin') }}">
    <link rel="stylesheet" href="{{ asset('assets/css/fileupload/jquery.fileupload-ui.css', 'admin') }}">
    <link rel="stylesheet" href="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/themes/smoothness/jquery-ui.css">

    <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 stylesheet %}

{% 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 src="{{ asset('assets/js/vendor/fileupload/vendor/jquery.ui.widget.js', 'admin') }}"></script>
    <script src="{{ asset('assets/js/vendor/fileupload/jquery.iframe-transport.js', 'admin') }}"></script>
    <script src="{{ asset('assets/js/vendor/fileupload/jquery.fileupload.js', 'admin') }}"></script>
    <script src="{{ asset('assets/js/vendor/fileupload/jquery.fileupload-process.js', 'admin') }}"></script>
    <script src="{{ asset('assets/js/vendor/fileupload/jquery.fileupload-validate.js', 'admin') }}"></script>
    <script>var bootstrapTooltip = $.fn.tooltip.noConflict();</script>
    <script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
    <script>
        $.fn.tooltip = bootstrapTooltip;
        $(document).on('drop dragover', function(e) {
            e.preventDefault();
        });

        $(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];
                    i++;
                });
                $.ajax({
                    url: '{{ url('admin_product_category_sort_no_move') }}',
                    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');
            });

            // 削除モーダルの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'));
            });

            var hideThumbnail = function() {
                if ($('#thumb div').length > 0) {
                    $('#icon_no_image').css('display', 'none');
                } else {
                    $('#icon_no_image').css('display', '');
                }
            };

            var proto_img = '<div class="c-form__fileUploadThumbnail" style="background-image:url(\'__path__\');">' +
                '<a class="delete-image"><i class="fa fa-times" aria-hidden="true"></i></a>' +
                '</div>';
            var category_image = $('#{{ form.category_image.vars.id }}').val();
            if (category_image != '') {
                var filename = $('#{{ form.category_image.vars.id }}').val();
                {# if (category_image == '{{ oldCategoryImage }}') { #}
                var path = '{{ asset('', 'save_image') }}' + filename;
                //} else {
                {# var path = '{{ asset('', 'temp_image') }}' + filename; #}
                //}
                var $img = $(proto_img.replace(/__path__/g, path));
                $('#{{ form.category_image.vars.id }}').val(filename);

                $('#thumb').append($img);
                hideThumbnail();
            }
            hideThumbnail();

            $('.file-upload').fileupload({
                url: "{{ url('admin_product_category_image_add') }}",
                type: 'post',
                dataType: 'json',
                dropZone: $('#upload-zone'),
                done: function(e, data) {
                    $('.progress', $(this).parent()).hide();
                    var path = '{{ asset('', 'temp_image') }}/' + data.result.filename;
                    var $img = $(proto_img.replace(/__path__/g, path));
                    $('.category-image', $(this).parent()).val(data.result.filename);
                    $('.upload-image', $(this).parent()).append($img);
                    $('img', $(this).parent()).remove();

                    hideThumbnail();
                },
                fail: function(e, data) {
                    alert('{{ 'admin.common.upload_error'|trans }}');
                },
                always: function(e, data) {
                    $('.progress').hide();
                    $('.progress .progress-bar').width('0%');
                },
                start: function(e, data) {
                    if ($('.c-form__fileUploadThumbnail').length >= 1) {
                        $.each($('.delete-image'), function(index, delete_image) {
                            delete_image.click();
                        });
                    }
                    $('.progress', $(this).parent()).show();
                    $('#thumb', $(this).parent()).find('div').remove();
                    $('#{{ form.category_image.vars.id }}').val('');
                },
                acceptFileTypes: /(\.|\/)(gif|jpe?g|png)$/i,
                maxFileSize: 10000000,
                maxNumberOfFiles: 1,
                progressall: function(e, data) {
                    var progress = parseInt(data.loaded / data.total * 100, 10);
                    $('.progress .progress-bar', $(this).parent()).css(
                        'width',
                        progress + '%'
                    );
                },
                processalways: function(e, data) {
                    if (data.files.error) {
                        alert("{{ 'admin.common.upload_error'|trans }}");
                    }
                }
            });

            $('#thumb').on('click', '.delete-image', function() {
                $('#{{ form.category_image.vars.id }}').val('');
                var thumbnail = $(this).parents('div.c-form__fileUploadThumbnail');
                $(thumbnail).remove();
                hideThumbnail();
            });

            $(document).on('click', '.delete-image', function() {
                var thumbnail = $(this).parents('div.c-form__fileUploadThumbnail');
                $(thumbnail).remove();
                $(this).parent().find('img').remove();
                $(this).parent().find('.category-image').val('');
                $(this).hide();
            });
        });
    </script>
{% 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_category') }}">
                                    {{ '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_category_show', { parent_id : ParentCategory.id }) }}">
                                            {{ ParentCategory.name }}
                                        </a>
                                    </li>
                                {% endif %}
                            {% endfor %}
                        </ol>
                    </nav>
                </div>
                <div class="col-6 text-right">
                    <div class="btn-group" role="group">
                        <a class="btn btn-ec-regular" href="{{ url('admin_product_category_export') }}">
                            <i class="fa fa-cloud-download mr-1 text-secondary"></i>
                            <span>{{ 'admin.common.csv_download'|trans }}</span>
                        </a>
                        <a class="btn btn-ec-regular" href="{{ url('admin_setting_shop_csv', { id : constant('\\Eccube\\Entity\\Master\\CsvType::CSV_TYPE_CATEGORY') }) }}">
                            <i class="fa fa-cog mr-1 text-secondary"></i>
                            <span>{{ 'admin.setting.shop.csv_setting'|trans }}</span>
                        </a>
                    </div>
                </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">
                                    <form role="form" name="form1" id="form1" method="post"
                                          action="{% if TargetCategory.id %}{{ path('admin_product_category_edit', {id: TargetCategory.id}) }}{% elseif Parent %}{{ url('admin_product_category_show', {'parent_id': Parent.id}) }}{% else %}{{ url('admin_product_category') }}{% endif %}"
                                          enctype="multipart/form-data">
                                        {% if TargetCategory.hierarchy <= eccube_config.eccube_category_nest_level %}
                                            {{ form_widget(form._token) }}
                                            <div class="form-row mb-3">
                                                <div class="col-auto align-self-center mr-3"><span>カテゴリ名</span></div>
                                                <div class="col-7">
                                                    {{ form_widget(form.name) }}
                                                    {{ form_errors(form.name) }}
                                                </div>
                                                <div class="col-12 align-self-center"><span>詳細</span></div>
                                                <div class="col-12">
                                                    {{ form_widget(form.free_area) }}
                                                    {{ form_errors(form.free_area) }}
                                                </div>
                                                <div class="col-12 align-self-center"><span>カテゴリ画像</span></div>
                                                <div class="col-12 mb-2">
                                                    <div class="progress" style="display: none;">
                                                        <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
                                                    </div>
                                                    <div id="thumb" class="c-form__fileUploadThumbnails clearfix"></div>
                                                    <div class="upload-image"></div>
                                                    {{ form_widget(form.category_image, { attr : {'class': 'category-image', style : 'display:none;' } }) }}
                                                    {{ form_widget(form.category_file, { attr : {'class': 'file-upload', 'class': 'file-upload', accept : 'image/*', style : 'display:none;' } }) }}
                                                    {{ form_errors(form.category_image) }}

                                                    <a class="btn btn-ec-regular" onclick="$('#admin_category_category_file').click()">
                                                        {{ 'admin.common.file_select'|trans }}
                                                    </a>
                                                </div>
                                                <div class="col-auto">
                                                    <button class="btn btn-ec-regular" type="submit">
                                                        {{ 'admin.common.create__new'|trans }}
                                                    </button>
                                                </div>
                                            </div>
                                            {# エンティティ拡張の自動出力 #}
                                            {% for f in form if f.vars.eccube_form_options.auto_render %}
                                                {% if f.vars.eccube_form_options.form_theme %}
                                                    {% form_theme f f.vars.eccube_form_options.form_theme %}
                                                    {{ form_row(f) }}
                                                {% else %}
                                                    <div class="form-row mb-3">
                                                        <div class="col-3">
                                                            <span>{{ f.vars.label|trans }}</span>
                                                        </div>
                                                        <div class="col">
                                                            {{ form_widget(f) }}
                                                            {{ form_errors(f) }}
                                                        </div>
                                                    </div>
                                                {% endif %}
                                            {% endfor %}
                                        {% endif %}
                                    </form>
                                </li>
                                <li class="list-group-item">
                                    <div class="row">
                                        <div class="col-auto"><strong>&nbsp;</strong></div>
                                        <div class="col-auto"><strong>{{ 'admin.common.id'|trans }}</strong></div>
                                        <div class="col-2"><strong>{{ 'admin.product.category'|trans }}</strong></div>
                                    </div>
                                </li>
                                {% if Categories|length > 0 %}
                                    {% for Category in Categories %}
                                        <li id="ex-category-{{ Category.id }}" class="list-group-item sortable-item" data-id="{{ Category.id }}" data-sort-no="{{ Category.sort_no }}">
                                            {% if Category.id != TargetCategory.id %}
                                                <div class="row justify-content-around mode-view">
                                                    <div class="col-auto d-flex align-items-center"><i class="fa fa-bars text-ec-gray"></i></div>
                                                    <div class="col-auto d-flex align-items-center">{{ Category.id }}</div>
                                                    <div class="col d-flex align-items-center">
                                                        <a href="{{ url('admin_product_category_show',  { parent_id : Category.id }) }}">{{ Category.name }}</a>
                                                        {% if Category.category_image %}
                                                            <div class="ml-5">
                                                                <img src="{{ asset(Category.category_image, 'save_image') }}" width="50px">
                                                            </div>
                                                        {% endif %}

                                                    </div>
                                                    <div class="col-auto 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: Category.id}) }}"
                                                           data-tooltip="true" data-placement="top"
                                                           title="{{ 'admin.common.edit'|trans }}">
                                                            <i class="fa fa-pencil fa-lg text-secondary"></i>
                                                        </a>
                                                        <div class="d-inline-block mr-2" data-tooltip="true" data-placement="top"
                                                             title="{{ 'admin.common.delete'|trans }}">
                                                            <a class="btn btn-ec-actionIcon{% if Category.Children|length > 0 or Category.hasProductCategories %} disabled{% endif %}"
                                                               data-toggle="modal" data-target="#DeleteModal"
                                                               data-url="{{ url('admin_product_category_delete', {id: Category.id}) }}"
                                                               data-message="{{ 'admin.common.delete_modal__message'|trans({ "%name%" : Category.name }) }}">
                                                                <i class="fa fa-close fa-lg text-secondary"></i>
                                                            </a>
                                                        </div>
                                                    </div>
                                                </div>
                                                <form class="form-row d-none mode-edit" method="POST" action="{{ (Parent and Parent.id) ? url('admin_product_category_show', {'parent_id': Parent.id}) : url('admin_product_category') }}" enctype="multipart/form-data">
                                                    {{ form_widget(forms[Category.id]._token) }}
                                                    <div class="col-auto align-self-center mr-3"><span>カテゴリ名</span></div>
                                                    <div class="col-7">
                                                        {{ form_widget(forms[Category.id].name, {'attr': {'data-origin-value': forms[Category.id].name.vars.value}}) }}
                                                        {{ form_errors(forms[Category.id].name) }}
                                                    </div>
                                                    <div class="col-12 align-self-center"><span>詳細</span></div>
                                                    <div class="col-12">
                                                        {{ form_widget(forms[Category.id].free_area, {'attr': {'data-origin-value': forms[Category.id].free_area.vars.value}}) }}
                                                        {{ form_errors(forms[Category.id].free_area) }}
                                                    </div>
                                                    <div class="col-12 align-self-center"><span>カテゴリ画像</span></div>
                                                    <div class="col-12 mb-2">
                                                        <div class="progress" style="display: none;">
                                                            <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
                                                        </div>
                                                        <div class="upload-image"></div>
                                                        {% if forms[Category.id].category_image.vars.value %}
                                                            <img src="{{ asset(forms[Category.id].category_image.vars.value, 'save_image') }}" width="150px">
                                                        {% endif %}
                                                        {{ form_widget(forms[Category.id].category_image, {'attr': {'class': 'category-image', 'data-origin-value': forms[Category.id].category_image.vars.value}}) }}
                                                        {{ form_errors(forms[Category.id].category_image) }}
                                                        {{ form_widget(forms[Category.id].category_file, { attr : { 'class': 'file-upload', accept : 'image/*', style : 'display:none;' } }) }}
                                                        <a class="btn btn-ec-regular mr-2" onclick="$('#{{ forms[Category.id].category_file.vars.id }}').click()">
                                                            {{ 'admin.common.file_select'|trans }}
                                                        </a>
                                                        <a class="btn btn-ec-regular mr-2 delete-image" {% if not forms[Category.id].category_image.vars.value %}style="display: none"{% endif %}>
                                                            削除
                                                        </a>
                                                    </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>
                                                    {# エンティティ拡張の自動出力 #}
                                                    {% for f in forms[Category.id] if f.vars.eccube_form_options.auto_render %}
                                                        <div class="col-auto align-items-center" style="width:90%; padding-top: 10px;">
                                                            <div class="row">
                                                                <div class="col-3">
                                                                    <span>{{ f.vars.label|trans }}</span>
                                                                </div>
                                                                <div class="col-9">
                                                                    {{ form_widget(f) }}
                                                                    {{ form_errors(f) }}
                                                                </div>
                                                            </div>
                                                        </div>
                                                    {% endfor %}
                                                </form>
                                            {% endif %}
                                        </li>
                                    {% endfor %}
                                {% 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>
                <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_category_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_category') }}">{{ '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 %}

商品一覧画面の修正

src/Eccube/Resource/template/default/Product/list.twigapp/template/default/Product/list.twigまでコピーし、適切な箇所へ以下のタグを追加します。

  • app/template/default/Product/list.twig
{% if Category and Category.category_image %}
    <figure>
        <img src="{{ asset(Category.category_image|no_image_product, 'save_image') }}" alt="{{ Category.name }}">
    </figure>
{% endif %}

{% if Category and Category.free_area %}
    <div>
        {{ include(template_from_string(Category. free_area)) }}
    </div>
{% endif %}

以上でカテゴリページに説明文や画像などのコンテンツを追加する事が可能です。もしカテゴリが指定されていなかった時でも画像や説明文を表示させたいという場合、固定の画像や説明文をtwig内で分岐させて表示させる方法で十分だと思います。

もし動作しないなどがありましたらコメントください。

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系からの移行については少しカスタマイズする事で対応可能になると思います。

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