EC-CUBE4でwkhtmltopdfを利用する方法

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

EC-CUBE4では、納品書出力という機能はtcpdfというライブラリを利用してPDF出力を行なっています。

このライブラリは2系から利用されており非常に便利なのですが、レイアウトの修正など煩わしい問題があります。

今回は簡単にレイアウトの修正が行えるようにするwkhtmltopdfというライブラリの利用方法を説明します。

wkhtmltopdfというライブラリはhtmlをそのままpdfへと変換するライブラリとなります。詳しくは以下のURLを参照してください。

wkhtmltopdf.org

wkhtmltopdfのインストール

EC-CUBE4で利用するためには、こちらを参考に、

github.com

wkhtmltopdfをcomposerコマンドを使ってインストールします。

composer require h4cc/wkhtmltopdf-amd64 0.12.x
composer require h4cc/wkhtmltoimage-amd64 0.12.x

Macを利用されている方で正常にインストールできないという方は、下記よりダウンロードしてください。

https://wkhtmltopdf.org/downloads.html

KnpSnappyBundleのインストール

準備が整えば、今度はKnpSnappyBundleというライブラリをインストールします。

github.com

インストール方法は記載されている通り、composerコマンドを利用します。

composer require knplabs/knp-snappy-bundle

その後、以下の設定を行います。

  • app/config/eccube/bundles.phpの修正
return [
    Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
    Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
    Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
    Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['all' => true],
    Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true],
    Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
    Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
    Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true, 'install' => true],
    Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true, 'install' => true],
    Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
    Knp\Bundle\PaginatorBundle\KnpPaginatorBundle::class => ['all' => true],
    Exercise\HTMLPurifierBundle\ExerciseHTMLPurifierBundle::class => ['all' => true],
    Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
    DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
    Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
    Knp\Bundle\SnappyBundle\KnpSnappyBundle::class => ['all' => true],
];

Knp\Bundle\SnappyBundle\KnpSnappyBundle::class => ['all' => true],を最終行に追加します。

  • app/config/eccube/packages/knp_snappy.yamlの作成
knp_snappy:
    pdf:
        enabled:    true
        binary:     /usr/local/bin/wkhtmltopdf #"\"C:\\Program Files\\wkhtmltopdf\\bin\\wkhtmltopdf.exe\"" for Windows users
        options:    []
    image:
        enabled:    true
        binary:     /usr/local/bin/wkhtmltoimage #"\"C:\\Program Files\\wkhtmltopdf\\bin\\wkhtmltoimage.exe\"" for Windows users
        options:    []
    temporary_folder: "%kernel.cache_dir%/snappy"

上記の通り、knp_snappy.yamlファイルを作成します。

PDFファイルの作成

準備が出来れば後はhtmlファイルやtwigファイルを作成するのみとなります。

  • Contorollerに関数を追加

どのControllerクラスでも構いませんし、新規作成しても構いませんので一例として以下の関数を作成します。

<php




use Knp\Bundle\SnappyBundle\Snappy\Response\PdfResponse;
use Knp\Snappy\Pdf;




    /**
     * @Route("/outputpdf", name="outputpdf", methods={"GET"})
     */
    public function pdfAction(Pdf $knpSnappyPdf)
    {
        $html = $this->renderView('Pdf/outputpdf.twig', [
            'name' => 'ああああ',
        ]);

        return new PdfResponse(
            $knpSnappyPdf->getOutputFromHtml($html),
            'output.pdf'
        );
    }
  • app/template/default/Pdf/outputpdf.twigファイルの作成
<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">

    <title>請求書</title>

    <style>
        body {
            font-family: "Hiragino Kaku Gothic ProN", "segoe ui", 'Noto Serif JP', serif, sans-serif;
            font-size: 1rem;
            line-height: 1.6;
            background: #fff;
        }

        main {
            position: relative;
            overflow: hidden;
        }
    </style>
</head>

<body>
<header>
    <div class="wrap">
        <div>
            <h2>請 求 書</h2>
        </div>
    </div>

</header>

<main>
    {{ name }} 様<br>
    請求書の内容を作成
</main>

</body>
</html>

以上を作成後、http://xxxx/outputpdf を入力するとtwigで作成されたものがPDFダウンロードされます。

あとは、twigファイルに対して出力したい内容を組み立てていけば、htmlを修正するだけで簡単にレイアウト変更が可能となります。 wkhtmltopdfですがflex指定が出来ない時もありますのでhtmlを作成する場合、昔ながらの方法でcss指定を行なってください。

PDFが出力されず、

Exit with code 1 due to network error: ProtocolUnknownError

というエラーが発生した場合、画像やCSSファイルはフルパスで記述する必要がありますのでご注意ください。

wkhtmltopdfは横向きや縦向きなど色んなオプションが用意されています。 getOutputFromHtml関数の第2引数に対して、

$options = [
    'encoding' => 'utf-8',
    'page-size' => 'A4',
    'margin-top' => '30px',
    'margin-bottom' => '30px',
    'margin-left' => '20px',
    'margin-right' => '20px',
];

return new PdfResponse(
    $knpSnappyPdf->getOutputFromHtml($html, $options),
    'output.pdf'
);

とオプション指定すれば設定可能です。 オプションについては下記のURLをご覧ください。

https://wkhtmltopdf.org/usage/wkhtmltopdf.txt

PDFが動作しないという方はコメントに記載ください。

EC-CUBE4でのメール送信設定方法

EC-CUBE Advent Calendar 2022 10日目の記事です。

EC-CUBE4ではメール送信の設定はインストール時に行います。 3系と異なり4系からは基本的にSMTPを利用した設定しかできなくなりました。

ただし、sendmailなどを利用したい方やGmailを利用して設定したい方もいられるので様々な設定方法をご紹介します。

メール送信設定はインストール後は画面から変更する方法が存在せず、インストール後に作成される.envファイルで変更可能です。 設定内容ですが、4.2系からSymfonyのバージョンが異なるため若干異なります。

  • 4.2系
###> symfony/mailer ###
MAILER_DSN=smtp://localhost:1025
###< symfony/mailer ###
  • 4、4.1系
###> symfony/swiftmailer-bundle ###
# For Gmail as a transport, use: "gmail://username:password@localhost"
# For a generic SMTP server, use: "smtp://localhost:25?encryption=&auth_mode="
# For a debug SMTP server, use: "smtp://mailcatcher:1025"
# Delivery is disabled by default via "null://localhost"
MAILER_URL=smtp://localhost:1025
###< symfony/swiftmailer-bundle ###

以下ではそれぞれの環境に応じての設定方法となります。

4.2系の方はMAILER_DSNで置き換えて設定してください。

SMTPでの設定

メール送信にはSMTPを利用される方が多いと思いますが、以下は一例となります。

MAILER_URL=smtp://メールアカウント名:パスワード@SMTPサーバ名:587?auth_mode=plain

大体この方法でさくらサーバやXServerなどレンタルサーバをご利用の方は送信可能となります。

sendmailでの設定

今はあまり使われなくなったsendmailですがまだまだ利用される方もいてると思いますので、sendmailの設定方法ものせておきます。

MAILER_URL=sendmail://localhost

Gmailでの設定

Gmailの設定はアプリを許可する必要がありますので、 Gmailで送信できないという方は一度Googleにログインして許可して確認してみてください。

安全性の低いアプリがアカウントにアクセスするのを許可する - Google アカウント ヘルプ

MAILER_URL=smtp://smtp.gmail.com:465?encryption=ssl&auth_mode=login&username=xxxxx@gmail.com&password=パスワード

上記の方法で送信できない場合、以下のライブラリを利用する方法もお試しください。

ライブラリのインストールにはcomposerコマンドを利用します。

composer require symfony/google-mailer

その後、以下の設定を行います。

MAILER_URL=gmail+smtp://USERNAME:PASSWORD@default

Amazon SESでの設定

Amazon SESでメール送信する場合、ライブラリが必要となりますので、以下のコマンドを実行してください。

ライブラリのインストールにはcomposerコマンドを利用します。

composer require symfony/amazon-mailer

その後、以下の設定を行います。

MAILER_DSN=ses+smtp://ACCESS_KEY:SECRET_KEY@default?region=eu-west-1

他のメールサービスをご利用したい方は、以下のURLをご確認ください。

symfony.com

上記の設定でメール送信できない場合、コメントに記載してください。

EC-CUBE4で日付毎に1番目から注文番号を採番する方法

EC-CUBE Advent Calendar 2022 8日目の記事です。

EC-CUBE4から注文番号が自由に採番できるようになりました。 こちらはドキュメントにも詳しく載っておらず意外と知らない方が多いのですが、app/config/eccube/packages/eccube.yaml にある、

    # 注文番号のフォーマット. 以下のフォーマットが利用可能です. フォーマットを空にした場合, dtb_order.idを出力します.
    # {yyyy} : 西暦(4桁)
    # {yy}: 西暦(2桁)
    # {mm}: 月(09)
    # {dd}: 日(01)
    # {id,桁数}: dtb_order.idの桁数分0埋め(桁数を超えたらそのまま表示)
    # {random,桁数}: ランダムな数値を桁数分作成
    # {random_alnum,桁数} : ランダムな半角英数大文字を桁数分作成
    eccube_order_no_format: ''

で設定可能となります。

設定方法は記載されている通りで例えば、

    eccube_order_no_format: '{yyyy}{mm}{dd}-{id,3}'

と設定すると、注文番号は20221208-001と採番されます。桁数が3と指定されていますが、1000を超えると20221208-1000というようになります。

時々要望として上がるのが、日付度ごとに注文番号を1から採番してほしいというのがあります。今回はそのカスタマイズ方法を説明します。 まずeccube.yaml

    # 注文番号のフォーマット. 以下のフォーマットが利用可能です. フォーマットを空にした場合, dtb_order.idを出力します.
    # {yyyy} : 西暦(4桁)
    # {yy}: 西暦(2桁)
    # {mm}: 月(09)
    # {dd}: 日(01)
    # {id,桁数}: dtb_order.idの桁数分0埋め(桁数を超えたらそのまま表示)
    # {random,桁数}: ランダムな数値を桁数分作成
    # {random_alnum,桁数} : ランダムな半角英数大文字を桁数分作成
    # {dd_no,桁数} : 日が変わると新たな連番を桁数分作成
    eccube_order_no_format: '{yyyy}{mm}{dd}-{dd_no,3}'

というように新たに{dd_no,桁数}という定義を行い、eccube_order_no_formatにそれを利用するように設定します。

次に、OrderNoProcessor.phpクラスを以下のように修正します。

  • src/Eccube/Service/PurchaseFlow/Processor/OrderNoProcessor.php
<?php

/*
 * This file is part of EC-CUBE
 *
 * Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.
 *
 * http://www.ec-cube.co.jp/
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Eccube\Service\PurchaseFlow\Processor;

use Eccube\Common\EccubeConfig;
use Eccube\Entity\ItemHolderInterface;
use Eccube\Entity\Order;
use Eccube\Repository\OrderRepository;
use Eccube\Service\PurchaseFlow\ItemHolderPreprocessor;
use Eccube\Service\PurchaseFlow\PurchaseContext;
use Eccube\Util\StringUtil;

class OrderNoProcessor implements ItemHolderPreprocessor
{
    /**
     * @var EccubeConfig
     */
    private $eccubeConfig;

    /**
     * @var OrderRepository
     */
    private $orderRepository;

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

    /**
     * {@inheritdoc}
     */
    public function process(ItemHolderInterface $itemHolder, PurchaseContext $context)
    {
        $Order = $itemHolder;

        if ($Order instanceof Order) {
            if ($Order->getOrderNo()) {
                return;
            }

            if (null === $Order->getId()) {
                return;
            }

            $format = $this->eccubeConfig['eccube_order_no_format'];
            if (empty($format)) {
                // フォーマットが設定されていなければ受注IDが設定される
                $Order->setOrderNo($Order->getId());
            } else {
                do {
                    $orderNo = preg_replace_callback('/\{(.*)}/U', function($matches) use ($Order) {
                        if (count($matches) === 2) {
                            $dateTime = new \DateTime('now', new \DateTimeZone($this->eccubeConfig->get('timezone')));
                            switch ($matches[1]) {
                                case 'yyyy':
                                    return $dateTime->format('Y');
                                case 'yy':
                                    return $dateTime->format('y');
                                case 'mm':
                                    return $dateTime->format('m');
                                case 'dd':
                                    return $dateTime->format('d');
                                default:
                                    $res = explode(',', str_replace(' ', '', $matches[1]));
                                    if (count($res) === 2 && $res[0] == 'dd_no') {
                                        $count = $this->orderRepository->getTodayOrderCount();
                                        if (is_numeric($res[1])) {
                                            return sprintf("%0{$res[1]}d", $count);
                                        } else {
                                            return $count;
                                        }
                                    } else {
                                        if (count($res) === 2 && is_numeric($res[1])) {
                                            if ($res[0] === 'id') {
                                                return sprintf("%0{$res[1]}d", $Order->getId());
                                            } elseif ($res[0] === 'random') {
                                                $random = random_int(1, (int)str_repeat('9', $res[1]));

                                                return sprintf("%0{$res[1]}d", $random);
                                            } elseif ($res[0] === 'random_alnum') {
                                                return strtoupper(StringUtil::random($res[1]));
                                            }
                                        }
                                    }

                                    return $Order->getId();
                            }
                        }

                        return $Order->getId();
                    }, $format);

                    $tempOrder = $this->orderRepository->findOneBy([
                        'order_no' => $orderNo,
                    ]);
                } while ($tempOrder);

                $Order->setOrderNo($orderNo);
            }
        }
    }
}

84行目から91行目が新たに追加した内容です。今回は日毎という事なので、dtb_orderを日付ごとにカウントする関数をOrderRepository.phpクラスに追加します。

以下関数のみを抜粋します。

  • src/Eccube/Repository/OrderRepository.php
<?php
・
・
・

    /**
     * 当日の注文件数を取得
     *
     * @return float|int|mixed|string
     * @throws NoResultException
     * @throws \Doctrine\ORM\NonUniqueResultException
     */
    public function getTodayOrderCount()
    {
        $today = Carbon::today();

        $qb = $this->createQueryBuilder('o')
            ->select('COALESCE(COUNT(o.id) + 1, 1)')
            ->andWhere('o.create_date >= :create_date_start')
            ->andWhere('o.create_date < :create_date_end')
            ->andWhere('o.order_no is not null')
            ->setParameter('create_date_start', $today)
            ->setParameter('create_date_end', $today->copy()->addDay());

        $count = $qb
            ->getQuery()
            ->getSingleScalarResult();

        return $count;
    }

本来であればorder_dateで比較したいところですが、一部の画面遷移で正常にカウントした値がとれず、無限ループが発生してしまうため、create_dateを利用しています。

こちらを設定すると注文番号は、

20221208-001
20221208-002
20221208-003
20221209-001
20221209-002
20221209-003
20221210-001
20221210-002

というように日毎に新たに1から採番されます。

以上で日付が変われば1から新たに採番される方法となります。 利用しない方には特に不要なカスタマイズとなりますが、時々要望として言われる方はぜひご活用ください。

EC-CUBE4.2で doctrine:generate:entities を利用できるようにする方法

EC-CUBE Advent Calendar 2022 4日目の記事です。

2022年9月にEC-CUBE4.2がリリースされました。

仕様として大きな変更点はありませんが、内部的にはSymfonyのバージョンが5.4へとバージョンアップされています。 他にもライブラリのバージョンアップが行われています。 細かい変更点は以下をご覧ください。

github.com

今回、ライブラリのバージョンアップでEC-CUBE4.1まで利用できていた doctrine:generate:entities が利用できなくなっています。

これはEntityクラスにプロパティを追加した後に、このコマンドを実行すると自動でsetter、getter関数を作成してくれます。

カスタマイズをされない方には不要なコマンドですが、カラム追加などでカスタマイズする方にはあった方が何かと便利です。

利用したければMakerBundleを利用してくださいと説明されていますがそれも面倒なので、 今回は4.2でも doctrine:generate:entities を利用できるようにする方法を説明します。

方法は物凄く簡単で、4.1からGenerateEntitiesDoctrineCommandクラスをコピーしてディレクトリに配置するだけです。

GenerateEntitiesDoctrineCommandクラスの作成

  • src/Eccube/Command/GenerateEntitiesDoctrineCommand.php
<?php

namespace Eccube\Command;

use Doctrine\Bundle\DoctrineBundle\Command\DoctrineCommand;
use Doctrine\Bundle\DoctrineBundle\Mapping\DisconnectedMetadataFactory;
use Doctrine\ORM\Tools\EntityRepositoryGenerator;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Generate entity classes from mapping information
 *
 * @final
 */
class GenerateEntitiesDoctrineCommand extends DoctrineCommand
{
    /**
     * {@inheritDoc}
     */
    protected function configure()
    {
        $this
            ->setName('doctrine:generate:entities')
            ->setAliases(['generate:doctrine:entities'])
            ->setDescription('Generates entity classes and method stubs from your mapping information')
            ->addArgument('name', InputArgument::REQUIRED, 'A bundle name, a namespace, or a class name')
            ->addOption('path', null, InputOption::VALUE_REQUIRED, 'The path where to generate entities when it cannot be guessed')
            ->addOption('no-backup', null, InputOption::VALUE_NONE, 'Do not backup existing entities files.')
            ->setHelp(<<<EOT
The <info>%command.name%</info> command generates entity classes
and method stubs from your mapping information:

You have to limit generation of entities:

* To a bundle:

  <info>php %command.full_name% MyCustomBundle</info>

* To a single entity:

  <info>php %command.full_name% MyCustomBundle:User</info>
  <info>php %command.full_name% MyCustomBundle/Entity/User</info>

* To a namespace

  <info>php %command.full_name% MyCustomBundle/Entity</info>

If the entities are not stored in a bundle, and if the classes do not exist,
the command has no way to guess where they should be generated. In this case,
you must provide the <comment>--path</comment> option:

  <info>php %command.full_name% Blog/Entity --path=src/</info>

By default, the unmodified version of each entity is backed up and saved
(e.g. Product.php~). To prevent this task from creating the backup file,
pass the <comment>--no-backup</comment> option:

  <info>php %command.full_name% Blog/Entity --no-backup</info>

<error>Important:</error> Even if you specified Inheritance options in your
XML or YAML Mapping files the generator cannot generate the base and
child classes for you correctly, because it doesn't know which
class is supposed to extend which. You have to adjust the entity
code manually for inheritance to work!

EOT
        );
    }

    /**
     * {@inheritDoc}
     */
    protected function execute(InputInterface $input, OutputInterface $output)
    {
        trigger_error('The doctrine:generate:entity command has been deprecated.', E_USER_DEPRECATED);
        $output->writeln([
            ' <comment>NOTE:</comment> The <info>doctrine:generate:entities</info> command has been deprecated.',
            '       To read more about the differences between anemic and rich models go here <info>http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/tutorials/getting-started.html#adding-behavior-to-entities</info>.',
            '       If you wish to generate your entities, use <info>make:entity --regenerate</info> from MakerBundle instead.',
        ]);

        $manager = new DisconnectedMetadataFactory($this->getDoctrine());

        try {
            $bundle = $this->getApplication()->getKernel()->getBundle($input->getArgument('name'));

            $output->writeln(sprintf('Generating entities for bundle "<info>%s</info>"', $bundle->getName()));
            $metadata = $manager->getBundleMetadata($bundle);
        } catch (InvalidArgumentException $e) {
            $name = strtr($input->getArgument('name'), '/', '\\');
            $pos  = strpos($name, ':');

            if ($pos !== false) {
                $name = $this->getDoctrine()->getAliasNamespace(substr($name, 0, $pos)) . '\\' . substr($name, $pos + 1);
            }

            if (class_exists($name)) {
                $output->writeln(sprintf('Generating entity "<info>%s</info>"', $name));
                $metadata = $manager->getClassMetadata($name, $input->getOption('path'));
            } else {
                $output->writeln(sprintf('Generating entities for namespace "<info>%s</info>"', $name));
                $metadata = $manager->getNamespaceMetadata($name, $input->getOption('path'));
            }
        }

        $generator = $this->getEntityGenerator();

        $backupExisting = ! $input->getOption('no-backup');
        $generator->setBackupExisting($backupExisting);

        $repoGenerator = new EntityRepositoryGenerator();
        foreach ($metadata->getMetadata() as $m) {
            if ($backupExisting) {
                $basename = substr($m->name, strrpos($m->name, '\\') + 1);
                $output->writeln(sprintf('  > backing up <comment>%s.php</comment> to <comment>%s.php~</comment>', $basename, $basename));
            }
            // Getting the metadata for the entity class once more to get the correct path if the namespace has multiple occurrences
            try {
                $entityMetadata = $manager->getClassMetadata($m->getName(), $input->getOption('path'));
            } catch (RuntimeException $e) {
                // fall back to the bundle metadata when no entity class could be found
                $entityMetadata = $metadata;
            }

            $output->writeln(sprintf('  > generating <comment>%s</comment>', $m->name));
            $generator->generate([$m], $entityMetadata->getPath());

            if (! $m->customRepositoryClassName || strpos($m->customRepositoryClassName, $metadata->getNamespace()) === false) {
                continue;
            }

            $repoGenerator->writeEntityRepositoryClass($m->customRepositoryClassName, $metadata->getPath());
        }

        return 0;
    }
}

namespaceのみEccube\Command に変更しています。

追加後、ターミナルなどから

php bin/console doctrine:generate:entities Eccube/Entity/Product

を実行するとsetter、getterが作成されます。

カスタマイズが必要でない方には不要な内容ですが、カスタマイズをされている方で今まで利用できていたのに困っていたという方はぜひお試しください。

共用サーバでMySQL利用時のsql_mode設定について

レンタルサーバを契約してEC-CUBE環境を作成されている方は、殆どMySQLを利用されている方が多いと思います。

ただ、レンタルサーバ会社によってはsql_modeの設定がデフォルトのままという時もあります。

基本的には標準でも問題ありませんが、カスタマイズされている方は時々、

ONLY_FULL_GROUP_BY

が影響して正しく動作しない時があります。my.cnfが修正できれば良いのですが、共用サーバのため当然修正なんてできません。

それを回避するために、

app/config/eccube/packages/doctrine.yaml

の設定を以下のように変更すれば対応可能です。

  • app/config/eccube/packages/doctrine.yaml
・
・
・
doctrine:
    dbal:
        driver: 'pdo_sqlite'
        server_version: "%env(DATABASE_SERVER_VERSION)%dbal"
        charset: utf8

        # for mysql only
        default_table_options:
          collate: 'utf8_general_ci'
        options:
            1002: 'SET sql_mode=(SELECT REPLACE(@@sql_mode, "ONLY_FULL_GROUP_BY", ""))'
・
・
・

上記内容では、optionsというパラメータを追記しました。

options:
    1002: 'SET sql_mode=(SELECT REPLACE(@@sql_mode, "ONLY_FULL_GROUP_BY", ""))'

これで問題なく動作されるようになる筈なので一度お試しください。

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内で分岐させて表示させる方法で十分だと思います。

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