From 2f364a6e16272a5d5b33572d740a1793003e946e Mon Sep 17 00:00:00 2001 From: tao-s Date: Thu, 11 Apr 2024 09:25:24 +0900 Subject: [PATCH 1/6] =?UTF-8?q?ref:=20#6136=20Organization,=20Website?= =?UTF-8?q?=E3=81=AE=E3=83=9E=E3=83=BC=E3=82=AF=E3=82=A2=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../template/default/Schema/organization.twig | 44 +++++++++++++++++++ .../template/default/Schema/website.twig | 20 +++++++++ .../template/default/default_frame.twig | 3 ++ 3 files changed, 67 insertions(+) create mode 100644 src/Eccube/Resource/template/default/Schema/organization.twig create mode 100644 src/Eccube/Resource/template/default/Schema/website.twig diff --git a/src/Eccube/Resource/template/default/Schema/organization.twig b/src/Eccube/Resource/template/default/Schema/organization.twig new file mode 100644 index 00000000000..9f105963f99 --- /dev/null +++ b/src/Eccube/Resource/template/default/Schema/organization.twig @@ -0,0 +1,44 @@ +{ + "@context": "https://schema.org", + "@type": "Organization", + "url": "{{url('homepage')}}", + "logo": { + "@type": "ImageObject", + "contentUrl": "{{ asset('assets/img/common/favicon.ico', 'user_data') }}" + }, + "name": "{{BaseInfo.shop_name}}", + "alternateName": "{{BaseInfo.shop_name_eng}}", + "legalName": "{{BaseInfo.company_name}}", + "description": "{{BaseInfo.message}}", + "email": "{{BaseInfo.email01}}", + "telephone": "+81-{{BaseInfo.phone_number}}", + "address": { + "@type": "PostalAddress", + "streetAddress": "{{BaseInfo.addr02}}", + "addressLocality": "{{BaseInfo.addr01}}", + "addressCountry": "JP", + "addressRegion": "{{BaseInfo.Pref.name}}", + "postalCode": "{{BaseInfo.postal_code}}" + }, + "contactPoint": { + "@type": "ContactPoint", + "telephone": "+81-{{BaseInfo.phone_number}}", + "email": "{{BaseInfo.email02}}", + "url": "{{url('contact')}}" + } + {% if BaseInfo.invoice_registration_number is not null %} + ,"iso6523Code": "0221:{{BaseInfo.invoice_registration_number}}" + {% endif %} +{# + ,"image": "{{ asset('assets/img/common/site_image.png', 'user_data') }}", + "sameAs": [ + "https://twitter.com/EC_CUBE", + "https://www.ec-cube.net/" + ], + "numberOfEmployees": { + "@type": "QuantitativeValue", + "value": 2056 + }, + "foundingDate": "1970-01-01" +#} +} \ No newline at end of file diff --git a/src/Eccube/Resource/template/default/Schema/website.twig b/src/Eccube/Resource/template/default/Schema/website.twig new file mode 100644 index 00000000000..4a991335b16 --- /dev/null +++ b/src/Eccube/Resource/template/default/Schema/website.twig @@ -0,0 +1,20 @@ +{ + "@context" : "https://schema.org", + "@type" : "WebSite", + "name" : "{{BaseInfo.shop_name}}", + "alternateName" : "{{BaseInfo.shop_name_eng}}", + "url" : "{{url('homepage')}}", + "description": "{{BaseInfo.good_traded}}", + "potentialAction": { + "@type": "SearchAction", + "target": { + "@type": "EntryPoint", + "urlTemplate": "{{url('product_list')}}?name={search_term_string}" + }, + "query-input": "required name=search_term_string" + }, + "author":{{include('Schema/organization.twig')}} +{# + ,"copyrightYear": "2024" +#} +} \ No newline at end of file diff --git a/src/Eccube/Resource/template/default/default_frame.twig b/src/Eccube/Resource/template/default/default_frame.twig index ae70411b105..a9b4edcc268 100644 --- a/src/Eccube/Resource/template/default/default_frame.twig +++ b/src/Eccube/Resource/template/default/default_frame.twig @@ -187,5 +187,8 @@ file that was distributed with this source code. {{ include('snippet.twig', { snippets: plugin_snippets }) }} {% endif %} + From 2c79b6b55deca423ae70a31ee92599954fede4ae Mon Sep 17 00:00:00 2001 From: "takumi.tokoro" Date: Wed, 22 Jul 2026 11:32:08 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(schema):=20=E3=82=B5=E3=82=A4=E3=83=88?= =?UTF-8?q?=E6=A7=8B=E9=80=A0=E5=8C=96=E3=83=87=E3=83=BC=E3=82=BF=E3=82=92?= =?UTF-8?q?=20json=5Fld=20=E3=83=95=E3=82=A3=E3=83=AB=E3=82=BF=E7=B5=8C?= =?UTF-8?q?=E7=94=B1=E3=81=AE=E5=AE=89=E5=85=A8=E3=81=AA=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Organization / WebSite の JSON-LD をテンプレートの文字列補間で組み立てていたため、 店名等に " や が含まれると構造化データが壊れる/XSS 経路になりうる問題があった (CodeRabbit 指摘)。#6883 で導入済みの json_ld フィルタ(JSON_HEX_* でエスケープ)に揃える。 - SiteStructuredDataService を新設し WebSite / Organization の連想配列を組み立て - TwigInitializeListener で site_json_ld を front グローバルに注入 - default_frame.twig は {{ site_json_ld|json_ld }} で出力 - 旧 Schema/organization.twig・website.twig を削除 - 値が空の任意プロパティは出力しない(ProductStructuredDataService と同方針) - 都道府県未設定(Pref=null)でもフロントが 500 にならない(元 PR の不具合も解消) - SiteStructuredDataServiceTest を追加 出力される JSON-LD は、値のある項目については従来と同一 (空の任意プロパティが省略される点のみ差分)。 Refs #6136 #6147 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EventListener/TwigInitializeListener.php | 4 +- .../template/default/Schema/organization.twig | 44 ----- .../template/default/Schema/website.twig | 20 -- .../template/default/default_frame.twig | 6 +- .../Service/SiteStructuredDataService.php | 180 ++++++++++++++++++ .../Service/SiteStructuredDataServiceTest.php | 103 ++++++++++ 6 files changed, 289 insertions(+), 68 deletions(-) delete mode 100644 src/Eccube/Resource/template/default/Schema/organization.twig delete mode 100644 src/Eccube/Resource/template/default/Schema/website.twig create mode 100644 src/Eccube/Service/SiteStructuredDataService.php create mode 100644 tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php diff --git a/src/Eccube/EventListener/TwigInitializeListener.php b/src/Eccube/EventListener/TwigInitializeListener.php index d6dbe2ace48..f9554abb93f 100644 --- a/src/Eccube/EventListener/TwigInitializeListener.php +++ b/src/Eccube/EventListener/TwigInitializeListener.php @@ -31,6 +31,7 @@ use Eccube\Repository\PageLayoutRepository; use Eccube\Repository\PageRepository; use Eccube\Request\Context; +use Eccube\Service\SiteStructuredDataService; use Eccube\Service\SystemService; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpFoundation\ParameterBag; @@ -49,7 +50,7 @@ class TwigInitializeListener implements EventSubscriberInterface /** * TwigInitializeListener constructor. */ - public function __construct(protected Environment $twig, protected BaseInfoRepository $baseInfoRepository, protected PageRepository $pageRepository, protected PageLayoutRepository $pageLayoutRepository, protected BlockPositionRepository $blockPositionRepository, protected DeviceTypeRepository $deviceTypeRepository, private readonly AuthorityRoleRepository $authorityRoleRepository, private EccubeConfig $eccubeConfig, protected Context $requestContext, private readonly MobileDetect $mobileDetector, private readonly UrlGeneratorInterface $router, private readonly LayoutRepository $layoutRepository, protected SystemService $systemService) + public function __construct(protected Environment $twig, protected BaseInfoRepository $baseInfoRepository, protected PageRepository $pageRepository, protected PageLayoutRepository $pageLayoutRepository, protected BlockPositionRepository $blockPositionRepository, protected DeviceTypeRepository $deviceTypeRepository, private readonly AuthorityRoleRepository $authorityRoleRepository, private EccubeConfig $eccubeConfig, protected Context $requestContext, private readonly MobileDetect $mobileDetector, private readonly UrlGeneratorInterface $router, private readonly LayoutRepository $layoutRepository, protected SystemService $systemService, private readonly SiteStructuredDataService $siteStructuredDataService) { } @@ -177,6 +178,7 @@ public function setFrontVariables(RequestEvent $event): void $this->twig->addGlobal('title', $Page->getName()); $this->twig->addGlobal('isMaintenance', $this->systemService->isMaintenanceMode()); $this->twig->addGlobal('isDebugMode', env('APP_DEBUG')); + $this->twig->addGlobal('site_json_ld', $this->siteStructuredDataService->createWebSiteJsonLd($this->baseInfoRepository->get())); } public function setAdminGlobals(RequestEvent $event): void diff --git a/src/Eccube/Resource/template/default/Schema/organization.twig b/src/Eccube/Resource/template/default/Schema/organization.twig deleted file mode 100644 index 9f105963f99..00000000000 --- a/src/Eccube/Resource/template/default/Schema/organization.twig +++ /dev/null @@ -1,44 +0,0 @@ -{ - "@context": "https://schema.org", - "@type": "Organization", - "url": "{{url('homepage')}}", - "logo": { - "@type": "ImageObject", - "contentUrl": "{{ asset('assets/img/common/favicon.ico', 'user_data') }}" - }, - "name": "{{BaseInfo.shop_name}}", - "alternateName": "{{BaseInfo.shop_name_eng}}", - "legalName": "{{BaseInfo.company_name}}", - "description": "{{BaseInfo.message}}", - "email": "{{BaseInfo.email01}}", - "telephone": "+81-{{BaseInfo.phone_number}}", - "address": { - "@type": "PostalAddress", - "streetAddress": "{{BaseInfo.addr02}}", - "addressLocality": "{{BaseInfo.addr01}}", - "addressCountry": "JP", - "addressRegion": "{{BaseInfo.Pref.name}}", - "postalCode": "{{BaseInfo.postal_code}}" - }, - "contactPoint": { - "@type": "ContactPoint", - "telephone": "+81-{{BaseInfo.phone_number}}", - "email": "{{BaseInfo.email02}}", - "url": "{{url('contact')}}" - } - {% if BaseInfo.invoice_registration_number is not null %} - ,"iso6523Code": "0221:{{BaseInfo.invoice_registration_number}}" - {% endif %} -{# - ,"image": "{{ asset('assets/img/common/site_image.png', 'user_data') }}", - "sameAs": [ - "https://twitter.com/EC_CUBE", - "https://www.ec-cube.net/" - ], - "numberOfEmployees": { - "@type": "QuantitativeValue", - "value": 2056 - }, - "foundingDate": "1970-01-01" -#} -} \ No newline at end of file diff --git a/src/Eccube/Resource/template/default/Schema/website.twig b/src/Eccube/Resource/template/default/Schema/website.twig deleted file mode 100644 index 4a991335b16..00000000000 --- a/src/Eccube/Resource/template/default/Schema/website.twig +++ /dev/null @@ -1,20 +0,0 @@ -{ - "@context" : "https://schema.org", - "@type" : "WebSite", - "name" : "{{BaseInfo.shop_name}}", - "alternateName" : "{{BaseInfo.shop_name_eng}}", - "url" : "{{url('homepage')}}", - "description": "{{BaseInfo.good_traded}}", - "potentialAction": { - "@type": "SearchAction", - "target": { - "@type": "EntryPoint", - "urlTemplate": "{{url('product_list')}}?name={search_term_string}" - }, - "query-input": "required name=search_term_string" - }, - "author":{{include('Schema/organization.twig')}} -{# - ,"copyrightYear": "2024" -#} -} \ No newline at end of file diff --git a/src/Eccube/Resource/template/default/default_frame.twig b/src/Eccube/Resource/template/default/default_frame.twig index a9b4edcc268..d356f1f2cde 100644 --- a/src/Eccube/Resource/template/default/default_frame.twig +++ b/src/Eccube/Resource/template/default/default_frame.twig @@ -187,8 +187,8 @@ file that was distributed with this source code. {{ include('snippet.twig', { snippets: plugin_snippets }) }} {% endif %} - +{% if site_json_ld is defined and site_json_ld %} + +{% endif %} diff --git a/src/Eccube/Service/SiteStructuredDataService.php b/src/Eccube/Service/SiteStructuredDataService.php new file mode 100644 index 00000000000..790e3cf0f30 --- /dev/null +++ b/src/Eccube/Service/SiteStructuredDataService.php @@ -0,0 +1,180 @@ + &` などを機械的にエスケープする(テンプレート文字列補間による JSON 破壊・XSS を防ぐ)。 + * + * 値が空の任意プロパティは出力しない(ProductStructuredDataService と同じ方針)。 + */ +class SiteStructuredDataService +{ + /** + * ロゴに用いるファビコン画像ファイル(user_data 配下). + */ + private const LOGO_FILE = 'assets/img/common/favicon.ico'; + + public function __construct( + private readonly UrlGeneratorInterface $urlGenerator, + private readonly Packages $packages, + ) { + } + + /** + * サイト共通の JSON-LD 構造(WebSite。author に Organization を内包)を組み立てて返す. + * + * @return array + */ + public function createWebSiteJsonLd(BaseInfo $BaseInfo): array + { + $data = [ + '@context' => 'https://schema.org', + '@type' => 'WebSite', + 'name' => (string) $BaseInfo->getShopName(), + ]; + $this->addIfNotEmpty($data, 'alternateName', $BaseInfo->getShopNameEng()); + $data['url'] = $this->generateAbsoluteUrl('homepage'); + $this->addIfNotEmpty($data, 'description', $BaseInfo->getGoodTraded()); + $data['potentialAction'] = [ + '@type' => 'SearchAction', + 'target' => [ + '@type' => 'EntryPoint', + 'urlTemplate' => $this->generateAbsoluteUrl('product_list').'?name={search_term_string}', + ], + 'query-input' => 'required name=search_term_string', + ]; + $data['author'] = $this->createOrganizationJsonLd($BaseInfo); + + return $data; + } + + /** + * Organization の JSON-LD 構造を組み立てて返す. + * + * @return array + */ + public function createOrganizationJsonLd(BaseInfo $BaseInfo): array + { + $data = [ + '@context' => 'https://schema.org', + '@type' => 'Organization', + 'url' => $this->generateAbsoluteUrl('homepage'), + 'logo' => [ + '@type' => 'ImageObject', + 'contentUrl' => $this->packages->getUrl(self::LOGO_FILE, 'user_data'), + ], + 'name' => (string) $BaseInfo->getShopName(), + ]; + $this->addIfNotEmpty($data, 'alternateName', $BaseInfo->getShopNameEng()); + $this->addIfNotEmpty($data, 'legalName', $BaseInfo->getCompanyName()); + $this->addIfNotEmpty($data, 'description', $BaseInfo->getMessage()); + $this->addIfNotEmpty($data, 'email', $BaseInfo->getEmail01()); + + $phoneNumber = $BaseInfo->getPhoneNumber(); + if ($phoneNumber !== null && $phoneNumber !== '') { + $data['telephone'] = '+81-'.$phoneNumber; + } + + $address = $this->buildAddress($BaseInfo); + if ($address !== null) { + $data['address'] = $address; + } + + $contactPoint = $this->buildContactPoint($BaseInfo, $phoneNumber); + if ($contactPoint !== null) { + $data['contactPoint'] = $contactPoint; + } + + $invoiceRegistrationNumber = $BaseInfo->getInvoiceRegistrationNumber(); + if ($invoiceRegistrationNumber !== null && $invoiceRegistrationNumber !== '') { + $data['iso6523Code'] = '0221:'.$invoiceRegistrationNumber; + } + + return $data; + } + + /** + * PostalAddress 構造を組み立てる(住所要素が1つも無ければ null). + * + * @return array|null + */ + private function buildAddress(BaseInfo $BaseInfo): ?array + { + $address = ['@type' => 'PostalAddress']; + $this->addIfNotEmpty($address, 'streetAddress', $BaseInfo->getAddr02()); + $this->addIfNotEmpty($address, 'addressLocality', $BaseInfo->getAddr01()); + $this->addIfNotEmpty($address, 'addressRegion', $BaseInfo->getPref()?->getName()); + $this->addIfNotEmpty($address, 'postalCode', $BaseInfo->getPostalCode()); + + // @type 以外に住所要素が無ければ出力しない + if (count($address) === 1) { + return null; + } + + $address['addressCountry'] = 'JP'; + + return $address; + } + + /** + * ContactPoint 構造を組み立てる(連絡手段が1つも無ければ null). + * + * @return array|null + */ + private function buildContactPoint(BaseInfo $BaseInfo, ?string $phoneNumber): ?array + { + $contactPoint = ['@type' => 'ContactPoint']; + if ($phoneNumber !== null && $phoneNumber !== '') { + $contactPoint['telephone'] = '+81-'.$phoneNumber; + } + $this->addIfNotEmpty($contactPoint, 'email', $BaseInfo->getEmail02()); + + // telephone / email が無ければ url だけの ContactPoint は出力しない + if (!isset($contactPoint['telephone']) && !isset($contactPoint['email'])) { + return null; + } + + $contactPoint['url'] = $this->generateAbsoluteUrl('contact'); + + return $contactPoint; + } + + /** + * ルート名から絶対URLを生成する. + */ + private function generateAbsoluteUrl(string $route): string + { + return $this->urlGenerator->generate($route, [], UrlGeneratorInterface::ABSOLUTE_URL); + } + + /** + * 値が null / 空文字でない場合のみ連想配列へ追加する. + * + * @param array $data + */ + private function addIfNotEmpty(array &$data, string $key, ?string $value): void + { + if ($value !== null && $value !== '') { + $data[$key] = $value; + } + } +} diff --git a/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php b/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php new file mode 100644 index 00000000000..3d3c30e5c62 --- /dev/null +++ b/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php @@ -0,0 +1,103 @@ +service = static::getContainer()->get(SiteStructuredDataService::class); + $this->BaseInfo = static::getContainer()->get(BaseInfoRepository::class)->get(); + } + + public function testWebSiteBaseStructure(): void + { + $data = $this->service->createWebSiteJsonLd($this->BaseInfo); + + $this->assertSame('https://schema.org', $data['@context']); + $this->assertSame('WebSite', $data['@type']); + $this->assertSame((string) $this->BaseInfo->getShopName(), $data['name']); + $this->assertStringStartsWith('http', $data['url']); + $this->assertSame('SearchAction', $data['potentialAction']['@type']); + $this->assertArrayHasKey('author', $data); + $this->assertSame('Organization', $data['author']['@type']); + } + + public function testOrganizationBaseStructure(): void + { + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + $this->assertSame('https://schema.org', $data['@context']); + $this->assertSame('Organization', $data['@type']); + $this->assertStringStartsWith('http', $data['url']); + $this->assertSame('ImageObject', $data['logo']['@type']); + $this->assertNotEmpty($data['logo']['contentUrl']); + $this->assertSame((string) $this->BaseInfo->getShopName(), $data['name']); + } + + public function testEmptyOptionalPropertiesAreOmitted(): void + { + $this->BaseInfo->setShopNameEng(null); + $this->BaseInfo->setGoodTraded(null); + + $data = $this->service->createWebSiteJsonLd($this->BaseInfo); + + $this->assertArrayNotHasKey('alternateName', $data); + $this->assertArrayNotHasKey('description', $data); + $this->assertArrayNotHasKey('alternateName', $data['author']); + } + + public function testPrefNullOmitsAddressRegionWithoutError(): void + { + $this->BaseInfo->setPref(null); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + // 都道府県未設定でも例外にならず、addressRegion のみ欠落する + $this->assertSame('Organization', $data['@type']); + if (isset($data['address'])) { + $this->assertArrayNotHasKey('addressRegion', $data['address']); + } + } + + public function testInvoiceRegistrationNumberIsIncludedWhenPresent(): void + { + $this->BaseInfo->setInvoiceRegistrationNumber('T1234567890123'); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + $this->assertSame('0221:T1234567890123', $data['iso6523Code']); + } + + public function testEmptyInvoiceRegistrationNumberIsOmitted(): void + { + $this->BaseInfo->setInvoiceRegistrationNumber(''); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + // 空文字の場合は "0221:" だけの iso6523Code を出力しない + $this->assertArrayNotHasKey('iso6523Code', $data); + } +} From b9d9b26aad181c88ac9e6e67f4f74fe2fe82c9d5 Mon Sep 17 00:00:00 2001 From: "takumi.tokoro" Date: Wed, 22 Jul 2026 13:34:22 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(schema):=20=E5=BA=97=E8=88=97=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=81=AB=E6=A7=8B=E9=80=A0=E5=8C=96=E3=83=87=E3=83=BC?= =?UTF-8?q?=E3=82=BF=E6=8B=A1=E5=BC=B5=E9=A0=85=E7=9B=AE=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=EF=BC=88sameAs/foundingDate=20=E7=AD=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #6147。#6139 で新設した SiteStructuredDataService に、店舗設定 (BaseInfo) から出力する構造化データ項目を拡充する。 - BaseInfo に same_as / founding_date / number_of_employees / copyright_year / site_image を追加 - 店舗設定「基本設定」に入力欄(ShopMasterType / shop_master.twig / 翻訳 ja,en)を追加 - SiteStructuredDataService で Organization の sameAs(配列)/ foundingDate / numberOfEmployees(QuantitativeValue)/ image、WebSite の copyrightYear を出力 - マイグレーション追加(Version20260722000000, TEXT型はMySQL/PostgreSQLで分岐) - SiteStructuredDataServiceTest に各項目のテストを追加 値が空の任意プロパティは出力しない(#6139 の方針を踏襲)。 Refs #6136 #6147 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Version20260722000000.php | 73 ++++++++++++ src/Eccube/Entity/BaseInfo.php | 107 ++++++++++++++++++ src/Eccube/Form/Type/Admin/ShopMasterType.php | 42 +++++++ src/Eccube/Resource/locale/messages.en.yaml | 10 ++ src/Eccube/Resource/locale/messages.ja.yaml | 10 ++ .../admin/Setting/Shop/shop_master.twig | 45 ++++++++ .../Service/SiteStructuredDataService.php | 43 +++++++ .../Service/SiteStructuredDataServiceTest.php | 78 +++++++++++++ 8 files changed, 408 insertions(+) create mode 100644 app/DoctrineMigrations/Version20260722000000.php diff --git a/app/DoctrineMigrations/Version20260722000000.php b/app/DoctrineMigrations/Version20260722000000.php new file mode 100644 index 00000000000..65adb736acc --- /dev/null +++ b/app/DoctrineMigrations/Version20260722000000.php @@ -0,0 +1,73 @@ +hasTable(self::NAME)) { + return; + } + + $table = $schema->getTable(self::NAME); + if ($table->hasColumn('same_as')) { + return; + } + + // TEXT 型は MySQL では LONGTEXT にマップされるため、schema:update との差分を避けて分岐する + $textType = $this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform ? 'LONGTEXT' : 'TEXT'; + + $this->addSql('ALTER TABLE dtb_base_info ADD same_as '.$textType.' DEFAULT NULL'); + $this->addSql('ALTER TABLE dtb_base_info ADD founding_date DATE DEFAULT NULL'); + $this->addSql('ALTER TABLE dtb_base_info ADD number_of_employees INT DEFAULT NULL'); + $this->addSql('ALTER TABLE dtb_base_info ADD copyright_year INT DEFAULT NULL'); + $this->addSql('ALTER TABLE dtb_base_info ADD site_image VARCHAR(255) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + $table = $schema->getTable(self::NAME); + if (!$table->hasColumn('same_as')) { + return; + } + + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN same_as'); + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN founding_date'); + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN number_of_employees'); + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN copyright_year'); + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN site_image'); + } +} diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 064dc6d5b1c..8ea6a8805c3 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -170,6 +170,21 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'ucp_catalog_requires_auth', type: Types::BOOLEAN, options: ['default' => false])] private bool $ucp_catalog_requires_auth = false; + #[ORM\Column(name: 'same_as', type: Types::TEXT, nullable: true)] + private ?string $same_as = null; + + #[ORM\Column(name: 'founding_date', type: Types::DATE_MUTABLE, nullable: true)] + private ?\DateTime $founding_date = null; + + #[ORM\Column(name: 'number_of_employees', type: Types::INTEGER, nullable: true)] + private ?int $number_of_employees = null; + + #[ORM\Column(name: 'copyright_year', type: Types::INTEGER, nullable: true)] + private ?int $copyright_year = null; + + #[ORM\Column(name: 'site_image', type: Types::STRING, length: 255, nullable: true)] + private ?string $site_image = null; + /** * Get id. * @@ -937,4 +952,96 @@ public function isUcpCatalogRequiresAuth(): bool { return $this->ucp_catalog_requires_auth; } + + /** + * Get sameAs. + * + * 構造化データ(Organization.sameAs)に出力する SNS 等の公式 URL を改行区切りで保持する. + */ + public function getSameAs(): ?string + { + return $this->same_as; + } + + /** + * Set sameAs. + */ + public function setSameAs(?string $sameAs): BaseInfo + { + $this->same_as = $sameAs; + + return $this; + } + + /** + * Get foundingDate. + */ + public function getFoundingDate(): ?\DateTime + { + return $this->founding_date; + } + + /** + * Set foundingDate. + */ + public function setFoundingDate(?\DateTime $foundingDate): BaseInfo + { + $this->founding_date = $foundingDate; + + return $this; + } + + /** + * Get numberOfEmployees. + */ + public function getNumberOfEmployees(): ?int + { + return $this->number_of_employees; + } + + /** + * Set numberOfEmployees. + */ + public function setNumberOfEmployees(?int $numberOfEmployees): BaseInfo + { + $this->number_of_employees = $numberOfEmployees; + + return $this; + } + + /** + * Get copyrightYear. + */ + public function getCopyrightYear(): ?int + { + return $this->copyright_year; + } + + /** + * Set copyrightYear. + */ + public function setCopyrightYear(?int $copyrightYear): BaseInfo + { + $this->copyright_year = $copyrightYear; + + return $this; + } + + /** + * Get siteImage. + */ + public function getSiteImage(): ?string + { + return $this->site_image; + } + + /** + * Set siteImage. + */ + public function setSiteImage(?string $siteImage): BaseInfo + { + $this->site_image = $siteImage; + + return $this; + } } diff --git a/src/Eccube/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index eb5fa362bb7..1153940520d 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -23,6 +23,7 @@ use Eccube\Form\Type\ToggleSwitchType; use Eccube\Form\Validator\Email; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\Extension\Core\Type\NumberType; @@ -142,6 +143,47 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]), ], ]) + // 構造化データ(JSON-LD / schema.org) + ->add('same_as', TextareaType::class, [ + 'required' => false, + 'constraints' => [ + new Assert\Length([ + 'max' => $this->eccubeConfig['eccube_ltext_len'], + ]), + ], + ]) + ->add('founding_date', DateType::class, [ + 'required' => false, + 'input' => 'datetime', + 'widget' => 'single_text', + ]) + ->add('number_of_employees', IntegerType::class, [ + 'required' => false, + 'constraints' => [ + new Assert\Regex([ + 'pattern' => "/^\d+$/u", + 'message' => 'form_error.numeric_only', + ]), + ], + ]) + ->add('copyright_year', IntegerType::class, [ + 'required' => false, + 'constraints' => [ + new Assert\Range([ + 'min' => 1900, + 'max' => 9999, + ]), + ], + ]) + ->add('site_image', TextType::class, [ + 'required' => false, + 'constraints' => [ + new Assert\Length([ + 'max' => $this->eccubeConfig['eccube_stext_len'], + ]), + new Assert\Url(), + ], + ]) // 送料設定 ->add('delivery_free_amount', PriceType::class, [ 'required' => false, diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index a3f34448b1b..03f92583876 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1201,6 +1201,11 @@ admin.setting.shop.shop.email_reply_to: Emails for Replies (ReplyTo) admin.setting.shop.shop.email_return_path: Email for Errors (ReturnPath) admin.setting.shop.shop.good_traded: Product Descriptions admin.setting.shop.shop.message: Message from Owner +admin.setting.shop.shop.same_as: Official URLs (SNS, etc.) +admin.setting.shop.shop.founding_date: Founding Date +admin.setting.shop.shop.number_of_employees: Number of Employees +admin.setting.shop.shop.copyright_year: Copyright Start Year +admin.setting.shop.shop.site_image: Site Image URL admin.setting.shop.shop.option_delivery_fee: Shipping Charge admin.setting.shop.shop.option_delivery_fee_free_amount: Free Shipping (Amount) admin.setting.shop.shop.option_delivery_fee_free_quantity: Free Shipping (Qty) @@ -1791,6 +1796,11 @@ tooltip.setting.shop.shop.email_reply_to: This is the email address by which you tooltip.setting.shop.shop.email_return_path: This is the email address by which you will receive a notice when a transmission error occurs in sending emails from your store. tooltip.setting.shop.shop.good_traded: Brief descriptions of your products. tooltip.setting.shop.shop.message: This will be displayed on storefront. The placement varies according to the design templates. +tooltip.setting.shop.shop.same_as: Official account URLs (SNS, etc.) output to structured data (Organization.sameAs). Enter one URL per line to specify multiple URLs. +tooltip.setting.shop.shop.founding_date: The founding date of the shop/company, output to structured data (Organization.foundingDate). +tooltip.setting.shop.shop.number_of_employees: The number of employees, output to structured data (Organization.numberOfEmployees). +tooltip.setting.shop.shop.copyright_year: The starting year of the copyright notice, output to structured data (WebSite.copyrightYear). +tooltip.setting.shop.shop.site_image: The absolute URL of the site image, output to structured data (Organization.image). tooltip.setting.shop.shop.option_delivery_fee_free_amount: Shipping charge will be waived if a customer purchases more than this amount. tooltip.setting.shop.shop.option_delivery_fee_free_quantity: Shipping charge will be waived if a customer purchases more than this quantity. tooltip.setting.shop.shop.option_delivery_fee_by_product: If turned on, you can set shipping charge per product. diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 9e16c989b3c..2567be52cfb 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1200,6 +1200,11 @@ admin.setting.shop.shop.email_reply_to: 返信先メールアドレス(ReplyTo) admin.setting.shop.shop.email_return_path: 送信エラー通知メールアドレス(ReturnPath) admin.setting.shop.shop.good_traded: 取り扱い商品説明文 admin.setting.shop.shop.message: 店舗からのメッセージ +admin.setting.shop.shop.same_as: SNS等の公式URL +admin.setting.shop.shop.founding_date: 稼働開始日 +admin.setting.shop.shop.number_of_employees: 従業員数 +admin.setting.shop.shop.copyright_year: 著作権表示の開始年 +admin.setting.shop.shop.site_image: サイト代表画像URL admin.setting.shop.shop.option_delivery_fee: 送料設定 admin.setting.shop.shop.option_delivery_fee_free_amount: 送料無料条件(金額) admin.setting.shop.shop.option_delivery_fee_free_quantity: 送料無料条件(数量) @@ -1791,6 +1796,11 @@ tooltip.setting.shop.shop.email_reply_to: 返信メールを受け付けるメ tooltip.setting.shop.shop.email_return_path: 店舗からメールを送信しエラーが生じた場合に、その通知を受信するメールアドレスです。 tooltip.setting.shop.shop.good_traded: 店舗が取り扱う商品についての簡単な説明文です。 tooltip.setting.shop.shop.message: フロント側に表示されます。表示位置はデザインテンプレートによって異なります。 +tooltip.setting.shop.shop.same_as: 構造化データ(Organization.sameAs)に出力する、SNS等の公式アカウントURLです。1行に1URLで複数指定できます。 +tooltip.setting.shop.shop.founding_date: 構造化データ(Organization.foundingDate)に出力する、店舗・法人の稼働開始日です。 +tooltip.setting.shop.shop.number_of_employees: 構造化データ(Organization.numberOfEmployees)に出力する従業員数です。 +tooltip.setting.shop.shop.copyright_year: 構造化データ(WebSite.copyrightYear)に出力する著作権表示の開始年です。 +tooltip.setting.shop.shop.site_image: 構造化データ(Organization.image)に出力するサイト代表画像の絶対URLです。 tooltip.setting.shop.shop.option_delivery_fee_free_amount: この金額を超える購入があった場合、送料を無料とします。 tooltip.setting.shop.shop.option_delivery_fee_free_quantity: この個数を超える購入があった場合、送料を無料とします。 tooltip.setting.shop.shop.option_delivery_fee_by_product: ここをオンにすると、商品ごとに送料を指定できるようになります。 diff --git a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig index 0e37636b8d0..732557c9060 100644 --- a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig +++ b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig @@ -170,6 +170,51 @@ file that was distributed with this source code. {{ form_errors(form.message) }} +
+
+
{{ 'admin.setting.shop.shop.same_as'|trans }}
+
+
+ {{ form_widget(form.same_as) }} + {{ form_errors(form.same_as) }} +
+
+
+
+
{{ 'admin.setting.shop.shop.founding_date'|trans }}
+
+
+ {{ form_widget(form.founding_date) }} + {{ form_errors(form.founding_date) }} +
+
+
+
+
{{ 'admin.setting.shop.shop.number_of_employees'|trans }}
+
+
+ {{ form_widget(form.number_of_employees) }} + {{ form_errors(form.number_of_employees) }} +
+
+
+
+
{{ 'admin.setting.shop.shop.copyright_year'|trans }}
+
+
+ {{ form_widget(form.copyright_year) }} + {{ form_errors(form.copyright_year) }} +
+
+
+
+
{{ 'admin.setting.shop.shop.site_image'|trans }}
+
+
+ {{ form_widget(form.site_image) }} + {{ form_errors(form.site_image) }} +
+
{# エンティティ拡張の自動出力 #} {% for f in form|filter(f => f.vars.eccube_form_options.auto_render) %} {% if f.vars.eccube_form_options.form_theme %} diff --git a/src/Eccube/Service/SiteStructuredDataService.php b/src/Eccube/Service/SiteStructuredDataService.php index 790e3cf0f30..5d14df6da56 100644 --- a/src/Eccube/Service/SiteStructuredDataService.php +++ b/src/Eccube/Service/SiteStructuredDataService.php @@ -62,6 +62,12 @@ public function createWebSiteJsonLd(BaseInfo $BaseInfo): array ], 'query-input' => 'required name=search_term_string', ]; + + $copyrightYear = $BaseInfo->getCopyrightYear(); + if ($copyrightYear !== null) { + $data['copyrightYear'] = $copyrightYear; + } + $data['author'] = $this->createOrganizationJsonLd($BaseInfo); return $data; @@ -87,6 +93,7 @@ public function createOrganizationJsonLd(BaseInfo $BaseInfo): array $this->addIfNotEmpty($data, 'alternateName', $BaseInfo->getShopNameEng()); $this->addIfNotEmpty($data, 'legalName', $BaseInfo->getCompanyName()); $this->addIfNotEmpty($data, 'description', $BaseInfo->getMessage()); + $this->addIfNotEmpty($data, 'image', $BaseInfo->getSiteImage()); $this->addIfNotEmpty($data, 'email', $BaseInfo->getEmail01()); $phoneNumber = $BaseInfo->getPhoneNumber(); @@ -104,14 +111,50 @@ public function createOrganizationJsonLd(BaseInfo $BaseInfo): array $data['contactPoint'] = $contactPoint; } + $foundingDate = $BaseInfo->getFoundingDate(); + if ($foundingDate !== null) { + $data['foundingDate'] = $foundingDate->format('Y-m-d'); + } + + $numberOfEmployees = $BaseInfo->getNumberOfEmployees(); + if ($numberOfEmployees !== null) { + $data['numberOfEmployees'] = [ + '@type' => 'QuantitativeValue', + 'value' => $numberOfEmployees, + ]; + } + $invoiceRegistrationNumber = $BaseInfo->getInvoiceRegistrationNumber(); if ($invoiceRegistrationNumber !== null && $invoiceRegistrationNumber !== '') { $data['iso6523Code'] = '0221:'.$invoiceRegistrationNumber; } + $sameAs = $this->buildSameAs($BaseInfo->getSameAs()); + if ($sameAs !== []) { + $data['sameAs'] = $sameAs; + } + return $data; } + /** + * 改行区切りの SNS 等公式 URL 文字列を、空要素を除いた URL のリストに変換する. + * + * @return list + */ + private function buildSameAs(?string $sameAs): array + { + if ($sameAs === null || $sameAs === '') { + return []; + } + + $urls = preg_split('/\R/u', $sameAs) ?: []; + $urls = array_map(trim(...), $urls); + $urls = array_filter($urls, static fn (string $url): bool => $url !== ''); + + return array_values($urls); + } + /** * PostalAddress 構造を組み立てる(住所要素が1つも無ければ null). * diff --git a/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php b/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php index 3d3c30e5c62..8a944c5a6fc 100644 --- a/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php +++ b/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php @@ -100,4 +100,82 @@ public function testEmptyInvoiceRegistrationNumberIsOmitted(): void // 空文字の場合は "0221:" だけの iso6523Code を出力しない $this->assertArrayNotHasKey('iso6523Code', $data); } + + public function testSameAsIsSplitIntoListAndTrimmed(): void + { + $this->BaseInfo->setSameAs("https://example.com/a\n https://example.com/b \n\nhttps://example.com/c"); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + // 改行区切り→trim→空行除去でURLのリストになる + $this->assertSame([ + 'https://example.com/a', + 'https://example.com/b', + 'https://example.com/c', + ], $data['sameAs']); + } + + public function testEmptySameAsIsOmitted(): void + { + $this->BaseInfo->setSameAs(" \n "); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + $this->assertArrayNotHasKey('sameAs', $data); + } + + public function testFoundingDateIsFormatted(): void + { + $this->BaseInfo->setFoundingDate(new \DateTime('2000-04-01')); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + $this->assertSame('2000-04-01', $data['foundingDate']); + } + + public function testNumberOfEmployeesIsQuantitativeValue(): void + { + $this->BaseInfo->setNumberOfEmployees(42); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + $this->assertSame('QuantitativeValue', $data['numberOfEmployees']['@type']); + $this->assertSame(42, $data['numberOfEmployees']['value']); + } + + public function testSiteImageIsOutputAsImage(): void + { + $this->BaseInfo->setSiteImage('https://example.com/site.png'); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + $this->assertSame('https://example.com/site.png', $data['image']); + } + + public function testCopyrightYearIsOutputOnWebSite(): void + { + $this->BaseInfo->setCopyrightYear(2020); + + $data = $this->service->createWebSiteJsonLd($this->BaseInfo); + + $this->assertSame(2020, $data['copyrightYear']); + } + + public function testOptionalSchemaFieldsAreOmittedWhenUnset(): void + { + $this->BaseInfo->setSameAs(null); + $this->BaseInfo->setFoundingDate(null); + $this->BaseInfo->setNumberOfEmployees(null); + $this->BaseInfo->setSiteImage(null); + $this->BaseInfo->setCopyrightYear(null); + + $org = $this->service->createOrganizationJsonLd($this->BaseInfo); + $web = $this->service->createWebSiteJsonLd($this->BaseInfo); + + $this->assertArrayNotHasKey('sameAs', $org); + $this->assertArrayNotHasKey('foundingDate', $org); + $this->assertArrayNotHasKey('numberOfEmployees', $org); + $this->assertArrayNotHasKey('image', $org); + $this->assertArrayNotHasKey('copyrightYear', $web); + } } From 4e3a5c0e9811da4fc2d48f0e579d64dfeef5f10a Mon Sep 17 00:00:00 2001 From: "takumi.tokoro" Date: Wed, 22 Jul 2026 14:11:59 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(schema):=20=E5=96=B6=E6=A5=AD=E6=99=82?= =?UTF-8?q?=E9=96=93(OpeningHoursSpecification)=E3=82=92=E5=BA=97=E8=88=97?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A=E3=81=AB=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #6147。営業時間を構造化データ(Organization.openingHoursSpecification) として出力できるようにする。既存 business_hour(自由テキスト・Help表示用)とは 別に、曜日×開店/閉店時刻を構造化して保持する。 - OpeningHours エンティティ(dtb_opening_hours)を新設し BaseInfo に OneToMany を追加 - 店舗設定「基本設定」に CollectionType の営業時間入力欄(動的な行追加/削除UI)を追加 - OpeningHoursType(曜日=複数選択, opens/closes=TimeType)・prototype テンプレート - SiteStructuredDataService で openingHoursSpecification(配列)を出力 (曜日/時刻がいずれも無いエントリは出力しない) - バリデーション: 開店<閉店・曜日必須・開店/閉店ペア必須(OpeningHours)、 同一曜日の時間帯重複禁止(ShopMasterType)。BaseInfo の OneToMany に Assert\Valid でカスケード - 削除ボタンは既存の collection UI に合わせ fa-close アイコン化、時刻欄クリックで showPicker - マイグレーション Version20260722010000(Schema APIで両DB対応) - 翻訳(ja/en, ラベルは messages・エラーは validators)・テスト追加 Refs #6136 #6147 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Version20260722010000.php | 56 ++++++ src/Eccube/Entity/BaseInfo.php | 43 +++++ src/Eccube/Entity/OpeningHours.php | 169 ++++++++++++++++++ .../Form/Type/Admin/OpeningHoursType.php | 79 ++++++++ src/Eccube/Form/Type/Admin/ShopMasterType.php | 54 ++++++ .../Repository/OpeningHoursRepository.php | 30 ++++ src/Eccube/Resource/locale/messages.en.yaml | 16 ++ src/Eccube/Resource/locale/messages.ja.yaml | 16 ++ src/Eccube/Resource/locale/validators.en.yaml | 5 + src/Eccube/Resource/locale/validators.ja.yaml | 5 + .../Setting/Shop/opening_hours_prototype.twig | 26 +++ .../admin/Setting/Shop/shop_master.twig | 39 ++++ .../Service/SiteStructuredDataService.php | 44 +++++ .../Form/Type/Admin/ShopMasterTypeTest.php | 47 +++++ .../Service/SiteStructuredDataServiceTest.php | 29 +++ 15 files changed, 658 insertions(+) create mode 100644 app/DoctrineMigrations/Version20260722010000.php create mode 100644 src/Eccube/Entity/OpeningHours.php create mode 100644 src/Eccube/Form/Type/Admin/OpeningHoursType.php create mode 100644 src/Eccube/Repository/OpeningHoursRepository.php create mode 100644 src/Eccube/Resource/template/admin/Setting/Shop/opening_hours_prototype.twig diff --git a/app/DoctrineMigrations/Version20260722010000.php b/app/DoctrineMigrations/Version20260722010000.php new file mode 100644 index 00000000000..303b36ddf12 --- /dev/null +++ b/app/DoctrineMigrations/Version20260722010000.php @@ -0,0 +1,56 @@ +hasTable(self::NAME)) { + return; + } + + $table = $schema->createTable(self::NAME); + $table->addColumn('id', Types::INTEGER, ['autoincrement' => true, 'unsigned' => true]); + $table->addColumn('base_info_id', Types::INTEGER, ['unsigned' => true, 'notnull' => false]); + $table->addColumn('day_of_week', Types::SIMPLE_ARRAY, ['notnull' => false]); + $table->addColumn('opens', Types::TIME_MUTABLE, ['notnull' => false]); + $table->addColumn('closes', Types::TIME_MUTABLE, ['notnull' => false]); + $table->addColumn('sort_no', Types::INTEGER, ['default' => 0]); + $table->addColumn('discriminator_type', Types::STRING, ['length' => 255]); + $table->setPrimaryKey(['id']); + $table->addIndex(['base_info_id'], 'dtb_opening_hours_base_info_id_idx'); + $table->addForeignKeyConstraint('dtb_base_info', ['base_info_id'], ['id'], [], 'fk_opening_hours_base_info'); + } + + public function down(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + $schema->dropTable(self::NAME); + } +} diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 8ea6a8805c3..7fe3587e59d 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -13,11 +13,14 @@ namespace Eccube\Entity; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Eccube\Entity\Master\Country; use Eccube\Entity\Master\Pref; use Eccube\Repository\BaseInfoRepository; +use Symfony\Component\Validator\Constraints as Assert; #[ORM\Table(name: 'dtb_base_info')] #[ORM\InheritanceType('SINGLE_TABLE')] @@ -185,6 +188,19 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'site_image', type: Types::STRING, length: 255, nullable: true)] private ?string $site_image = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: OpeningHours::class, mappedBy: 'BaseInfo', cascade: ['persist', 'remove'], orphanRemoval: true)] + #[ORM\OrderBy(['sort_no' => 'ASC'])] + #[Assert\Valid] + private Collection $OpeningHours; + + public function __construct() + { + $this->OpeningHours = new ArrayCollection(); + } + /** * Get id. * @@ -1044,4 +1060,31 @@ public function setSiteImage(?string $siteImage): BaseInfo return $this; } + + /** + * Get openingHours. + * + * @return Collection + */ + public function getOpeningHours(): Collection + { + return $this->OpeningHours; + } + + public function addOpeningHour(OpeningHours $openingHour): BaseInfo + { + if (!$this->OpeningHours->contains($openingHour)) { + $this->OpeningHours[] = $openingHour; + $openingHour->setBaseInfo($this); + } + + return $this; + } + + public function removeOpeningHour(OpeningHours $openingHour): BaseInfo + { + $this->OpeningHours->removeElement($openingHour); + + return $this; + } } diff --git a/src/Eccube/Entity/OpeningHours.php b/src/Eccube/Entity/OpeningHours.php new file mode 100644 index 00000000000..ea5ff5c450f --- /dev/null +++ b/src/Eccube/Entity/OpeningHours.php @@ -0,0 +1,169 @@ + true])] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * 曜日(schema.org DayOfWeek。例: Monday)のリスト. + * + * @var array|null + */ + #[ORM\Column(name: 'day_of_week', type: Types::SIMPLE_ARRAY, nullable: true)] + private ?array $day_of_week = null; + + #[ORM\Column(name: 'opens', type: Types::TIME_MUTABLE, nullable: true)] + private ?\DateTime $opens = null; + + #[ORM\Column(name: 'closes', type: Types::TIME_MUTABLE, nullable: true)] + private ?\DateTime $closes = null; + + #[ORM\Column(name: 'sort_no', type: Types::INTEGER, options: ['default' => 0])] + private int $sort_no = 0; + + #[ORM\ManyToOne(targetEntity: BaseInfo::class, inversedBy: 'OpeningHours')] + #[ORM\JoinColumn(name: 'base_info_id', referencedColumnName: 'id')] + private ?BaseInfo $BaseInfo = null; + + /** + * 営業時間 1 エントリの入力内容を検証する. + * + * - いずれかが入力されている行は、曜日・開店・閉店をすべて必須とする + * - 開店時刻は閉店時刻より前でなければならない + * (すべて空の行は未使用行として検証しない) + */ + #[Assert\Callback] + public function validate(ExecutionContextInterface $context): void + { + $hasDay = $this->day_of_week !== null && $this->day_of_week !== []; + $hasOpens = $this->opens !== null; + $hasCloses = $this->closes !== null; + + if (!$hasDay && !$hasOpens && !$hasCloses) { + return; + } + + if (!$hasDay) { + $context->buildViolation('admin.setting.shop.opening_hours.error.day_required') + ->atPath('day_of_week') + ->addViolation(); + } + if (!$hasOpens) { + $context->buildViolation('admin.setting.shop.opening_hours.error.opens_required') + ->atPath('opens') + ->addViolation(); + } + if (!$hasCloses) { + $context->buildViolation('admin.setting.shop.opening_hours.error.closes_required') + ->atPath('closes') + ->addViolation(); + } + if ($hasOpens && $hasCloses && $this->opens >= $this->closes) { + $context->buildViolation('admin.setting.shop.opening_hours.error.opens_before_closes') + ->atPath('closes') + ->addViolation(); + } + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return array|null + */ + public function getDayOfWeek(): ?array + { + return $this->day_of_week; + } + + /** + * @param array|null $dayOfWeek + */ + public function setDayOfWeek(?array $dayOfWeek): OpeningHours + { + $this->day_of_week = $dayOfWeek; + + return $this; + } + + public function getOpens(): ?\DateTime + { + return $this->opens; + } + + public function setOpens(?\DateTime $opens): OpeningHours + { + $this->opens = $opens; + + return $this; + } + + public function getCloses(): ?\DateTime + { + return $this->closes; + } + + public function setCloses(?\DateTime $closes): OpeningHours + { + $this->closes = $closes; + + return $this; + } + + public function getSortNo(): int + { + return $this->sort_no; + } + + public function setSortNo(int $sortNo): OpeningHours + { + $this->sort_no = $sortNo; + + return $this; + } + + public function getBaseInfo(): ?BaseInfo + { + return $this->BaseInfo; + } + + public function setBaseInfo(?BaseInfo $baseInfo): OpeningHours + { + $this->BaseInfo = $baseInfo; + + return $this; + } +} diff --git a/src/Eccube/Form/Type/Admin/OpeningHoursType.php b/src/Eccube/Form/Type/Admin/OpeningHoursType.php new file mode 100644 index 00000000000..cda59649c0c --- /dev/null +++ b/src/Eccube/Form/Type/Admin/OpeningHoursType.php @@ -0,0 +1,79 @@ + 'Monday', + 'admin.setting.shop.opening_hours.tuesday' => 'Tuesday', + 'admin.setting.shop.opening_hours.wednesday' => 'Wednesday', + 'admin.setting.shop.opening_hours.thursday' => 'Thursday', + 'admin.setting.shop.opening_hours.friday' => 'Friday', + 'admin.setting.shop.opening_hours.saturday' => 'Saturday', + 'admin.setting.shop.opening_hours.sunday' => 'Sunday', + 'admin.setting.shop.opening_hours.public_holidays' => 'PublicHolidays', + ]; + + /** + * {@inheritdoc} + * + * @param array $options + */ + #[\Override] + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('day_of_week', ChoiceType::class, [ + 'required' => false, + 'multiple' => true, + 'expanded' => true, + 'choices' => self::DAY_OF_WEEK_CHOICES, + ]) + ->add('opens', TimeType::class, [ + 'required' => false, + 'input' => 'datetime', + 'widget' => 'single_text', + ]) + ->add('closes', TimeType::class, [ + 'required' => false, + 'input' => 'datetime', + 'widget' => 'single_text', + ]); + } + + /** + * {@inheritdoc} + */ + #[\Override] + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => OpeningHours::class, + ]); + } +} diff --git a/src/Eccube/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index 1153940520d..32c25e4d77c 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -23,6 +23,7 @@ use Eccube\Form\Type\ToggleSwitchType; use Eccube\Form\Validator\Email; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\EmailType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; @@ -32,6 +33,7 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; /** * Class ShopMasterType @@ -184,6 +186,14 @@ public function buildForm(FormBuilderInterface $builder, array $options): void new Assert\Url(), ], ]) + ->add('OpeningHours', CollectionType::class, [ + 'entry_type' => OpeningHoursType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'prototype' => true, + 'by_reference' => false, + 'required' => false, + ]) // 送料設定 ->add('delivery_free_amount', PriceType::class, [ 'required' => false, @@ -310,9 +320,53 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => BaseInfo::class, + 'constraints' => [ + new Assert\Callback([$this, 'validateOpeningHoursOverlap']), + ], ]); } + /** + * 同一曜日を含む営業時間の時間帯が重複していないか検証する. + */ + public function validateOpeningHoursOverlap(?BaseInfo $BaseInfo, ExecutionContextInterface $context): void + { + if (!$BaseInfo instanceof BaseInfo) { + return; + } + + $list = array_values($BaseInfo->getOpeningHours()->toArray()); + $count = count($list); + for ($i = 0; $i < $count; ++$i) { + for ($j = $i + 1; $j < $count; ++$j) { + $a = $list[$i]; + $b = $list[$j]; + + $daysA = $a->getDayOfWeek() ?? []; + $daysB = $b->getDayOfWeek() ?? []; + if (array_intersect($daysA, $daysB) === []) { + continue; + } + + $opensA = $a->getOpens(); + $closesA = $a->getCloses(); + $opensB = $b->getOpens(); + $closesB = $b->getCloses(); + // 時刻が欠けている行は単体バリデーションに委ねる + if ($opensA === null || $closesA === null || $opensB === null || $closesB === null) { + continue; + } + + // 時間帯が交差する場合はエラー(max(開店) < min(閉店)) + if (max($opensA, $opensB) < min($closesA, $closesB)) { + $context->buildViolation('admin.setting.shop.opening_hours.error.overlap') + ->atPath('OpeningHours['.$j.']') + ->addViolation(); + } + } + } + } + /** * {@inheritdoc} */ diff --git a/src/Eccube/Repository/OpeningHoursRepository.php b/src/Eccube/Repository/OpeningHoursRepository.php new file mode 100644 index 00000000000..3d4c2c8fc01 --- /dev/null +++ b/src/Eccube/Repository/OpeningHoursRepository.php @@ -0,0 +1,30 @@ + + */ +class OpeningHoursRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, OpeningHours::class); + } +} diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 03f92583876..c23483effee 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1206,6 +1206,21 @@ admin.setting.shop.shop.founding_date: Founding Date admin.setting.shop.shop.number_of_employees: Number of Employees admin.setting.shop.shop.copyright_year: Copyright Start Year admin.setting.shop.shop.site_image: Site Image URL +admin.setting.shop.shop.opening_hours: Opening Hours +admin.setting.shop.opening_hours.add: Add Opening Hours +admin.setting.shop.opening_hours.monday: Monday +admin.setting.shop.opening_hours.tuesday: Tuesday +admin.setting.shop.opening_hours.wednesday: Wednesday +admin.setting.shop.opening_hours.thursday: Thursday +admin.setting.shop.opening_hours.friday: Friday +admin.setting.shop.opening_hours.saturday: Saturday +admin.setting.shop.opening_hours.sunday: Sunday +admin.setting.shop.opening_hours.public_holidays: Public Holidays +admin.setting.shop.opening_hours.error.day_required: Please select at least one day of the week. +admin.setting.shop.opening_hours.error.opens_required: Please enter the opening time. +admin.setting.shop.opening_hours.error.closes_required: Please enter the closing time. +admin.setting.shop.opening_hours.error.opens_before_closes: The opening time must be earlier than the closing time. +admin.setting.shop.opening_hours.error.overlap: The opening hours overlap on the same day of the week. admin.setting.shop.shop.option_delivery_fee: Shipping Charge admin.setting.shop.shop.option_delivery_fee_free_amount: Free Shipping (Amount) admin.setting.shop.shop.option_delivery_fee_free_quantity: Free Shipping (Qty) @@ -1801,6 +1816,7 @@ tooltip.setting.shop.shop.founding_date: The founding date of the shop/company, tooltip.setting.shop.shop.number_of_employees: The number of employees, output to structured data (Organization.numberOfEmployees). tooltip.setting.shop.shop.copyright_year: The starting year of the copyright notice, output to structured data (WebSite.copyrightYear). tooltip.setting.shop.shop.site_image: The absolute URL of the site image, output to structured data (Organization.image). +tooltip.setting.shop.shop.opening_hours: The opening hours output to structured data (Organization.openingHoursSpecification). Set the days of the week (multiple allowed) and the opening/closing times per row. tooltip.setting.shop.shop.option_delivery_fee_free_amount: Shipping charge will be waived if a customer purchases more than this amount. tooltip.setting.shop.shop.option_delivery_fee_free_quantity: Shipping charge will be waived if a customer purchases more than this quantity. tooltip.setting.shop.shop.option_delivery_fee_by_product: If turned on, you can set shipping charge per product. diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 2567be52cfb..a6e7082bacf 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1205,6 +1205,21 @@ admin.setting.shop.shop.founding_date: 稼働開始日 admin.setting.shop.shop.number_of_employees: 従業員数 admin.setting.shop.shop.copyright_year: 著作権表示の開始年 admin.setting.shop.shop.site_image: サイト代表画像URL +admin.setting.shop.shop.opening_hours: 営業時間 +admin.setting.shop.opening_hours.add: 営業時間を追加 +admin.setting.shop.opening_hours.monday: 月曜日 +admin.setting.shop.opening_hours.tuesday: 火曜日 +admin.setting.shop.opening_hours.wednesday: 水曜日 +admin.setting.shop.opening_hours.thursday: 木曜日 +admin.setting.shop.opening_hours.friday: 金曜日 +admin.setting.shop.opening_hours.saturday: 土曜日 +admin.setting.shop.opening_hours.sunday: 日曜日 +admin.setting.shop.opening_hours.public_holidays: 祝日 +admin.setting.shop.opening_hours.error.day_required: 曜日を1つ以上選択してください。 +admin.setting.shop.opening_hours.error.opens_required: 開店時刻を入力してください。 +admin.setting.shop.opening_hours.error.closes_required: 閉店時刻を入力してください。 +admin.setting.shop.opening_hours.error.opens_before_closes: 開店時刻は閉店時刻より前にしてください。 +admin.setting.shop.opening_hours.error.overlap: 同じ曜日で営業時間が重複しています。 admin.setting.shop.shop.option_delivery_fee: 送料設定 admin.setting.shop.shop.option_delivery_fee_free_amount: 送料無料条件(金額) admin.setting.shop.shop.option_delivery_fee_free_quantity: 送料無料条件(数量) @@ -1801,6 +1816,7 @@ tooltip.setting.shop.shop.founding_date: 構造化データ(Organization.found tooltip.setting.shop.shop.number_of_employees: 構造化データ(Organization.numberOfEmployees)に出力する従業員数です。 tooltip.setting.shop.shop.copyright_year: 構造化データ(WebSite.copyrightYear)に出力する著作権表示の開始年です。 tooltip.setting.shop.shop.site_image: 構造化データ(Organization.image)に出力するサイト代表画像の絶対URLです。 +tooltip.setting.shop.shop.opening_hours: 構造化データ(Organization.openingHoursSpecification)に出力する営業時間です。曜日(複数選択可)と開店・閉店時刻を行ごとに設定できます。 tooltip.setting.shop.shop.option_delivery_fee_free_amount: この金額を超える購入があった場合、送料を無料とします。 tooltip.setting.shop.shop.option_delivery_fee_free_quantity: この個数を超える購入があった場合、送料を無料とします。 tooltip.setting.shop.shop.option_delivery_fee_by_product: ここをオンにすると、商品ごとに送料を指定できるようになります。 diff --git a/src/Eccube/Resource/locale/validators.en.yaml b/src/Eccube/Resource/locale/validators.en.yaml index 96799a1c1ac..4ca457dbad5 100644 --- a/src/Eccube/Resource/locale/validators.en.yaml +++ b/src/Eccube/Resource/locale/validators.en.yaml @@ -74,3 +74,8 @@ form.type.add.quantity: Please enter more than 1. form.type.select.select_is_future_or_now_date: Invalid Date of Birth. form.type.admin.nottrackingnumberstyle: Tracking No. entry must be alphanumeric chars and hypens. form.type.float.invalid: Only numbers and decimal points are accepted. +admin.setting.shop.opening_hours.error.day_required: Please select at least one day of the week. +admin.setting.shop.opening_hours.error.opens_required: Please enter the opening time. +admin.setting.shop.opening_hours.error.closes_required: Please enter the closing time. +admin.setting.shop.opening_hours.error.opens_before_closes: The opening time must be earlier than the closing time. +admin.setting.shop.opening_hours.error.overlap: The opening hours overlap on the same day of the week. diff --git a/src/Eccube/Resource/locale/validators.ja.yaml b/src/Eccube/Resource/locale/validators.ja.yaml index 88ecbe44312..c20a30b19c6 100644 --- a/src/Eccube/Resource/locale/validators.ja.yaml +++ b/src/Eccube/Resource/locale/validators.ja.yaml @@ -77,3 +77,8 @@ form.type.add.quantity: 1以上で入力してください。 form.type.select.select_is_future_or_now_date: 生年月日が不正な日付です。 form.type.admin.nottrackingnumberstyle: 送り状番号は半角英数字かハイフンのみを入力してください。 form.type.float.invalid: 数字と小数点のみ入力できます。 +admin.setting.shop.opening_hours.error.day_required: 曜日を1つ以上選択してください。 +admin.setting.shop.opening_hours.error.opens_required: 開店時刻を入力してください。 +admin.setting.shop.opening_hours.error.closes_required: 閉店時刻を入力してください。 +admin.setting.shop.opening_hours.error.opens_before_closes: 開店時刻は閉店時刻より前にしてください。 +admin.setting.shop.opening_hours.error.overlap: 同じ曜日で営業時間が重複しています。 diff --git a/src/Eccube/Resource/template/admin/Setting/Shop/opening_hours_prototype.twig b/src/Eccube/Resource/template/admin/Setting/Shop/opening_hours_prototype.twig new file mode 100644 index 00000000000..5c0fbee4b46 --- /dev/null +++ b/src/Eccube/Resource/template/admin/Setting/Shop/opening_hours_prototype.twig @@ -0,0 +1,26 @@ +{# +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. +#} +
+
+ {{ form_widget(form.day_of_week) }} + {{ form_errors(form.day_of_week) }} +
+
+ {{ form_widget(form.opens) }} + + {{ form_widget(form.closes) }} + +
+ {{ form_errors(form.opens) }} + {{ form_errors(form.closes) }} +
diff --git a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig index 732557c9060..50f772b303f 100644 --- a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig +++ b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig @@ -19,6 +19,32 @@ file that was distributed with this source code. {% block javascript %} + {% endblock %} {% block main %} @@ -215,6 +241,19 @@ file that was distributed with this source code. {{ form_errors(form.site_image) }} +
+
+
{{ 'admin.setting.shop.shop.opening_hours'|trans }}
+
+
+
+ {% for child in form.OpeningHours %} + {{ include('@admin/Setting/Shop/opening_hours_prototype.twig', {'form': child}) }} + {% endfor %} +
+ +
+
{# エンティティ拡張の自動出力 #} {% for f in form|filter(f => f.vars.eccube_form_options.auto_render) %} {% if f.vars.eccube_form_options.form_theme %} diff --git a/src/Eccube/Service/SiteStructuredDataService.php b/src/Eccube/Service/SiteStructuredDataService.php index 5d14df6da56..cd56b6c3a3b 100644 --- a/src/Eccube/Service/SiteStructuredDataService.php +++ b/src/Eccube/Service/SiteStructuredDataService.php @@ -134,9 +134,53 @@ public function createOrganizationJsonLd(BaseInfo $BaseInfo): array $data['sameAs'] = $sameAs; } + $openingHours = $this->buildOpeningHours($BaseInfo); + if ($openingHours !== []) { + $data['openingHoursSpecification'] = $openingHours; + } + return $data; } + /** + * 店舗設定の営業時間を OpeningHoursSpecification のリストに変換する. + * + * 曜日・開店時刻・閉店時刻がいずれも無いエントリは出力しない. + * + * @return list> + */ + private function buildOpeningHours(BaseInfo $BaseInfo): array + { + $specs = []; + foreach ($BaseInfo->getOpeningHours() as $OpeningHours) { + $spec = ['@type' => 'OpeningHoursSpecification']; + + $dayOfWeek = $OpeningHours->getDayOfWeek(); + if ($dayOfWeek !== null && $dayOfWeek !== []) { + $spec['dayOfWeek'] = array_values($dayOfWeek); + } + + $opens = $OpeningHours->getOpens(); + if ($opens !== null) { + $spec['opens'] = $opens->format('H:i'); + } + + $closes = $OpeningHours->getCloses(); + if ($closes !== null) { + $spec['closes'] = $closes->format('H:i'); + } + + // @type 以外に情報が無いエントリは出力しない + if (count($spec) === 1) { + continue; + } + + $specs[] = $spec; + } + + return $specs; + } + /** * 改行区切りの SNS 等公式 URL 文字列を、空要素を除いた URL のリストに変換する. * diff --git a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php index 207246dcb9e..e821caa8f08 100644 --- a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php +++ b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php @@ -169,4 +169,51 @@ public function testInValidMessageMaxLength() $this->form->submit($this->formData); $this->assertFalse($this->form->isValid()); } + + public function testValidOpeningHours() + { + $this->formData['OpeningHours'] = [ + ['day_of_week' => ['Monday', 'Tuesday'], 'opens' => '09:00', 'closes' => '18:00'], + ]; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidOpeningHoursOpensAfterCloses() + { + $this->formData['OpeningHours'] = [ + ['day_of_week' => ['PublicHolidays'], 'opens' => '20:00', 'closes' => '15:00'], + ]; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testInValidOpeningHoursMissingDay() + { + $this->formData['OpeningHours'] = [ + ['day_of_week' => [], 'opens' => '09:00', 'closes' => '18:00'], + ]; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testInValidOpeningHoursOverlapSameDay() + { + $this->formData['OpeningHours'] = [ + ['day_of_week' => ['Saturday'], 'opens' => '10:00', 'closes' => '15:00'], + ['day_of_week' => ['Saturday'], 'opens' => '14:00', 'closes' => '18:00'], + ]; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testValidOpeningHoursDifferentDayNoOverlap() + { + $this->formData['OpeningHours'] = [ + ['day_of_week' => ['Saturday'], 'opens' => '10:00', 'closes' => '15:00'], + ['day_of_week' => ['Sunday'], 'opens' => '10:00', 'closes' => '15:00'], + ]; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } } diff --git a/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php b/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php index 8a944c5a6fc..6b59abc3025 100644 --- a/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php +++ b/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php @@ -16,6 +16,7 @@ namespace Eccube\Tests\Service; use Eccube\Entity\BaseInfo; +use Eccube\Entity\OpeningHours; use Eccube\Repository\BaseInfoRepository; use Eccube\Service\SiteStructuredDataService; @@ -178,4 +179,32 @@ public function testOptionalSchemaFieldsAreOmittedWhenUnset(): void $this->assertArrayNotHasKey('image', $org); $this->assertArrayNotHasKey('copyrightYear', $web); } + + public function testOpeningHoursSpecification(): void + { + $this->BaseInfo->getOpeningHours()->clear(); + $OpeningHours = new OpeningHours(); + $OpeningHours->setDayOfWeek(['Monday', 'Tuesday']); + $OpeningHours->setOpens(new \DateTime('09:00')); + $OpeningHours->setCloses(new \DateTime('18:00')); + $this->BaseInfo->addOpeningHour($OpeningHours); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + $this->assertArrayHasKey('openingHoursSpecification', $data); + $spec = $data['openingHoursSpecification'][0]; + $this->assertSame('OpeningHoursSpecification', $spec['@type']); + $this->assertSame(['Monday', 'Tuesday'], $spec['dayOfWeek']); + $this->assertSame('09:00', $spec['opens']); + $this->assertSame('18:00', $spec['closes']); + } + + public function testEmptyOpeningHoursIsOmitted(): void + { + $this->BaseInfo->getOpeningHours()->clear(); + + $data = $this->service->createOrganizationJsonLd($this->BaseInfo); + + $this->assertArrayNotHasKey('openingHoursSpecification', $data); + } } From 4e97130668adc10df16933af34eee9a536e66a41 Mon Sep 17 00:00:00 2001 From: "takumi.tokoro" Date: Thu, 23 Jul 2026 09:14:55 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(schema):=20=E6=A7=8B=E9=80=A0=E5=8C=96?= =?UTF-8?q?=E3=83=87=E3=83=BC=E3=82=BF=E6=8B=A1=E5=BC=B5=E9=A0=85=E7=9B=AE?= =?UTF-8?q?=E3=81=AE=E3=83=90=E3=83=AA=E3=83=87=E3=83=BC=E3=82=B7=E3=83=A7?= =?UTF-8?q?=E3=83=B3=E5=BC=B7=E5=8C=96=E3=81=A8=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - same_as: 改行区切り各行を Assert\Url で検証(site_image と対称化) - founding_date: LessThanOrEqual('today') で未来日を禁止 - number_of_employees: Regex を Assert\PositiveOrZero に置換(非負整数) - 営業時間の重複エラーを該当行に表示(overlap の atPath を closes に変更) - ShopMasterTypeTest にスカラー5項目のUT 14件を追加(計32件) - admin-basicinfo.spec.ts に営業時間の動的行UI・保存往復・JSON-LD反映・重複エラーのE2Eを追加(冪等) Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/tests/admin-basicinfo.spec.ts | 98 +++++++++++++++++ src/Eccube/Form/Type/Admin/ShopMasterType.php | 45 ++++++-- src/Eccube/Resource/locale/validators.en.yaml | 2 + src/Eccube/Resource/locale/validators.ja.yaml | 2 + .../Form/Type/Admin/ShopMasterTypeTest.php | 100 ++++++++++++++++++ 5 files changed, 241 insertions(+), 6 deletions(-) diff --git a/e2e/tests/admin-basicinfo.spec.ts b/e2e/tests/admin-basicinfo.spec.ts index 63b2335cf4b..b274f79b08b 100644 --- a/e2e/tests/admin-basicinfo.spec.ts +++ b/e2e/tests/admin-basicinfo.spec.ts @@ -1325,4 +1325,102 @@ test.describe('Admin Basic Info (EA07)', () => { await page.waitForLoadState('load'); await expect(page.locator('#page_admin_setting_shop_calendar .alert-success')).toContainText('保存しました'); }); + + test('basicinfo_構造化データ_営業時間とSNS等URL - EA0701-UC01-T18', async ({ page }) => { + test.setTimeout(120_000); + + const sameAsUrl = 'https://example.com/official-sns'; + + // --- 入力(構造化データのスカラー項目 + 営業時間1行) --- + await page.goto(`/${adminRoute}/setting/shop`); + await page.waitForLoadState('load'); + await ensureAdminLoggedIn(page); + if (!page.url().includes('/setting/shop')) { + await page.goto(`/${adminRoute}/setting/shop`); + await page.waitForLoadState('load'); + } + await expect(page.locator('.c-pageTitle')).toContainText('基本設定'); + + // --- クリーンな基準状態にする(過去実行の残存行・値を消して保存) --- + const resetDeletes = page.locator('#opening-hours-group .delete-opening-hour'); + while (await resetDeletes.count() > 0) { + await resetDeletes.first().click(); + } + await page.locator('#shop_master_same_as').fill(''); + await page.locator('#shop_master_founding_date').fill(''); + await page.locator('#shop_master_number_of_employees').fill(''); + await page.locator('#shop_master_copyright_year').fill(''); + await page.locator('#shop_master_site_image').fill(''); + await page.locator('button.ladda-button[type="submit"]').click(); + await page.waitForLoadState('load'); + await expect(page.locator('.alert-success')).toContainText('保存しました', { timeout: 30_000 }); + + // --- 入力(構造化データのスカラー項目 + 営業時間1行) --- + await page.goto(`/${adminRoute}/setting/shop`); + await page.waitForLoadState('load'); + await expect(page.locator('#opening-hours-group .opening-hours-item')).toHaveCount(0); + + await page.locator('#shop_master_same_as').fill(sameAsUrl); + await page.locator('#shop_master_founding_date').fill('2000-04-01'); + await page.locator('#shop_master_number_of_employees').fill('42'); + await page.locator('#shop_master_copyright_year').fill('2020'); + await page.locator('#shop_master_site_image').fill('https://example.com/site.png'); + + // 営業時間の行を追加(月曜 09:00-18:00) + await page.locator('#add-opening-hour-button').click(); + await expect(page.locator('#opening-hours-group .opening-hours-item')).toHaveCount(1); + await page.locator('#shop_master_OpeningHours_0_day_of_week_0').check(); // Monday + await page.locator('#shop_master_OpeningHours_0_opens').fill('09:00'); + await page.locator('#shop_master_OpeningHours_0_closes').fill('18:00'); + + await page.locator('button.ladda-button[type="submit"]').click(); + await page.waitForLoadState('load'); + await expect(page.locator('.alert-success')).toContainText('保存しました', { timeout: 30_000 }); + + // --- 保存往復の検証(DB反映→再表示) --- + await page.goto(`/${adminRoute}/setting/shop`); + await page.waitForLoadState('load'); + await expect(page.locator('#shop_master_same_as')).toHaveValue(sameAsUrl); + await expect(page.locator('#shop_master_number_of_employees')).toHaveValue('42'); + await expect(page.locator('#shop_master_copyright_year')).toHaveValue('2020'); + await expect(page.locator('#opening-hours-group .opening-hours-item')).toHaveCount(1); + await expect(page.locator('#shop_master_OpeningHours_0_day_of_week_0')).toBeChecked(); + + // --- フロントの JSON-LD 反映を検証 --- + await page.goto('/'); + await page.waitForLoadState('load'); + const content = await page.content(); + expect(content).toContain('"openingHoursSpecification"'); + expect(content).toContain('"sameAs"'); + expect(content).toContain(sameAsUrl); + + // --- 重複バリデーション:同一曜日で重なる行を追加すると保存が弾かれ、行にエラーが出る --- + await page.goto(`/${adminRoute}/setting/shop`); + await page.waitForLoadState('load'); + await page.locator('#add-opening-hour-button').click(); + await expect(page.locator('#opening-hours-group .opening-hours-item')).toHaveCount(2); + await page.locator('#shop_master_OpeningHours_1_day_of_week_0').check(); // Monday(既存行と重複) + await page.locator('#shop_master_OpeningHours_1_opens').fill('12:00'); + await page.locator('#shop_master_OpeningHours_1_closes').fill('20:00'); + await page.locator('button.ladda-button[type="submit"]').click(); + await page.waitForLoadState('load'); + await expect(page.locator('.alert-success')).toHaveCount(0); + await expect(page.locator('#opening-hours-group')).toContainText('同じ曜日で営業時間が重複しています'); + + // --- クリーンアップ:営業時間の行とスカラー項目を消して保存 --- + await page.goto(`/${adminRoute}/setting/shop`); + await page.waitForLoadState('load'); + const deleteButtons = page.locator('#opening-hours-group .delete-opening-hour'); + while (await deleteButtons.count() > 0) { + await deleteButtons.first().click(); + } + await page.locator('#shop_master_same_as').fill(''); + await page.locator('#shop_master_founding_date').fill(''); + await page.locator('#shop_master_number_of_employees').fill(''); + await page.locator('#shop_master_copyright_year').fill(''); + await page.locator('#shop_master_site_image').fill(''); + await page.locator('button.ladda-button[type="submit"]').click(); + await page.waitForLoadState('load'); + await expect(page.locator('.alert-success')).toContainText('保存しました', { timeout: 30_000 }); + }); }); diff --git a/src/Eccube/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index 32c25e4d77c..01abd062008 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -152,20 +152,24 @@ public function buildForm(FormBuilderInterface $builder, array $options): void new Assert\Length([ 'max' => $this->eccubeConfig['eccube_ltext_len'], ]), + new Assert\Callback($this->validateSameAsUrls(...)), ], ]) ->add('founding_date', DateType::class, [ 'required' => false, 'input' => 'datetime', 'widget' => 'single_text', + 'constraints' => [ + new Assert\LessThanOrEqual([ + 'value' => 'today', + 'message' => 'admin.setting.shop.founding_date.error.not_future', + ]), + ], ]) ->add('number_of_employees', IntegerType::class, [ 'required' => false, 'constraints' => [ - new Assert\Regex([ - 'pattern' => "/^\d+$/u", - 'message' => 'form_error.numeric_only', - ]), + new Assert\PositiveOrZero(), ], ]) ->add('copyright_year', IntegerType::class, [ @@ -321,11 +325,39 @@ public function configureOptions(OptionsResolver $resolver): void $resolver->setDefaults([ 'data_class' => BaseInfo::class, 'constraints' => [ - new Assert\Callback([$this, 'validateOpeningHoursOverlap']), + new Assert\Callback($this->validateOpeningHoursOverlap(...)), ], ]); } + /** + * sameAs(改行区切りの複数URL)の各行が有効な URL か検証する. + * + * 単一URLの site_image と同じ Assert\Url で1行ずつ検証し、 + * 1行でも不正なら項目全体にエラーを付ける(空行は無視する). + */ + public function validateSameAsUrls(?string $sameAs, ExecutionContextInterface $context): void + { + if ($sameAs === null || $sameAs === '') { + return; + } + + $validator = $context->getValidator(); + $lines = preg_split('/\R/u', $sameAs) ?: []; + foreach ($lines as $line) { + $url = trim($line); + if ($url === '') { + continue; + } + if ($validator->validate($url, new Assert\Url())->count() > 0) { + $context->buildViolation('admin.setting.shop.same_as.error.invalid_url') + ->addViolation(); + + return; + } + } + } + /** * 同一曜日を含む営業時間の時間帯が重複していないか検証する. */ @@ -358,9 +390,10 @@ public function validateOpeningHoursOverlap(?BaseInfo $BaseInfo, ExecutionContex } // 時間帯が交差する場合はエラー(max(開店) < min(閉店)) + // 描画済みのリーフ(closes)にエラーを付け、該当行に表示されるようにする if (max($opensA, $opensB) < min($closesA, $closesB)) { $context->buildViolation('admin.setting.shop.opening_hours.error.overlap') - ->atPath('OpeningHours['.$j.']') + ->atPath('OpeningHours['.$j.'].closes') ->addViolation(); } } diff --git a/src/Eccube/Resource/locale/validators.en.yaml b/src/Eccube/Resource/locale/validators.en.yaml index 4ca457dbad5..bc16ad4fa01 100644 --- a/src/Eccube/Resource/locale/validators.en.yaml +++ b/src/Eccube/Resource/locale/validators.en.yaml @@ -79,3 +79,5 @@ admin.setting.shop.opening_hours.error.opens_required: Please enter the opening admin.setting.shop.opening_hours.error.closes_required: Please enter the closing time. admin.setting.shop.opening_hours.error.opens_before_closes: The opening time must be earlier than the closing time. admin.setting.shop.opening_hours.error.overlap: The opening hours overlap on the same day of the week. +admin.setting.shop.same_as.error.invalid_url: Each line must be a valid URL (SNS, etc.). +admin.setting.shop.founding_date.error.not_future: The founding date cannot be a future date. diff --git a/src/Eccube/Resource/locale/validators.ja.yaml b/src/Eccube/Resource/locale/validators.ja.yaml index c20a30b19c6..8bdafc615c3 100644 --- a/src/Eccube/Resource/locale/validators.ja.yaml +++ b/src/Eccube/Resource/locale/validators.ja.yaml @@ -82,3 +82,5 @@ admin.setting.shop.opening_hours.error.opens_required: 開店時刻を入力し admin.setting.shop.opening_hours.error.closes_required: 閉店時刻を入力してください。 admin.setting.shop.opening_hours.error.opens_before_closes: 開店時刻は閉店時刻より前にしてください。 admin.setting.shop.opening_hours.error.overlap: 同じ曜日で営業時間が重複しています。 +admin.setting.shop.same_as.error.invalid_url: 各行にSNS等の有効なURLを入力してください。 +admin.setting.shop.founding_date.error.not_future: 稼働開始日は未来の日付にできません。 diff --git a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php index e821caa8f08..ef3015d3068 100644 --- a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php +++ b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php @@ -216,4 +216,104 @@ public function testValidOpeningHoursDifferentDayNoOverlap() $this->form->submit($this->formData); $this->assertTrue($this->form->isValid()); } + + public function testValidSameAsMultipleUrls() + { + $this->formData['same_as'] = "https://example.com/a\nhttps://example.com/b"; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidSameAsContainsNonUrl() + { + $this->formData['same_as'] = "https://example.com/a\nnot-a-url"; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testInValidSameAsMaxLength() + { + // 形式は有効なURLだが最大長を超えるケース(長さ制約のみを検証) + $this->formData['same_as'] = 'https://example.com/'.str_repeat('a', $this->eccubeConfig['eccube_ltext_len']); + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testValidNumberOfEmployeesZero() + { + $this->formData['number_of_employees'] = '0'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidNumberOfEmployeesNegative() + { + $this->formData['number_of_employees'] = '-1'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testValidCopyrightYearRangeMin() + { + $this->formData['copyright_year'] = '1900'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testValidCopyrightYearRangeMax() + { + $this->formData['copyright_year'] = '9999'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidCopyrightYearBelowMin() + { + $this->formData['copyright_year'] = '1899'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testInValidCopyrightYearAboveMax() + { + $this->formData['copyright_year'] = '10000'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testValidFoundingDatePast() + { + $this->formData['founding_date'] = '2000-04-01'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidFoundingDateFuture() + { + $this->formData['founding_date'] = (new \DateTime('+1 year'))->format('Y-m-d'); + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testValidSiteImageUrl() + { + $this->formData['site_image'] = 'https://example.com/site.png'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidSiteImageNotUrl() + { + $this->formData['site_image'] = 'not-a-url'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testInValidSiteImageMaxLength() + { + // 形式は有効なURLだが最大長を超えるケース(長さ制約のみを検証) + $this->formData['site_image'] = 'https://example.com/'.str_repeat('a', $this->eccubeConfig['eccube_stext_len']); + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } } From c4e64cbabcc6bf2c498becf08c5e83042e8d4b04 Mon Sep 17 00:00:00 2001 From: "takumi.tokoro" Date: Thu, 23 Jul 2026 09:57:45 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(schema):=20CodeRabbit=E6=8C=87=E6=91=98?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C=EF=BC=88number=5Fof=5Femployees=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E3=83=BB=E5=8D=98=E7=B4=94=E3=82=AB=E3=83=A9=E3=83=A0?= =?UTF-8?q?migration=E5=89=8A=E9=99=A4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - number_of_employees に Assert\LessThanOrEqual(2147483647) を追加(INT桁あふれ防止) 併せて上限のUT 2件を追加(境界2147483647は有効・2147483648は無効) - Version20260722000000(BaseInfo単純nullableカラム追加のみ)を削除 Entity属性+schema:update で反映されるためガイドライン準拠(dtb_opening_hours 作成の Version20260722010000 はテーブル新設のため維持) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Version20260722000000.php | 73 ------------------- src/Eccube/Form/Type/Admin/ShopMasterType.php | 2 + .../Form/Type/Admin/ShopMasterTypeTest.php | 14 ++++ 3 files changed, 16 insertions(+), 73 deletions(-) delete mode 100644 app/DoctrineMigrations/Version20260722000000.php diff --git a/app/DoctrineMigrations/Version20260722000000.php b/app/DoctrineMigrations/Version20260722000000.php deleted file mode 100644 index 65adb736acc..00000000000 --- a/app/DoctrineMigrations/Version20260722000000.php +++ /dev/null @@ -1,73 +0,0 @@ -hasTable(self::NAME)) { - return; - } - - $table = $schema->getTable(self::NAME); - if ($table->hasColumn('same_as')) { - return; - } - - // TEXT 型は MySQL では LONGTEXT にマップされるため、schema:update との差分を避けて分岐する - $textType = $this->connection->getDatabasePlatform() instanceof AbstractMySQLPlatform ? 'LONGTEXT' : 'TEXT'; - - $this->addSql('ALTER TABLE dtb_base_info ADD same_as '.$textType.' DEFAULT NULL'); - $this->addSql('ALTER TABLE dtb_base_info ADD founding_date DATE DEFAULT NULL'); - $this->addSql('ALTER TABLE dtb_base_info ADD number_of_employees INT DEFAULT NULL'); - $this->addSql('ALTER TABLE dtb_base_info ADD copyright_year INT DEFAULT NULL'); - $this->addSql('ALTER TABLE dtb_base_info ADD site_image VARCHAR(255) DEFAULT NULL'); - } - - public function down(Schema $schema): void - { - if (!$schema->hasTable(self::NAME)) { - return; - } - - $table = $schema->getTable(self::NAME); - if (!$table->hasColumn('same_as')) { - return; - } - - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN same_as'); - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN founding_date'); - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN number_of_employees'); - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN copyright_year'); - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN site_image'); - } -} diff --git a/src/Eccube/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index 01abd062008..9048f17a005 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -170,6 +170,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'constraints' => [ new Assert\PositiveOrZero(), + // DB カラムは INT。桁あふれによる保存時エラーを防ぐため上限を設ける + new Assert\LessThanOrEqual(2147483647), ], ]) ->add('copyright_year', IntegerType::class, [ diff --git a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php index ef3015d3068..a5c74791ecd 100644 --- a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php +++ b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php @@ -253,6 +253,20 @@ public function testInValidNumberOfEmployeesNegative() $this->assertFalse($this->form->isValid()); } + public function testValidNumberOfEmployeesIntMax() + { + $this->formData['number_of_employees'] = '2147483647'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidNumberOfEmployeesOverIntMax() + { + $this->formData['number_of_employees'] = '2147483648'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + public function testValidCopyrightYearRangeMin() { $this->formData['copyright_year'] = '1900';