diff --git a/app/DoctrineMigrations/Version20260722010000.php b/app/DoctrineMigrations/Version20260722010000.php new file mode 100644 index 0000000000..303b36ddf1 --- /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/e2e/tests/admin-basicinfo.spec.ts b/e2e/tests/admin-basicinfo.spec.ts index 6fdba0a268..63e2c1065a 100644 --- a/e2e/tests/admin-basicinfo.spec.ts +++ b/e2e/tests/admin-basicinfo.spec.ts @@ -1386,4 +1386,107 @@ test.describe('Admin Basic Info (EA07)', () => { await expect(page.locator('.alert-success')).toContainText('保存しました', { timeout: 30_000 }); } }); + + test('basicinfo_構造化データ_営業時間とSNS等URL - EA0701-UC01-T19', async ({ page }) => { + test.setTimeout(120_000); + + const sameAsUrl = 'https://example.com/official-sns'; + + // 検証が失敗してもクリーンアップを必ず実行する。BaseInfo と営業時間は + // 後続テストへ引き継がれるため、残すと結果が実行順序に依存する。 + try { + // --- 入力(構造化データのスカラー項目 + 営業時間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('同じ曜日で営業時間が重複しています'); + + } 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 }); + } + }); }); diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 2986493523..c453391701 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')] @@ -197,6 +200,34 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'order_pdf_visible_invoice_number', type: Types::BOOLEAN, options: ['default' => true])] private bool $order_pdf_visible_invoice_number = true; + #[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; + + /** + * @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. * @@ -1126,4 +1157,123 @@ public function isOrderPdfVisibleInvoiceNumber(): bool { return $this->order_pdf_visible_invoice_number; } + + /** + * 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; + } + + /** + * 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 0000000000..ea5ff5c450 --- /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 0000000000..cda59649c0 --- /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 dd5ed4bcc6..36ca248a5c 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; @@ -23,14 +24,21 @@ 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; use Symfony\Component\Form\Extension\Core\Type\NumberType; 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; /** * Class ShopMasterType @@ -142,6 +150,61 @@ 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'], + ]), + 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\PositiveOrZero(), + // DB カラムは INT。桁あふれによる保存時エラーを防ぐため上限を設ける + new Assert\LessThanOrEqual(2147483647), + ], + ]) + ->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('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, @@ -269,6 +332,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->addEventSubscriber(new ConvertKanaListener('CV')) ); + + // 営業時間の重複はフォームの子キーに紐づけたいので、クラス制約の Callback ではなく + // POST_SUBMIT で検証する(詳細は validateOpeningHoursOverlap() のコメント参照)。 + $builder->addEventListener(FormEvents::POST_SUBMIT, $this->validateOpeningHoursOverlap(...)); } /** @@ -282,6 +349,86 @@ public function configureOptions(OptionsResolver $resolver): void ]); } + /** + * 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; + } + } + } + + /** + * 同一曜日を含む営業時間の時間帯が重複していないか検証する. + * + * エンティティのコレクションではなく**フォームの子**を走査する。画面で中間行を削除すると + * 送信キーは歯抜け(0, 2 等)になるが、コレクション側は adder で 0 始まりに詰め直されるため、 + * コレクションの添字から組み立てた property path は実在しない子を指してしまい、 + * エラーが該当行に表示されない(あるいは失われる)。 + */ + public function validateOpeningHoursOverlap(FormEvent $event): void + { + $form = $event->getForm(); + if (!$form->has('OpeningHours')) { + return; + } + + /** @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 = $children[$names[$i]]->getData(); + $b = $children[$names[$j]]->getData(); + if (!$a instanceof OpeningHours || !$b instanceof OpeningHours) { + continue; + } + + $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)) { + $children[$names[$j]]->get('closes') + ->addError(new FormError(trans('admin.setting.shop.opening_hours.error.overlap'))); + } + } + } + } + /** * {@inheritdoc} */ diff --git a/src/Eccube/Repository/OpeningHoursRepository.php b/src/Eccube/Repository/OpeningHoursRepository.php new file mode 100644 index 0000000000..3d4c2c8fc0 --- /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 2c8ce5bbee..d6226f20fd 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1261,6 +1261,26 @@ 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.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) @@ -1862,6 +1882,12 @@ 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.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.order_pdf: Items printed in the shop information area of the delivery slip PDF. Among the items turned ON, only those with a value entered are printed. 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. diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 9649577697..b5b91d6114 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1260,6 +1260,26 @@ 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.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: 送料無料条件(数量) @@ -1862,6 +1882,12 @@ 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.opening_hours: 構造化データ(Organization.openingHoursSpecification)に出力する営業時間です。曜日(複数選択可)と開店・閉店時刻を行ごとに設定できます。 tooltip.setting.shop.shop.order_pdf: 納品書PDFの店舗情報欄に出力する項目です。ONにした項目のうち、値が入力されているものだけが出力されます。 tooltip.setting.shop.shop.option_delivery_fee_free_amount: この金額を超える購入があった場合、送料を無料とします。 tooltip.setting.shop.shop.option_delivery_fee_free_quantity: この個数を超える購入があった場合、送料を無料とします。 diff --git a/src/Eccube/Resource/locale/validators.en.yaml b/src/Eccube/Resource/locale/validators.en.yaml index 37a98dd2e2..d9c366d30a 100644 --- a/src/Eccube/Resource/locale/validators.en.yaml +++ b/src/Eccube/Resource/locale/validators.en.yaml @@ -78,3 +78,10 @@ form_error.refund_request.quantity_exceed: Quantity must be {{ compared_value }} form_error.refund_request.max_files: You can attach up to {{ limit }} files. form_error.refund_request.file_size: File is too large. Max {{ limit }}. form_error.refund_request.file_type: File type not allowed. Please use JPEG, PNG, GIF, WebP, MP4, MOV, or AVI. +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.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 6ce247550a..4169152a89 100644 --- a/src/Eccube/Resource/locale/validators.ja.yaml +++ b/src/Eccube/Resource/locale/validators.ja.yaml @@ -81,3 +81,10 @@ form_error.refund_request.quantity_exceed: 返品数量は{{ compared_value }} form_error.refund_request.max_files: ファイルは{{ limit }}件まで添付できます。 form_error.refund_request.file_size: ファイルサイズが大きすぎます。{{ limit }}以内にしてください。 form_error.refund_request.file_type: 許可されていないファイル形式です。画像(JPEG, PNG, GIF, WebP)または動画(MP4, MOV, AVI)を添付してください。 +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.same_as.error.invalid_url: 各行にSNS等の有効なURLを入力してください。 +admin.setting.shop.founding_date.error.not_future: 稼働開始日は未来の日付にできません。 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 0000000000..5c0fbee4b4 --- /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 8b4c2f61a0..e30d108061 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 %} @@ -213,6 +239,64 @@ file that was distributed with this source code.
+
+
+
{{ '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) }} +
+
+
+
+
{{ '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 8391362391..e9902de3eb 100644 --- a/src/Eccube/Service/SiteStructuredDataService.php +++ b/src/Eccube/Service/SiteStructuredDataService.php @@ -99,6 +99,12 @@ private function createWebSiteNode(BaseInfo $BaseInfo): array ], 'query-input' => 'required name=search_term_string', ]; + + $copyrightYear = $BaseInfo->getCopyrightYear(); + if ($copyrightYear !== null) { + $data['copyrightYear'] = $copyrightYear; + } + $data['publisher'] = ['@id' => $siteUrl.'#organization']; return $data; @@ -131,6 +137,7 @@ public function createOrganizationJsonLd(BaseInfo $BaseInfo): array $this->addIfNotEmpty($data, 'alternateName', $BaseInfo->getShopNameEng()); $this->addIfNotEmpty($data, 'legalName', $BaseInfo->getCompanyName()); $this->addIfNotEmpty($data, 'description', $this->normalizeDescription($BaseInfo->getMessage())); + $this->addIfNotEmpty($data, 'image', $BaseInfo->getSiteImage()); // email01 は送信元(From)かつ全送信メールの BCC 先で、送信専用や店舗内部の運用アドレスが // 入る前提の項目なので公開しない。公開して良い連絡先は email02(問い合わせ専用)。 $this->addIfNotEmpty($data, 'email', $BaseInfo->getEmail02()); @@ -153,14 +160,94 @@ 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; + } + + $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 のリストに変換する. + * + * @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/Form/Type/Admin/ShopMasterTypeTest.php b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php index f3e2f70d1c..f975881724 100644 --- a/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php +++ b/tests/Eccube/Tests/Form/Type/Admin/ShopMasterTypeTest.php @@ -171,6 +171,183 @@ public function testInValidMessageMaxLength() $this->assertFalse($this->form->isValid()); } + public function testValidOpeningHours(): void + { + $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(): void + { + $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(): void + { + $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(): void + { + $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()); + } + + /** + * 画面で中間行を削除すると送信キーが歯抜け(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'], + ['day_of_week' => ['Sunday'], 'opens' => '10:00', 'closes' => '15:00'], + ]; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + 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(): 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(): void + { + // 形式は有効な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(): void + { + $this->formData['number_of_employees'] = '0'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidNumberOfEmployeesNegative(): void + { + $this->formData['number_of_employees'] = '-1'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testValidNumberOfEmployeesIntMax(): void + { + $this->formData['number_of_employees'] = '2147483647'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidNumberOfEmployeesOverIntMax(): void + { + $this->formData['number_of_employees'] = '2147483648'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testValidCopyrightYearRangeMin(): void + { + $this->formData['copyright_year'] = '1900'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testValidCopyrightYearRangeMax(): void + { + $this->formData['copyright_year'] = '9999'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidCopyrightYearBelowMin(): void + { + $this->formData['copyright_year'] = '1899'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testInValidCopyrightYearAboveMax(): void + { + $this->formData['copyright_year'] = '10000'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testValidFoundingDatePast(): void + { + $this->formData['founding_date'] = '2000-04-01'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + 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(): void + { + $this->formData['site_image'] = 'https://example.com/site.png'; + $this->form->submit($this->formData); + $this->assertTrue($this->form->isValid()); + } + + public function testInValidSiteImageNotUrl(): void + { + $this->formData['site_image'] = 'not-a-url'; + $this->form->submit($this->formData); + $this->assertFalse($this->form->isValid()); + } + + public function testInValidSiteImageMaxLength(): void + { + // 形式は有効な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()); + } + /** * 納品書PDFの出力項目トグルが BaseInfo にマッピングされること (#6197). * チェックボックスは未チェックをキー欠落で表すため, OFF はキーを送らないことで再現する. diff --git a/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php b/tests/Eccube/Tests/Service/SiteStructuredDataServiceTest.php index f7279415dd..aaeefd5e59 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; use Symfony\Component\Filesystem\Filesystem; @@ -262,4 +263,113 @@ 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); + [$webSite] = $data['@graph']; + + // copyrightYear は WebSite ノードに載る(Organization ではない) + $this->assertSame('WebSite', $webSite['@type']); + $this->assertSame(2020, $webSite['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); + [$webSite] = $this->service->createWebSiteJsonLd($this->BaseInfo)['@graph']; + + $this->assertArrayNotHasKey('sameAs', $org); + $this->assertArrayNotHasKey('foundingDate', $org); + $this->assertArrayNotHasKey('numberOfEmployees', $org); + $this->assertArrayNotHasKey('image', $org); + $this->assertArrayNotHasKey('copyrightYear', $webSite); + } + + 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); + } }