From b9d9b26aad181c88ac9e6e67f4f74fe2fe82c9d5 Mon Sep 17 00:00:00 2001 From: "takumi.tokoro" Date: Wed, 22 Jul 2026 13:34:22 +0900 Subject: [PATCH 1/5] =?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 2/5] =?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 3/5] =?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 4/5] =?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'; From 97f392588bf6612ea89ed965f6bb11c4ce223ca6 Mon Sep 17 00:00:00 2001 From: "takumi.tokoro" Date: Fri, 7 Aug 2026 18:22:14 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20CodeRabbit=20=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=B8=E3=81=AE=E5=AF=BE=E5=BF=9C=EF=BC=88=E5=96=B6=E6=A5=AD?= =?UTF-8?q?=E6=99=82=E9=96=93=E3=81=AE=E9=87=8D=E8=A4=87=E3=82=A8=E3=83=A9?= =?UTF-8?q?=E3=83=BC=E4=BD=8D=E7=BD=AE=E3=83=BB=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AE=E5=9E=8B=E5=AE=A3=E8=A8=80=E3=83=BBE2E=E3=81=AE?= =?UTF-8?q?=E5=BE=8C=E5=A7=8B=E6=9C=AB=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ShopMasterType: 営業時間の重複検証をクラス制約の Callback から POST_SUBMIT へ移す。 画面で中間行を削除すると送信キーが歯抜け(0, 2 等)になる一方、エンティティ側は adder で 0 始まりに詰め直されるため、コレクションの添字から組んだ property path が 実在しない子を指し、エラーが該当行に表示されていなかった。フォームの子を走査して closes へ直接 addError する形にする - 指摘の「エンティティのキーを保持する」案では直らないことを実測で確認したため、 フォーム側のキーに合わせる形を採用した - ShopMasterTypeTest: 歯抜けキーでエラー位置を固定するテストを追加。 本 PR で追加した 21 メソッドに : void を付与 - admin-basicinfo.spec.ts: 検証失敗時も後始末が走るよう try/finally で囲む (BaseInfo と営業時間が後続テストへ引き継がれ、実行順序に依存するため) Co-Authored-By: Claude Opus 5 (1M context) --- e2e/tests/admin-basicinfo.spec.ts | 171 +++++++++--------- src/Eccube/Form/Type/Admin/ShopMasterType.php | 40 ++-- .../Form/Type/Admin/ShopMasterTypeTest.php | 58 +++--- 3 files changed, 153 insertions(+), 116 deletions(-) diff --git a/e2e/tests/admin-basicinfo.spec.ts b/e2e/tests/admin-basicinfo.spec.ts index b274f79b08b..9f973a1d8b9 100644 --- a/e2e/tests/admin-basicinfo.spec.ts +++ b/e2e/tests/admin-basicinfo.spec.ts @@ -1331,96 +1331,101 @@ test.describe('Admin Basic Info (EA07)', () => { 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')) { + // 検証が失敗してもクリーンアップを必ず実行する。BaseInfo と営業時間は + // 後続テストへ引き継がれるため、残すと結果が実行順序に依存する。 + try { + // --- 入力(構造化データのスカラー項目 + 営業時間1行) --- 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 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('基本設定'); - await page.locator('button.ladda-button[type="submit"]').click(); - await page.waitForLoadState('load'); - await expect(page.locator('.alert-success')).toContainText('保存しました', { timeout: 30_000 }); + // --- クリーンな基準状態にする(過去実行の残存行・値を消して保存) --- + 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 }); - // --- 保存往復の検証(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(); + // --- 入力(構造化データのスカラー項目 + 営業時間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 }); - // --- フロントの 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); + // --- 保存往復の検証(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'); + 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(); + } finally { + // --- クリーンアップ:営業時間の行とスカラー項目を消して保存 --- + 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 }); } - 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 a2429dc3150..644f6b704db 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -15,6 +15,7 @@ use Eccube\Common\EccubeConfig; use Eccube\Entity\BaseInfo; +use Eccube\Entity\OpeningHours; use Eccube\Form\EventListener\ConvertKanaListener; use Eccube\Form\Type\AddressType; use Eccube\Form\Type\PhoneNumberType; @@ -31,6 +32,10 @@ use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -318,6 +323,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->addEventSubscriber(new ConvertKanaListener('CV')) ); + + // 営業時間の重複はフォームの子キーに紐づけたいので、クラス制約の Callback ではなく + // POST_SUBMIT で検証する(詳細は validateOpeningHoursOverlap() のコメント参照)。 + $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateOpeningHoursOverlap(...)); } /** @@ -328,9 +337,6 @@ public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => BaseInfo::class, - 'constraints' => [ - new Assert\Callback($this->validateOpeningHoursOverlap(...)), - ], ]); } @@ -364,19 +370,30 @@ public function validateSameAsUrls(?string $sameAs, ExecutionContextInterface $c /** * 同一曜日を含む営業時間の時間帯が重複していないか検証する. + * + * エンティティのコレクションではなく**フォームの子**を走査する。画面で中間行を削除すると + * 送信キーは歯抜け(0, 2 等)になるが、コレクション側は adder で 0 始まりに詰め直されるため、 + * コレクションの添字から組み立てた property path は実在しない子を指してしまい、 + * エラーが該当行に表示されない(あるいは失われる)。 */ - public function validateOpeningHoursOverlap(?BaseInfo $BaseInfo, ExecutionContextInterface $context): void + public function validateOpeningHoursOverlap(FormEvent $event): void { - if (!$BaseInfo instanceof BaseInfo) { + $form = $event->getForm(); + if (!$form->has('OpeningHours')) { return; } - $list = array_values($BaseInfo->getOpeningHours()->toArray()); - $count = count($list); + /** @var FormInterface[] $children */ + $children = iterator_to_array($form->get('OpeningHours')); + $names = array_keys($children); + $count = count($names); for ($i = 0; $i < $count; ++$i) { for ($j = $i + 1; $j < $count; ++$j) { - $a = $list[$i]; - $b = $list[$j]; + $a = $children[$names[$i]]->getData(); + $b = $children[$names[$j]]->getData(); + if (!$a instanceof OpeningHours || !$b instanceof OpeningHours) { + continue; + } $daysA = $a->getDayOfWeek() ?? []; $daysB = $b->getDayOfWeek() ?? []; @@ -396,9 +413,8 @@ 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.'].closes') - ->addViolation(); + $children[$names[$j]]->get('closes') + ->addError(new FormError(trans('admin.setting.shop.opening_hours.error.overlap'))); } } } diff --git a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php index a5c74791ecd..8f74259f1f0 100644 --- a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php +++ b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php @@ -170,7 +170,7 @@ public function testInValidMessageMaxLength() $this->assertFalse($this->form->isValid()); } - public function testValidOpeningHours() + public function testValidOpeningHours(): void { $this->formData['OpeningHours'] = [ ['day_of_week' => ['Monday', 'Tuesday'], 'opens' => '09:00', 'closes' => '18:00'], @@ -179,7 +179,7 @@ public function testValidOpeningHours() $this->assertTrue($this->form->isValid()); } - public function testInValidOpeningHoursOpensAfterCloses() + public function testInValidOpeningHoursOpensAfterCloses(): void { $this->formData['OpeningHours'] = [ ['day_of_week' => ['PublicHolidays'], 'opens' => '20:00', 'closes' => '15:00'], @@ -188,7 +188,7 @@ public function testInValidOpeningHoursOpensAfterCloses() $this->assertFalse($this->form->isValid()); } - public function testInValidOpeningHoursMissingDay() + public function testInValidOpeningHoursMissingDay(): void { $this->formData['OpeningHours'] = [ ['day_of_week' => [], 'opens' => '09:00', 'closes' => '18:00'], @@ -197,7 +197,7 @@ public function testInValidOpeningHoursMissingDay() $this->assertFalse($this->form->isValid()); } - public function testInValidOpeningHoursOverlapSameDay() + public function testInValidOpeningHoursOverlapSameDay(): void { $this->formData['OpeningHours'] = [ ['day_of_week' => ['Saturday'], 'opens' => '10:00', 'closes' => '15:00'], @@ -207,7 +207,23 @@ public function testInValidOpeningHoursOverlapSameDay() $this->assertFalse($this->form->isValid()); } - public function testValidOpeningHoursDifferentDayNoOverlap() + /** + * 画面で中間行を削除すると送信キーが歯抜け(0, 2 等)になるため、 + * 重複エラーは詰めた通し番号ではなく実在する子フォームのキーに付く必要がある. + */ + public function testInValidOpeningHoursOverlapAttachesErrorToSubmittedKey(): void + { + $this->formData['OpeningHours'] = [ + 0 => ['day_of_week' => ['Saturday'], 'opens' => '10:00', 'closes' => '15:00'], + 2 => ['day_of_week' => ['Saturday'], 'opens' => '14:00', 'closes' => '18:00'], + ]; + $this->form->submit($this->formData); + + $this->assertFalse($this->form->isValid()); + $this->assertCount(1, $this->form->get('OpeningHours')->get('2')->get('closes')->getErrors()); + } + + public function testValidOpeningHoursDifferentDayNoOverlap(): void { $this->formData['OpeningHours'] = [ ['day_of_week' => ['Saturday'], 'opens' => '10:00', 'closes' => '15:00'], @@ -217,21 +233,21 @@ public function testValidOpeningHoursDifferentDayNoOverlap() $this->assertTrue($this->form->isValid()); } - public function testValidSameAsMultipleUrls() + public function testValidSameAsMultipleUrls(): void { $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() + public function testInValidSameAsContainsNonUrl(): void { $this->formData['same_as'] = "https://example.com/a\nnot-a-url"; $this->form->submit($this->formData); $this->assertFalse($this->form->isValid()); } - public function testInValidSameAsMaxLength() + public function testInValidSameAsMaxLength(): void { // 形式は有効なURLだが最大長を超えるケース(長さ制約のみを検証) $this->formData['same_as'] = 'https://example.com/'.str_repeat('a', $this->eccubeConfig['eccube_ltext_len']); @@ -239,91 +255,91 @@ public function testInValidSameAsMaxLength() $this->assertFalse($this->form->isValid()); } - public function testValidNumberOfEmployeesZero() + public function testValidNumberOfEmployeesZero(): void { $this->formData['number_of_employees'] = '0'; $this->form->submit($this->formData); $this->assertTrue($this->form->isValid()); } - public function testInValidNumberOfEmployeesNegative() + public function testInValidNumberOfEmployeesNegative(): void { $this->formData['number_of_employees'] = '-1'; $this->form->submit($this->formData); $this->assertFalse($this->form->isValid()); } - public function testValidNumberOfEmployeesIntMax() + public function testValidNumberOfEmployeesIntMax(): void { $this->formData['number_of_employees'] = '2147483647'; $this->form->submit($this->formData); $this->assertTrue($this->form->isValid()); } - public function testInValidNumberOfEmployeesOverIntMax() + public function testInValidNumberOfEmployeesOverIntMax(): void { $this->formData['number_of_employees'] = '2147483648'; $this->form->submit($this->formData); $this->assertFalse($this->form->isValid()); } - public function testValidCopyrightYearRangeMin() + public function testValidCopyrightYearRangeMin(): void { $this->formData['copyright_year'] = '1900'; $this->form->submit($this->formData); $this->assertTrue($this->form->isValid()); } - public function testValidCopyrightYearRangeMax() + public function testValidCopyrightYearRangeMax(): void { $this->formData['copyright_year'] = '9999'; $this->form->submit($this->formData); $this->assertTrue($this->form->isValid()); } - public function testInValidCopyrightYearBelowMin() + public function testInValidCopyrightYearBelowMin(): void { $this->formData['copyright_year'] = '1899'; $this->form->submit($this->formData); $this->assertFalse($this->form->isValid()); } - public function testInValidCopyrightYearAboveMax() + public function testInValidCopyrightYearAboveMax(): void { $this->formData['copyright_year'] = '10000'; $this->form->submit($this->formData); $this->assertFalse($this->form->isValid()); } - public function testValidFoundingDatePast() + public function testValidFoundingDatePast(): void { $this->formData['founding_date'] = '2000-04-01'; $this->form->submit($this->formData); $this->assertTrue($this->form->isValid()); } - public function testInValidFoundingDateFuture() + public function testInValidFoundingDateFuture(): void { $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() + public function testValidSiteImageUrl(): void { $this->formData['site_image'] = 'https://example.com/site.png'; $this->form->submit($this->formData); $this->assertTrue($this->form->isValid()); } - public function testInValidSiteImageNotUrl() + public function testInValidSiteImageNotUrl(): void { $this->formData['site_image'] = 'not-a-url'; $this->form->submit($this->formData); $this->assertFalse($this->form->isValid()); } - public function testInValidSiteImageMaxLength() + public function testInValidSiteImageMaxLength(): void { // 形式は有効なURLだが最大長を超えるケース(長さ制約のみを検証) $this->formData['site_image'] = 'https://example.com/'.str_repeat('a', $this->eccubeConfig['eccube_stext_len']);