Skip to content

feat: 店舗設定に構造化データ拡張項目を追加 (sameAs/foundingDate 等) (#6147) - #6957

Draft
ttokoro20240902 wants to merge 8 commits into
4.4from
feature/issue-6147-baseinfo-schema
Draft

feat: 店舗設定に構造化データ拡張項目を追加 (sameAs/foundingDate 等) (#6147)#6957
ttokoro20240902 wants to merge 8 commits into
4.4from
feature/issue-6147-baseinfo-schema

Conversation

@ttokoro20240902

@ttokoro20240902 ttokoro20240902 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

概要 (Refs #6147, #6136)

店舗設定(BaseInfo)を拡張し、Organization / WebSite の構造化データに以下を出力できるようにします。

スカラー項目

  • Organization.sameAs(SNS等の公式URL・複数=改行区切り)
  • Organization.foundingDate(稼働開始日)
  • Organization.numberOfEmployees(従業員数, QuantitativeValue)
  • Organization.image(サイト代表画像URL)
  • WebSite.copyrightYear(著作権表示の開始年)

営業時間

  • Organization.openingHoursSpecification(曜日×開店/閉店時刻・複数行)
    • 既存の business_hour(自由テキスト・Help表示用)とは別に、構造化データ用の曜日/時刻を保持

依存 / 順序(Stacked PR)

変更内容

  • BaseInfo にスカラー5カラム追加 + OpeningHours(dtb_opening_hours) を OneToMany で追加
  • 店舗設定「基本設定」に入力欄(スカラー各種 + 営業時間の CollectionType 動的行UI)/翻訳 ja,en
  • SiteStructuredDataService に各項目を反映(値が空の任意プロパティは出力しない
  • マイグレーション Version20260722000000(BaseInfo列)/ Version20260722010000(dtb_opening_hours, Schema APIで両DB対応)
  • SiteStructuredDataServiceTest に各項目のテストを追加

テスト

  • ローカル: php-cs-fixer / PHPStan(level6) / PHPUnit(15 tests 38 assertions) / Rector すべて green
  • 実機: 管理画面フォームの表示・保存 → DB反映 → フロント JSON-LD 反映を確認(営業時間の行追加/削除・openingHoursSpecification 出力含む)

残作業(別軸, #6147

  • Google Shopping Feed 必須属性の充足(商品フィード側)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • 新機能

    • 店舗基本情報に公式URL、創業日、従業員数、著作権年、サイト画像URLを追加しました。
    • 曜日別の営業時間を登録・編集・削除できるようになりました。
    • 店舗情報と営業時間を構造化データとしてフロントページに出力します。
  • バグ修正

    • URL形式、日付、営業時間の前後関係や重複を検証するようになりました。
    • 入力項目とエラーメッセージを日本語・英語に対応しました。

tao-s and others added 3 commits July 22, 2026 10:39
Organization / WebSite の JSON-LD をテンプレートの文字列補間で組み立てていたため、
店名等に " や </script> が含まれると構造化データが壊れる/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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

店舗基本情報に構造化データ項目と営業時間を追加しました。管理画面で保存した情報からJSON-LDを生成し、フロント画面へ出力します。入力検証、データベース保存、単体テスト、E2Eテストも追加しました。

Changes

サイト構造化データと営業時間

Layer / File(s) Summary
店舗情報と営業時間のデータモデル
app/DoctrineMigrations/..., src/Eccube/Entity/..., src/Eccube/Repository/...
BaseInfoに構造化データ項目とOpeningHours関連を追加しました。営業時間エンティティ、リポジトリ、データベーステーブルを追加しました。
管理画面入力とバリデーション
src/Eccube/Form/..., src/Eccube/Resource/template/admin/..., src/Eccube/Resource/locale/..., tests/Eccube/Tests/Form/...
構造化データ項目と営業時間の入力欄を追加しました。URL、日付、数値、営業時間の順序と重複を検証します。
JSON-LD生成サービス
src/Eccube/Service/SiteStructuredDataService.php, tests/Eccube/Tests/Service/...
WebSiteOrganizationのJSON-LDを生成します。住所、連絡先、sameAs、画像、設立日、従業員数、営業時間を条件付きで出力します。
Twig注入とフロント出力
src/Eccube/EventListener/..., src/Eccube/Resource/template/default/..., e2e/tests/admin-basicinfo.spec.ts
TwigグローバルへJSON-LDを登録し、フロント画面に出力します。管理画面での保存、再表示、JSON-LD出力、営業時間重複エラーをE2Eテストで検証します。

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Admin
  participant ShopMasterType
  participant SiteStructuredDataService
  participant Twig
  Admin->>ShopMasterType: 店舗情報と営業時間を保存
  ShopMasterType->>ShopMasterType: 入力値と営業時間の重複を検証
  Twig->>SiteStructuredDataService: BaseInfoからJSON-LDを生成
  SiteStructuredDataService-->>Twig: WebSite・Organization JSON-LDを返却
  Twig-->>Admin: JSON-LD scriptをフロント画面へ出力
Loading

Possibly related PRs

  • EC-CUBE/ec-cube#6139: SiteStructuredDataService、Twig出力、関連テストを拡張する変更です。
  • EC-CUBE/ec-cube#6837: BaseInfo.phpShopMasterType.phpの設定項目追加が関連します。

Suggested labels: enhancement, Status: discussion

Suggested reviewers: nanasess, dotani1111

Poem

ぴょん、店舗の時刻を登録した
URLと会社情報も整った
JSON-LDが空へ跳ね
Twigの画面に花を咲かせる
重なる時間は止めたよ
月明かりの店、準備完了 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed タイトルは、店舗設定への構造化データ拡張項目の追加という変更の主目的を、具体的かつ簡潔に示しています。
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/issue-6147-baseinfo-schema

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ttokoro20240902

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/Eccube/Form/Type/Admin/ShopMasterType.php (1)

160-168: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

number_of_employees に上限を設ける値域チェックが必要

number_of_employees は数値のみを許容する Assert\Regex のみで、上限がありません。DBカラムは INT(migration参照)のため、桁数の大きい値を入力すると保存時にDBエラーとなる可能性があります。同ファイル内の copyright_yearbasic_point_ratepoint_conversion_rate は Range 制約で値域を制限しているため、同様の対応を推奨します。

♻️ 提案する修正
             ->add('number_of_employees', IntegerType::class, [
                 'required' => false,
                 'constraints' => [
                     new Assert\Regex([
                         'pattern' => "/^\d+$/u",
                         'message' => 'form_error.numeric_only',
                     ]),
+                    new Assert\Range([
+                        'min' => 0,
+                        'max' => 2147483647,
+                    ]),
                 ],
             ])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Eccube/Form/Type/Admin/ShopMasterType.php` around lines 160 - 168, Update
the number_of_employees constraints in ShopMasterType to include an Assert\Range
upper bound matching the database INT column, while preserving the existing
numeric-only validation. Follow the established Range constraint pattern used by
copyright_year, basic_point_rate, and point_conversion_rate, including an
appropriate validation message.
src/Eccube/EventListener/TwigInitializeListener.php (1)

181-181: 🚀 Performance & Scalability | 🔵 Trivial

構造化データの生成はリクエスト毎に行われる点に留意

createWebSiteJsonLd はフロント全ページのリクエストごとに呼び出され、キャッシュされません。現状は軽量な処理(配列構築とルート生成数回)のため大きな懸念はありませんが、将来的に負荷が問題になる場合は、BaseInfo の更新時のみ再計算するキャッシュ戦略(例: タグ付きキャッシュや BaseInfo 更新イベントでの無効化)を検討してください。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Eccube/EventListener/TwigInitializeListener.php` at line 181, Review the
site_json_ld initialization in TwigInitializeListener and add a cache for
createWebSiteJsonLd keyed to the current BaseInfo state, with invalidation when
BaseInfo is updated; ensure subsequent requests reuse the cached structured data
until that update occurs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/DoctrineMigrations/Version20260722000000.php`:
- Around line 35-73: Remove the Version20260722000000 migration because it only
adds nullable columns already represented by BaseInfo entity attributes and
should be applied through schema:update. Delete both up() and down() handling
for same_as, founding_date, number_of_employees, copyright_year, and site_image;
retain the migration only if a documented production-upgrade requirement makes
it necessary, with that exception explicitly recorded.

---

Nitpick comments:
In `@src/Eccube/EventListener/TwigInitializeListener.php`:
- Line 181: Review the site_json_ld initialization in TwigInitializeListener and
add a cache for createWebSiteJsonLd keyed to the current BaseInfo state, with
invalidation when BaseInfo is updated; ensure subsequent requests reuse the
cached structured data until that update occurs.

In `@src/Eccube/Form/Type/Admin/ShopMasterType.php`:
- Around line 160-168: Update the number_of_employees constraints in
ShopMasterType to include an Assert\Range upper bound matching the database INT
column, while preserving the existing numeric-only validation. Follow the
established Range constraint pattern used by copyright_year, basic_point_rate,
and point_conversion_rate, including an appropriate validation message.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6a3143e4-2c95-413a-bc02-0809e40c0501

📥 Commits

Reviewing files that changed from the base of the PR and between b978d3a and b9d9b26.

📒 Files selected for processing (10)
  • app/DoctrineMigrations/Version20260722000000.php
  • src/Eccube/Entity/BaseInfo.php
  • src/Eccube/EventListener/TwigInitializeListener.php
  • src/Eccube/Form/Type/Admin/ShopMasterType.php
  • src/Eccube/Resource/locale/messages.en.yaml
  • src/Eccube/Resource/locale/messages.ja.yaml
  • src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig
  • src/Eccube/Resource/template/default/default_frame.twig
  • src/Eccube/Service/SiteStructuredDataService.php
  • tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php

Comment thread app/DoctrineMigrations/Version20260722000000.php Outdated
@ttokoro20240902
ttokoro20240902 force-pushed the feature/issue-6147-baseinfo-schema branch from 247de17 to b031ca9 Compare July 22, 2026 05:53
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) <noreply@anthropic.com>
@ttokoro20240902
ttokoro20240902 force-pushed the feature/issue-6147-baseinfo-schema branch from b031ca9 to 4e3a5c0 Compare July 22, 2026 06:22
ttokoro20240902 and others added 2 commits July 23, 2026 09:29
- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
@ttokoro20240902

ttokoro20240902 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

CodeRabbit のレビュー指摘に対応しました(commit 4e97130 / c4e64cb)。

  • number_of_employees の値域上限: Assert\LessThanOrEqual(2147483647)(INT 上限)を追加し、桁あふれによる保存時 DB エラーを防止しました。上限の単体テスト 2 件(境界 2147483647 = 有効 / 2147483648 = 無効)も追加しています。
    ※ 併せて元の Assert\RegexAssert\PositiveOrZero に置換済みで、負値も拒否します。

  • ② JSON-LD がリクエスト毎に生成される点(TwigInitializeListener:181: 今回は見送りました。ご指摘のとおり処理は軽量(連想配列の構築とルート生成が数回)で、CodeRabbit の評価も「現状は大きな懸念なし」のためです。将来的に負荷が問題化した場合は、BaseInfo 更新イベントでのタグ付きキャッシュ無効化を検討します。

  • ③ 単純カラム追加のための migration: 該当インラインスレッドのとおり Version20260722000000 を削除しました(dtb_opening_hours 新設の Version20260722010000 は維持)。

なお、本 PR には別途、追加項目のバリデーション強化(same_as の各行 URL 検証、founding_date の未来日禁止、営業時間の重複エラー表示)と、FormType 単体テスト・管理画面 E2E を追加済みです。

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.07143% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.34%. Comparing base (aafc564) to head (41bbff5).

Files with missing lines Patch % Lines
src/Eccube/Entity/OpeningHours.php 67.56% 12 Missing ⚠️
src/Eccube/Service/SiteStructuredDataService.php 93.45% 7 Missing ⚠️
src/Eccube/Form/Type/Admin/ShopMasterType.php 96.70% 3 Missing ⚠️
src/Eccube/Entity/BaseInfo.php 91.30% 2 Missing ⚠️
src/Eccube/Repository/OpeningHoursRepository.php 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              4.4    #6957      +/-   ##
==========================================
+ Coverage   77.21%   77.34%   +0.12%     
==========================================
  Files         563      567       +4     
  Lines       28111    28391     +280     
==========================================
+ Hits        21705    21958     +253     
- Misses       6406     6433      +27     
Flag Coverage Δ
Unit 77.34% <91.07%> (+0.12%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

validators.{ja,en}.yaml のコンフリクトを解消。
双方とも末尾へのキー追記のみだったため、4.4 側の
form_error.refund_request.* と本ブランチの
admin.setting.shop.* を両方残した。
@ttokoro20240902
ttokoro20240902 marked this pull request as ready for review August 5, 2026 02:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php (1)

202-208: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

空の営業時間エンティティも検証してください。

このテストはコレクションを空にします。buildOpeningHours() の「空の OpeningHours エンティティを除外する」分岐は実行しません。値を設定しない OpeningHours を追加して、openingHoursSpecification が出力されないことを検証してください。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php` around lines
202 - 208, Update testEmptyOpeningHoursIsOmitted to add an OpeningHours entity
with no values after clearing the collection, so buildOpeningHours() exercises
its empty-entity exclusion branch. Keep the assertion that
openingHoursSpecification is absent from the generated organization JSON-LD.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@e2e/tests/admin-basicinfo.spec.ts`:
- Around line 1329-1424: Wrap the test body after the initial reset save in a
try/finally so cleanup always runs when persistence or duplicate-validation
assertions fail. Move the existing cleanup logic beginning with the
deleteButtons loop into the finally block, preserving its removal of
opening-hour rows and scalar fields before saving.

In `@src/Eccube/Form/Type/Admin/ShopMasterType.php`:
- Around line 372-399: OpeningHours の元のコレクションキーが失われ、$j
がフォーム上の実際の行を指さなくなっています。array_values() を使わず元キーを保持したリストで比較し、atPath() の
OpeningHours[$j].closes には該当する元キーを使用してください。削除後にキーが飛んだ状態で重複行を検出した場合も、正しい closes
フィールドへ違反が表示されるテストを追加してください。

In `@tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php`:
- Around line 173-332: Update each newly added test method in
ShopMasterTypeTest, including testValidOpeningHours and the related
opening-hours, same_as, employee, copyright, founding-date, and site-image
tests, to declare a void return type. Keep their existing test logic unchanged.

---

Nitpick comments:
In `@tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php`:
- Around line 202-208: Update testEmptyOpeningHoursIsOmitted to add an
OpeningHours entity with no values after clearing the collection, so
buildOpeningHours() exercises its empty-entity exclusion branch. Keep the
assertion that openingHoursSpecification is absent from the generated
organization JSON-LD.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e6e2fd26-5e0e-4410-b45e-29fffbc286e5

📥 Commits

Reviewing files that changed from the base of the PR and between b9d9b26 and 41bbff5.

📒 Files selected for processing (16)
  • app/DoctrineMigrations/Version20260722010000.php
  • e2e/tests/admin-basicinfo.spec.ts
  • src/Eccube/Entity/BaseInfo.php
  • src/Eccube/Entity/OpeningHours.php
  • src/Eccube/Form/Type/Admin/OpeningHoursType.php
  • src/Eccube/Form/Type/Admin/ShopMasterType.php
  • src/Eccube/Repository/OpeningHoursRepository.php
  • src/Eccube/Resource/locale/messages.en.yaml
  • src/Eccube/Resource/locale/messages.ja.yaml
  • src/Eccube/Resource/locale/validators.en.yaml
  • src/Eccube/Resource/locale/validators.ja.yaml
  • src/Eccube/Resource/template/admin/Setting/Shop/opening_hours_prototype.twig
  • src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig
  • src/Eccube/Service/SiteStructuredDataService.php
  • tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php
  • tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php

Comment on lines +1329 to +1424
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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

失敗時にもテストデータを削除してください。

Line 1329 から Line 1424 では、保存後の検証または重複検証が失敗すると、Line 1410 以降のクリーンアップを実行しません。後続の E2E テストが BaseInfo の値と営業時間を引き継ぐため、結果が実行順序に依存します。初期化保存の直後から try/finally を使用して、失敗時にもクリーンアップを実行してください。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@e2e/tests/admin-basicinfo.spec.ts` around lines 1329 - 1424, Wrap the test
body after the initial reset save in a try/finally so cleanup always runs when
persistence or duplicate-validation assertions fail. Move the existing cleanup
logic beginning with the deleteButtons loop into the finally block, preserving
its removal of opening-hour rows and scalar fields before saving.

Comment on lines +372 to +399
$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(閉店))
// 描画済みのリーフ(closes)にエラーを付け、該当行に表示されるようにする
if (max($opensA, $opensB) < min($closesA, $closesB)) {
$context->buildViolation('admin.setting.shop.opening_hours.error.overlap')
->atPath('OpeningHours['.$j.'].closes')
->addViolation();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

営業時間の元のコレクションキーを保持してください。

array_values() は削除後のフォームキーを詰めます。例えば OpeningHours[1] を削除した場合、実際の子フォームは OpeningHours[0]OpeningHours[2] です。現在の $j は詰めた配列の位置なので、違反が別の行または存在しない closes フィールドに付きます。

元のキーを保持し、そのキーを atPath() に使用してください。削除後に重複した行で、エラーが実際の closes フィールドへ表示されるテストも追加してください。

修正例
-        $list = array_values($BaseInfo->getOpeningHours()->toArray());
-        $count = count($list);
+        $list = $BaseInfo->getOpeningHours()->toArray();
+        $keys = array_keys($list);
+        $count = count($keys);
         for ($i = 0; $i < $count; ++$i) {
             for ($j = $i + 1; $j < $count; ++$j) {
-                $a = $list[$i];
-                $b = $list[$j];
+                $a = $list[$keys[$i]];
+                $b = $list[$keys[$j]];
 ...
                 if (max($opensA, $opensB) < min($closesA, $closesB)) {
                     $context->buildViolation('admin.setting.shop.opening_hours.error.overlap')
-                        ->atPath('OpeningHours['.$j.'].closes')
+                        ->atPath('OpeningHours['.$keys[$j].'].closes')
                         ->addViolation();
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$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(閉店))
// 描画済みのリーフ(closes)にエラーを付け、該当行に表示されるようにする
if (max($opensA, $opensB) < min($closesA, $closesB)) {
$context->buildViolation('admin.setting.shop.opening_hours.error.overlap')
->atPath('OpeningHours['.$j.'].closes')
->addViolation();
$list = $BaseInfo->getOpeningHours()->toArray();
$keys = array_keys($list);
$count = count($keys);
for ($i = 0; $i < $count; ++$i) {
for ($j = $i + 1; $j < $count; ++$j) {
$a = $list[$keys[$i]];
$b = $list[$keys[$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(閉店))
// 描画済みのリーフ(closes)にエラーを付け、該当行に表示されるようにする
if (max($opensA, $opensB) < min($closesA, $closesB)) {
$context->buildViolation('admin.setting.shop.opening_hours.error.overlap')
->atPath('OpeningHours['.$keys[$j].'].closes')
->addViolation();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Eccube/Form/Type/Admin/ShopMasterType.php` around lines 372 - 399,
OpeningHours の元のコレクションキーが失われ、$j がフォーム上の実際の行を指さなくなっています。array_values()
を使わず元キーを保持したリストで比較し、atPath() の OpeningHours[$j].closes
には該当する元キーを使用してください。削除後にキーが飛んだ状態で重複行を検出した場合も、正しい closes
フィールドへ違反が表示されるテストを追加してください。

Comment on lines +173 to +332
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());
}

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 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';
$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());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

新しいテストメソッドに : void を付けてください。

追加した PHPUnit テストメソッドは戻り値を返しません。各メソッドに : void を宣言してください。

修正例
-    public function testValidOpeningHours()
+    public function testValidOpeningHours(): void

As per coding guidelines, PHP の引数と戻り値には型宣言を付ける。

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php` around lines 173 -
332, Update each newly added test method in ShopMasterTypeTest, including
testValidOpeningHours and the related opening-hours, same_as, employee,
copyright, founding-date, and site-image tests, to declare a void return type.
Keep their existing test logic unchanged.

Source: Coding guidelines

@ttokoro20240902
ttokoro20240902 marked this pull request as draft August 5, 2026 05:26
@ttokoro20240902

Copy link
Copy Markdown
Contributor Author

@nanasess @dotani1111 レビューをお願いした直後に恐縮ですが、本 PR を一旦 draft に戻します。 先に #6139 を進めるべきと判断しました。まだ着手されていなければ、そのままお待ちいただければ幸いです。

理由

本 PR と #6139 が、同じファイルをそれぞれ「新規追加」しています

ファイル #6139 本 PR
src/Eccube/Service/SiteStructuredDataService.php 新規追加 新規追加
tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php 新規追加 新規追加
src/Eccube/EventListener/TwigInitializeListener.php 変更 変更
src/Eccube/Resource/template/default/default_frame.twig 変更 変更

4.4 にはどちらも存在しない(あるのは ProductStructuredDataService のみ)ため、どちらが先にマージされても、もう一方は追加ファイルの衝突になります。出力項目を比べると本 PR は #6139 の上位集合で、sameAs / foundingDate / numberOfEmployees / openingHoursSpecification が増えているだけでした。同じ機能を 2 本の PR で二重に持っている状態です。

とくに、サイト共通の構造化データについてのレビューは #6139 側に蓄積されています。nanasess さんが 8/3 に出力値そのものを精査してくださっており、未解決スレッドが 11 件残っています。本 PR を先に通すと、そのレビューを迂回して同じ実装を取り込むことになります(telephone への +81- 無条件前置など、本 PR の service にも同じコードが入っています)。

進め方

  1. Organization, Websiteのマークアップを追加 #6139 の未解決スレッドに対応してマージを目指す
  2. その後、本 PR から SiteStructuredDataService / TwigInitializeListener / default_frame.twig の新規追加を落とし、既存サービスへの差分sameAs / foundingDate / numberOfEmployees / openingHoursSpecification の追加)に作り直す
  3. あらためて ready for review にします

OpeningHours エンティティ・OpeningHoursType・マイグレーション・E2E は #6139 とは独立なので、作り直しの範囲は上記 3 ファイルに限られます。Issue #6147 の趣旨(店舗設定の項目追加)と差分も一致するようになります。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants