diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 0c8cd8bfb5b..d5e043792e2 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -30,6 +30,9 @@ jobs: - front-product - front-customer - front-other + - front-order + - front-mypage + - front-invoice include: - db: pgsql database_url: postgres://postgres:password@127.0.0.1:5432/eccube_db diff --git a/e2e/tests/front-order.spec.ts b/e2e/tests/front-order.spec.ts index 7431a6dbd85..6e160f5ddf1 100644 --- a/e2e/tests/front-order.spec.ts +++ b/e2e/tests/front-order.spec.ts @@ -760,3 +760,109 @@ test.describe('Front Order (EF03)', () => { await expect(page.locator('.ec-cartRow__name').first()).toContainText('チェリーアイスサンド'); }); }); + +/** + * 配送方法・支払い方法の保存と復元 (#6819)。 + * + * 保存(注文確定)→ 情報ボックス表示 → 手動復元のユーザージャーニーを検証する。 + * ゲスト除外・複数配送先・非公開時の警告などのバリエーションは + * PHPUnit の機能テスト(ShoppingControllerPreferredShippingPaymentTest)でカバーする。 + * + * 2 つのテストは保存→復元の順序に依存するため serial で実行する。 + */ +test.describe.serial('Front Order 配送方法・支払い方法の保存と復元 (#6819)', () => { + + /** + * Helper: カートに商品を入れ、レジに進んで /shopping まで遷移する。 + */ + async function goToShopping(page: import('@playwright/test').Page) { + await addProductToCartAndGoToCart(page, 1); + await page.locator('a.ec-blockBtn--action', { hasText: 'レジに進む' }).click(); + await page.waitForLoadState('load'); + await expect(page).toHaveURL(/\/shopping$/); + } + + /** + * Helper: 支払方法のラジオを選択する。 + * data-trigger="change" によりフォームが /shopping/redirect_to へ送信され、画面がリロードされる。 + */ + async function selectPayment(page: import('@playwright/test').Page, label: string) { + const radio = page.getByRole('radio', { name: label }); + if (await radio.isChecked()) { + return; + } + await Promise.all([ + page.waitForResponse((resp) => resp.url().includes('/shopping/redirect_to')), + radio.check(), + ]); + await page.waitForLoadState('load'); + await expect(page.getByRole('radio', { name: label })).toBeChecked(); + } + + test.afterEach(async ({ page }) => { + await clearCart(page); + }); + + test('保存チェックボックスONで注文すると次回の注文手続きに保存設定が表示される', async ({ page }) => { + await loginAsTestCustomer(page); + await goToShopping(page); + + // 保存対象を明確にするため支払方法を「銀行振込」に変更する + await selectPayment(page, '銀行振込'); + + // 確認画面へ + await page.locator('.ec-blockBtn--action', { hasText: '確認する' }).click(); + await page.waitForLoadState('load'); + await expect(page).toHaveURL(/\/shopping\/confirm/); + + // 保存チェックボックスが表示され、初期値は OFF + const checkbox = page.locator('input[name="_shopping_order[save_preferred_shipping_payment]"]'); + await expect(checkbox).toBeVisible(); + await expect(checkbox).not.toBeChecked(); + const checkboxArea = page.locator('.ec-totalBox__preferred'); + await expect(checkboxArea).toContainText('この配送方法と支払い方法を保存する'); + await expect(checkboxArea).toContainText('次回の注文時にワンクリックで設定できます'); + + // ON にして注文する + await checkbox.check(); + await page.locator('.ec-blockBtn--action', { hasText: '注文する' }).click(); + await page.waitForLoadState('load'); + await expect(page).toHaveURL(/\/shopping\/complete/); + await expect(page.locator('.ec-pageHeader h1')).toContainText('ご注文完了'); + + // 再度購入フローへ進むと、お客様情報の直後に情報ボックスが表示される + await goToShopping(page); + const box = page.locator('.ec-orderPreferred'); + await expect(box).toBeVisible(); + await expect(box).toContainText('保存された設定があります'); + await expect(box).toContainText('配送方法: サンプル業者'); + await expect(box).toContainText('支払い方法: 銀行振込'); + await expect(box.locator('button', { hasText: 'この設定を使用する' })).toBeVisible(); + }); + + test('「この設定を使用する」で保存された設定が復元され合計が再計算される', async ({ page }) => { + await loginAsTestCustomer(page); + await goToShopping(page); + + // 前のテストで保存済みのため情報ボックスが表示される + const box = page.locator('.ec-orderPreferred'); + await expect(box).toBeVisible(); + + // 保存値(銀行振込)と異なる支払方法を選択しておく + await selectPayment(page, '郵便振替'); + + // 復元ボタンをクリックすると /shopping に戻り、成功メッセージが表示される + await box.locator('button', { hasText: 'この設定を使用する' }).click(); + await page.waitForLoadState('load'); + await expect(page).toHaveURL(/\/shopping$/); + await expect(page.locator('.ec-cartRole')).toContainText('保存された設定を適用しました'); + + // 支払方法・配送方法が保存値に復元される + await expect(page.getByRole('radio', { name: '銀行振込' })).toBeChecked(); + const selectedDelivery = page.locator('select[name="_shopping_order[Shippings][0][Delivery]"] option:checked'); + await expect(selectedDelivery).toHaveText('サンプル業者'); + + // 合計金額(送料込み)が表示されている + await expect(page.locator('.ec-totalBox__paymentTotal')).toContainText('¥'); + }); +}); diff --git a/html/template/default/assets/scss/project/_15.1.cart.scss b/html/template/default/assets/scss/project/_15.1.cart.scss index e62c67481d1..e169d7e90bf 100644 --- a/html/template/default/assets/scss/project/_15.1.cart.scss +++ b/html/template/default/assets/scss/project/_15.1.cart.scss @@ -38,6 +38,14 @@ Styleguide 15.1 display: inline-block; } } + & &__success{ + width: 100%; + text-align: center; + .ec-alert-success { + max-width: 80%; + display: inline-block; + } + } & &__totalText{ margin-bottom: 0; padding: 16px 0 6px; @@ -437,6 +445,34 @@ Styleguide 15.1.6 } } +.ec-alert-success { + width: 100%; + padding: 10px; + text-align: center; + background: #5cb85c; + margin-bottom: 20px; + + & &__inner { + display: inline-block; + + &__item { + display: flex; + flex-wrap: wrap; + justify-content: center; + } + } + + & &__text { + display: inline-block; + font-size: 16px; + font-weight: bold; + color: #fff; + position: relative; + flex: 1; + word-break: break-all; + } +} + diff --git a/src/Eccube/Controller/ShoppingController.php b/src/Eccube/Controller/ShoppingController.php index ff39665e563..66aa2c7377d 100644 --- a/src/Eccube/Controller/ShoppingController.php +++ b/src/Eccube/Controller/ShoppingController.php @@ -25,8 +25,10 @@ use Eccube\Form\Type\Shopping\CustomerAddressType; use Eccube\Form\Type\Shopping\OrderType; use Eccube\Repository\BaseInfoRepository; +use Eccube\Repository\DeliveryRepository; use Eccube\Repository\Master\PrefRepository; use Eccube\Repository\OrderRepository; +use Eccube\Repository\PaymentRepository; use Eccube\Repository\TradeLawRepository; use Eccube\Service\CartService; use Eccube\Service\MailService; @@ -43,6 +45,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; @@ -51,7 +54,7 @@ class ShoppingController extends AbstractShoppingController { - public function __construct(protected CartService $cartService, protected MailService $mailService, protected OrderRepository $orderRepository, protected OrderHelper $orderHelper, protected ContainerInterface $serviceContainer, protected TradeLawRepository $tradeLawRepository, protected RateLimiterFactoryInterface $shoppingConfirmIpLimiter, protected RateLimiterFactoryInterface $shoppingConfirmCustomerLimiter, protected RateLimiterFactoryInterface $shoppingCheckoutIpLimiter, protected RateLimiterFactoryInterface $shoppingCheckoutCustomerLimiter, protected BaseInfoRepository $baseInfoRepository, protected PrefRepository $prefRepository, private readonly PurchaseFlow $cartPurchaseFlow, private readonly AuthenticationUtils $authenticationUtils) + public function __construct(protected CartService $cartService, protected MailService $mailService, protected OrderRepository $orderRepository, protected OrderHelper $orderHelper, protected ContainerInterface $serviceContainer, protected TradeLawRepository $tradeLawRepository, protected RateLimiterFactoryInterface $shoppingConfirmIpLimiter, protected RateLimiterFactoryInterface $shoppingConfirmCustomerLimiter, protected RateLimiterFactoryInterface $shoppingCheckoutIpLimiter, protected RateLimiterFactoryInterface $shoppingCheckoutCustomerLimiter, protected BaseInfoRepository $baseInfoRepository, protected PrefRepository $prefRepository, protected DeliveryRepository $deliveryRepository, protected PaymentRepository $paymentRepository, private readonly PurchaseFlow $cartPurchaseFlow, private readonly AuthenticationUtils $authenticationUtils) { } @@ -117,11 +120,20 @@ public function index(): RedirectResponse|array $activeTradeLaws = $this->tradeLawRepository->findBy(['displayOrderScreen' => true], ['sortNo' => 'ASC']); $form = $this->createForm(OrderType::class, $Order); + // 保存された配送方法・支払い方法の検証(会員IDがない場合は何も表示しない). + $preferredInfo = $this->validatePreferredShippingPayment($Customer, $Order, '[注文手続][保存情報検証]'); + return [ 'form' => $form->createView(), 'Order' => $Order, 'activeTradeLaws' => $activeTradeLaws, 'Prefs' => $this->prefRepository->findAll(), + 'preferredPaymentId' => $preferredInfo['preferredPaymentId'], + 'preferredPaymentName' => $preferredInfo['preferredPaymentName'], + 'preferredDeliveryId' => $preferredInfo['preferredDeliveryId'], + 'preferredDeliveryName' => $preferredInfo['preferredDeliveryName'], + 'isMultipleShipping' => $preferredInfo['isMultipleShipping'], + 'preferredUnavailableReason' => $preferredInfo['preferredUnavailableReason'], ]; } @@ -205,14 +217,120 @@ public function redirectTo(Request $request): RedirectResponse|array log_info('[リダイレクト] フォームエラーのため, 注文手続き画面を表示します.', [$Order->getId()]); + // 保存された配送方法・支払い方法の検証(非会員の場合は検証しない). + $Customer = $this->getUser(); + if ($Customer instanceof Customer) { + $preferredInfo = $this->validatePreferredShippingPayment($Customer, $Order, '[リダイレクト][保存情報検証]'); + } else { + $preferredInfo = [ + 'preferredPaymentId' => null, + 'preferredPaymentName' => null, + 'preferredDeliveryId' => null, + 'preferredDeliveryName' => null, + 'isMultipleShipping' => $Order->getShippings()->count() > 1, + 'preferredUnavailableReason' => null, + ]; + } + return [ 'form' => $form->createView(), 'Order' => $Order, 'activeTradeLaws' => $activeTradeLaws, 'Prefs' => $this->prefRepository->findAll(), + 'preferredPaymentId' => $preferredInfo['preferredPaymentId'], + 'preferredPaymentName' => $preferredInfo['preferredPaymentName'], + 'preferredDeliveryId' => $preferredInfo['preferredDeliveryId'], + 'preferredDeliveryName' => $preferredInfo['preferredDeliveryName'], + 'isMultipleShipping' => $preferredInfo['isMultipleShipping'], + 'preferredUnavailableReason' => $preferredInfo['preferredUnavailableReason'], ]; } + /** + * 保存された配送方法・支払い方法を受注へ適用する. + * + * 会員が保存した優先配送方法・優先支払方法を購入処理中の受注へ適用し, 合計金額を再計算する. + * 複数配送先の場合や保存情報が利用できない場合は適用せず, 警告メッセージを表示して注文手続き画面へ戻す. + */ + #[Route(path: '/shopping/restore_preferred', name: 'shopping_restore_preferred', methods: ['POST'])] + public function restorePreferred(): RedirectResponse + { + // ログイン状態のチェック. + if ($this->orderHelper->isLoginRequired()) { + log_info('[保存設定復元] 未ログインもしくはRememberMeログインのため, ログイン画面に遷移します.'); + + return $this->redirectToRoute('shopping_login'); + } + + // 復元は会員のみ利用可能. + $Customer = $this->getUser(); + if (!$Customer instanceof Customer) { + log_info('[保存設定復元] 非会員のため復元できません.'); + + throw new AccessDeniedHttpException(); + } + + $this->isTokenValid(); + + // 受注の存在チェック. + $preOrderId = $this->cartService->getPreOrderId(); + $Order = $this->orderHelper->getPurchaseProcessingOrder($preOrderId); + if (!$Order) { + log_info('[保存設定復元] 購入処理中の受注が存在しません.', [$preOrderId]); + + return $this->redirectToRoute('shopping_error'); + } + + // 複数配送先の場合は復元しない. + if ($Order->getShippings()->count() > 1) { + log_info('[保存設定復元] 複数配送先のため復元をスキップします.', [$Order->getId()]); + $this->addWarning('front.shopping.preferred_multiple_shipping_notice'); + + return $this->redirectToRoute('shopping'); + } + + // 保存情報の検証. + $preferredInfo = $this->validatePreferredShippingPayment($Customer, $Order, '[保存設定復元][保存情報検証]'); + if ($preferredInfo['preferredUnavailableReason'] !== null) { + log_info('[保存設定復元] 保存情報が利用できないため復元をスキップします.', [$preferredInfo['preferredUnavailableReason']]); + $this->addWarning($preferredInfo['preferredUnavailableReason']); + + return $this->redirectToRoute('shopping'); + } + if ($preferredInfo['preferredPaymentId'] === null || $preferredInfo['preferredDeliveryId'] === null) { + log_info('[保存設定復元] 保存情報が存在しないため復元をスキップします.'); + + return $this->redirectToRoute('shopping'); + } + + // 保存値を受注へ適用する. + $Payment = $Customer->getPreferredPayment(); + $Delivery = $Customer->getPreferredDelivery(); + $Order->setPayment($Payment); + $Order->setPaymentMethod($Payment->getMethod()); + foreach ($Order->getShippings() as $Shipping) { + $Shipping->setDelivery($Delivery); + $Shipping->setShippingDeliveryName($Delivery->getName()); + // 配送方法を差し替えると, 設定済みのお届け時間は新しい配送業者に属さない可能性があるためクリアする. + $Shipping->setShippingDeliveryTime(); + $Shipping->setTimeId(null); + } + + // 合計金額の再計算. + log_info('[保存設定復元] 集計処理を開始します.', [$Order->getId()]); + $response = $this->executePurchaseFlow($Order); + $this->entityManager->flush(); + + if ($response) { + return $response; + } + + log_info('[保存設定復元] 保存された設定を適用しました.', [$Order->getId()]); + $this->addSuccess('front.shopping.preferred_restored_success'); + + return $this->redirectToRoute('shopping'); + } + /** * 注文確認画面を表示する. * @@ -308,6 +426,7 @@ public function confirm(Request $request): RedirectResponse|Response|array 'form' => $form->createView(), 'Order' => $Order, 'activeTradeLaws' => $activeTradeLaws, + 'isMultipleShipping' => $Order->getShippings()->count() > 1, ]; } @@ -478,6 +597,9 @@ public function checkout(Request $request): RedirectResponse|array|Response $this->mailService->sendOrderMail($Order); $this->entityManager->flush(); + // 配送方法・支払い方法の保存処理(会員かつ単一配送先のみ. 失敗しても注文は継続する). + $this->savePreferredShippingPayment($Order, $form); + log_info('[注文処理] 注文処理が完了しました. 購入完了画面へ遷移します.', [$Order->getId()]); return $this->redirectToRoute('shopping_complete'); @@ -817,6 +939,155 @@ public function error(Request $request): Response|array return []; } + /** + * 注文確定時に配送方法・支払い方法を会員へ保存する. + * + * 会員かつ単一配送先で, 保存チェックボックスがONの場合のみ保存する. + * 保存処理で例外が発生しても注文は継続し, 注文完了画面で保存失敗メッセージを表示する. + */ + private function savePreferredShippingPayment(Order $Order, FormInterface $form): void + { + $Customer = $this->getUser(); + if (!$Customer instanceof Customer) { + return; + } + + // 複数配送先の場合は保存しない. + if ($Order->getShippings()->count() > 1) { + log_info('[注文処理] 複数配送先のため, 配送方法・支払い方法の保存をスキップします.', [$Order->getId()]); + + return; + } + + if (!$form->has('save_preferred_shipping_payment') || !$form->get('save_preferred_shipping_payment')->getData()) { + return; + } + + try { + log_info('[注文処理] 配送方法・支払い方法の保存を開始します.', [$Order->getId()]); + + $Payment = $Order->getPayment(); + $Shipping = $Order->getShippings()->first(); + $Delivery = $Shipping ? $Shipping->getDelivery() : null; + + if (!$Payment || !$Delivery) { + log_warning('[注文処理] 支払い方法または配送方法が取得できないため, 保存をスキップします.', [$Order->getId()]); + + return; + } + + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + $this->entityManager->flush(); + + log_info('[注文処理] 配送方法・支払い方法の保存が完了しました.', [$Order->getId()]); + } catch (\Exception $e) { + // 保存失敗で注文は失敗させない. + log_error('[注文処理] 配送方法・支払い方法の保存に失敗しました.', [$e->getMessage()]); + $this->session->getFlashBag()->add('preferred_save_error', 'front.shopping.preferred_save_failed'); + } + } + + /** + * 会員の保存済み配送方法・支払い方法を検証する. + * + * 表示(index/redirectTo)と復元(restorePreferred)で共用する. + * すべての検証をパスした場合のみ名称・IDを設定し, 利用できない場合は理由のメッセージキーを返す. + * + * @return array{preferredPaymentId: int|null, preferredPaymentName: string|null, preferredDeliveryId: int|null, preferredDeliveryName: string|null, isMultipleShipping: bool, preferredUnavailableReason: string|null} + */ + private function validatePreferredShippingPayment(Customer $Customer, Order $Order, string $logPrefix = '[保存情報検証]'): array + { + $result = [ + 'preferredPaymentId' => null, + 'preferredPaymentName' => null, + 'preferredDeliveryId' => null, + 'preferredDeliveryName' => null, + 'isMultipleShipping' => $Order->getShippings()->count() > 1, + 'preferredUnavailableReason' => null, + ]; + + // 会員IDがない場合(非会員)は対象外. + if (!$Customer->getId()) { + return $result; + } + + $PreferredPayment = $Customer->getPreferredPayment(); + $PreferredDelivery = $Customer->getPreferredDelivery(); + + if (!$PreferredPayment && !$PreferredDelivery) { + log_info($logPrefix.' 保存情報が存在しません.', [$Customer->getId()]); + + return $result; + } + + // 片方のみ保存されている場合(参照先削除によるSET NULL等)は, 欠けている方を利用不可とする. + if (!$PreferredDelivery) { + $result['preferredUnavailableReason'] = 'front.shopping.preferred_delivery_unavailable'; + + return $result; + } + if (!$PreferredPayment) { + $result['preferredUnavailableReason'] = 'front.shopping.preferred_payment_unavailable'; + + return $result; + } + + // 非公開チェック. + if (!$PreferredPayment->isVisible()) { + log_info($logPrefix.' 保存された支払い方法が非公開です.', [$PreferredPayment->getId()]); + $result['preferredUnavailableReason'] = 'front.shopping.preferred_payment_unavailable'; + + return $result; + } + if (!$PreferredDelivery->isVisible()) { + log_info($logPrefix.' 保存された配送方法が非公開です.', [$PreferredDelivery->getId()]); + $result['preferredUnavailableReason'] = 'front.shopping.preferred_delivery_unavailable'; + + return $result; + } + + // 受注の販売種別に対応する配送方法に, 保存された配送方法が含まれるかチェック. + $availableDeliveries = $this->deliveryRepository->getDeliveries($Order->getSaleTypes()); + $matchedDelivery = null; + foreach ($availableDeliveries as $Delivery) { + if ($Delivery->getId() === $PreferredDelivery->getId()) { + $matchedDelivery = $Delivery; + break; + } + } + if (!$matchedDelivery) { + log_info($logPrefix.' 保存された配送方法が販売種別に対応していません.', [$PreferredDelivery->getId()]); + $result['preferredUnavailableReason'] = 'front.shopping.preferred_incompatible_combination'; + + return $result; + } + + // 保存された配送方法で利用可能な支払い方法に, 保存された支払い方法が含まれるかチェック. + $allowedPayments = $this->paymentRepository->findAllowedPayments([$matchedDelivery], true); + $allowedPaymentIds = []; + foreach ($allowedPayments as $Payment) { + if ($Payment->isVisible()) { + $allowedPaymentIds[] = $Payment->getId(); + } + } + if (!in_array($PreferredPayment->getId(), $allowedPaymentIds, true)) { + log_info($logPrefix.' 保存された配送方法と支払い方法の組み合わせが利用できません.', [$PreferredDelivery->getId(), $PreferredPayment->getId()]); + $result['preferredUnavailableReason'] = 'front.shopping.preferred_incompatible_combination'; + + return $result; + } + + // すべての検証をパスした場合のみ名称・IDを設定する. + $result['preferredPaymentId'] = $PreferredPayment->getId(); + $result['preferredPaymentName'] = $PreferredPayment->getMethod(); + $result['preferredDeliveryId'] = $PreferredDelivery->getId(); + $result['preferredDeliveryName'] = $PreferredDelivery->getName(); + log_info($logPrefix.' 検証をパスしました. 復元可能です.', [$PreferredDelivery->getId(), $PreferredPayment->getId()]); + + return $result; + } + /** * PaymentMethodをコンテナから取得する. */ diff --git a/src/Eccube/Entity/Customer.php b/src/Eccube/Entity/Customer.php index aef74e02d5f..69e8dbd1abe 100644 --- a/src/Eccube/Entity/Customer.php +++ b/src/Eccube/Entity/Customer.php @@ -189,6 +189,14 @@ class Customer extends AbstractEntity implements UserInterface, PasswordAuthenti #[ORM\JoinColumn(name: 'pref_id', referencedColumnName: 'id')] private ?Pref $Pref = null; + #[ORM\ManyToOne(targetEntity: Payment::class)] + #[ORM\JoinColumn(name: 'preferred_payment_id', referencedColumnName: 'id', onDelete: 'SET NULL')] + private ?Payment $PreferredPayment = null; + + #[ORM\ManyToOne(targetEntity: Delivery::class)] + #[ORM\JoinColumn(name: 'preferred_delivery_id', referencedColumnName: 'id', onDelete: 'SET NULL')] + private ?Delivery $PreferredDelivery = null; + /** * Constructor */ @@ -849,6 +857,42 @@ public function getPref(): ?Pref return $this->Pref; } + /** + * Set preferredPayment. + */ + public function setPreferredPayment(?Payment $preferredPayment = null): Customer + { + $this->PreferredPayment = $preferredPayment; + + return $this; + } + + /** + * Get preferredPayment. + */ + public function getPreferredPayment(): ?Payment + { + return $this->PreferredPayment; + } + + /** + * Set preferredDelivery. + */ + public function setPreferredDelivery(?Delivery $preferredDelivery = null): Customer + { + $this->PreferredDelivery = $preferredDelivery; + + return $this; + } + + /** + * Get preferredDelivery. + */ + public function getPreferredDelivery(): ?Delivery + { + return $this->PreferredDelivery; + } + /** * Set point */ diff --git a/src/Eccube/Form/Type/Shopping/OrderType.php b/src/Eccube/Form/Type/Shopping/OrderType.php index 0d9c21b579c..33b4c585403 100644 --- a/src/Eccube/Form/Type/Shopping/OrderType.php +++ b/src/Eccube/Form/Type/Shopping/OrderType.php @@ -14,6 +14,7 @@ namespace Eccube\Form\Type\Shopping; use Doctrine\Common\Collections\ArrayCollection; +use Eccube\Entity\Customer; use Eccube\Entity\Delivery; use Eccube\Entity\Order; use Eccube\Entity\Payment; @@ -24,6 +25,7 @@ use Eccube\Request\Context; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\IntegerType; @@ -54,6 +56,17 @@ public function __construct(protected OrderRepository $orderRepository, protecte #[\Override] public function buildForm(FormBuilderInterface $builder, array $options): void { + // 配送方法・支払い方法の保存チェックボックス(会員のみ). + // 注文確認画面からの送信(checkout)時にも値を受け取るため, skip_add_formの判定より前に定義する. + // 表示は単一配送先のみ(テンプレート側で制御), 複数配送先時の送信値は注文確定処理で無視される. + if ($this->requestContext->getCurrentUser() instanceof Customer) { + $builder->add('save_preferred_shipping_payment', CheckboxType::class, [ + 'label' => 'front.shopping.save_preferred_shipping_payment', + 'required' => false, + 'mapped' => false, + ]); + } + // ShoppingController::checkoutから呼ばれる場合は, フォーム項目の定義をスキップする. if ($options['skip_add_form']) { return; diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 3807b8b6380..85529d491ed 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -395,6 +395,18 @@ front.shopping.different_payment_methods: Sorry, your order includes items with front.shopping.payment_method_unselected: Please select the payment method. front.shopping.not_available_payment_method: The payment method you have selected is not available. front.shopping.payment_method_not_fount: Sorry, you have no payment options. If you have selected multiple delivery methods, you can only select the same payment option for them all. +front.shopping.save_preferred_shipping_payment: Save this shipping and payment method +front.shopping.save_preferred_shipping_payment_help: Use with one click for next order +front.shopping.preferred_info_title: Saved settings available +front.shopping.preferred_delivery_label: Shipping method +front.shopping.preferred_payment_label: Payment method +front.shopping.use_preferred_button: Use this setting +front.shopping.preferred_restored_success: Saved settings have been applied +front.shopping.preferred_payment_unavailable: Your saved payment method is currently unavailable +front.shopping.preferred_delivery_unavailable: Your saved shipping method is currently unavailable +front.shopping.preferred_multiple_shipping_notice: Saved settings cannot be restored when multiple shipping addresses are specified +front.shopping.preferred_incompatible_combination: The combination of your saved shipping and payment methods is currently unavailable +front.shopping.preferred_save_failed: Your order has been completed, but saving shipping and payment methods failed front.under_maintenance: The site is currently under maintenance. front.under_debug_mode: The site is currently under debug mode. diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 96445e787c0..b1800eaa68c 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -394,6 +394,18 @@ front.shopping.different_payment_methods: 支払い方法が異なる商品が front.shopping.payment_method_unselected: お支払い方法を選択してください。 front.shopping.not_available_payment_method: 選択したお支払い方法はご利用できません。 front.shopping.payment_method_not_fount: 選択できるお支払い方法がありません。配送方法が異なる場合は同じ配送方法を選んでください。 +front.shopping.save_preferred_shipping_payment: この配送方法と支払い方法を保存する +front.shopping.save_preferred_shipping_payment_help: 次回の注文時にワンクリックで設定できます +front.shopping.preferred_info_title: 保存された設定があります +front.shopping.preferred_delivery_label: 配送方法 +front.shopping.preferred_payment_label: 支払い方法 +front.shopping.use_preferred_button: この設定を使用する +front.shopping.preferred_restored_success: 保存された設定を適用しました +front.shopping.preferred_payment_unavailable: 保存された支払い方法が現在利用できません +front.shopping.preferred_delivery_unavailable: 保存された配送方法が現在利用できません +front.shopping.preferred_multiple_shipping_notice: 複数配送先指定時は保存設定の復元はできません +front.shopping.preferred_incompatible_combination: 保存された配送方法と支払い方法の組み合わせが現在利用できません +front.shopping.preferred_save_failed: 注文は完了しましたが、配送方法・支払い方法の保存に失敗しました front.under_maintenance: メンテナンスモードが有効になっています。 front.under_debug_mode: デバッグモードが有効になっています。 diff --git a/src/Eccube/Resource/template/default/Shopping/alert.twig b/src/Eccube/Resource/template/default/Shopping/alert.twig index e72f59e34b6..ddd7ba918b9 100644 --- a/src/Eccube/Resource/template/default/Shopping/alert.twig +++ b/src/Eccube/Resource/template/default/Shopping/alert.twig @@ -16,6 +16,19 @@ {% endfor %} {% if error_only == false %} +{% for success in app.session.flashbag.get('eccube.front.success') %} +
+
+
+
+
+ {{ success|trans|nl2br }} +
+
+
+
+
+{% endfor %} {% for error in app.session.flashbag.get('eccube.front.warning') %}
diff --git a/src/Eccube/Resource/template/default/Shopping/complete.twig b/src/Eccube/Resource/template/default/Shopping/complete.twig index 5d5bd3dbccd..b5b98e8e3ce 100644 --- a/src/Eccube/Resource/template/default/Shopping/complete.twig +++ b/src/Eccube/Resource/template/default/Shopping/complete.twig @@ -74,6 +74,13 @@ file that was distributed with this source code. {% if Order.complete_message is not empty %} {{ Order.complete_message|raw|purify }} {% endif %} + {# 配送方法・支払い方法の保存失敗メッセージ #} + {% for message in app.session.flashbag.get('preferred_save_error') %} +
+
+
{{ message|trans }}
+
+ {% endfor %}
diff --git a/src/Eccube/Resource/template/default/Shopping/confirm.twig b/src/Eccube/Resource/template/default/Shopping/confirm.twig index 8f608521bfe..4abbba128c8 100644 --- a/src/Eccube/Resource/template/default/Shopping/confirm.twig +++ b/src/Eccube/Resource/template/default/Shopping/confirm.twig @@ -221,6 +221,16 @@ file that was distributed with this source code.
{% endif %} + {# 配送方法・支払い方法の保存チェックボックス(会員かつ単一配送先のみ) #} + {% if form.save_preferred_shipping_payment is defined and (isMultipleShipping is not defined or not isMultipleShipping) %} +
+
+ {{ form_widget(form.save_preferred_shipping_payment) }} + {{ form_label(form.save_preferred_shipping_payment) }} +

{{ 'front.shopping.save_preferred_shipping_payment_help'|trans }}

+
+
+ {% endif %}
{{ 'front.shopping.back_to_order'|trans }} diff --git a/src/Eccube/Resource/template/default/Shopping/index.twig b/src/Eccube/Resource/template/default/Shopping/index.twig index ff14c13a0f2..fa4a40678b6 100644 --- a/src/Eccube/Resource/template/default/Shopping/index.twig +++ b/src/Eccube/Resource/template/default/Shopping/index.twig @@ -319,6 +319,31 @@ file that was distributed with this source code. {% endif %}
+ {# 保存された配送方法・支払い方法の情報ボックス(会員のみ) #} + {% if is_granted('ROLE_USER') %} + {% if preferredPaymentName is defined and preferredPaymentName and preferredDeliveryName is defined and preferredDeliveryName %} +
+
+

{{ 'front.shopping.preferred_info_title'|trans }}

+
+

{{ 'front.shopping.preferred_delivery_label'|trans }}: {{ preferredDeliveryName }}

+

{{ 'front.shopping.preferred_payment_label'|trans }}: {{ preferredPaymentName }}

+ {% if isMultipleShipping is defined and isMultipleShipping %} +

{{ 'front.shopping.preferred_multiple_shipping_notice'|trans }}

+ {% else %} + + + {% endif %} +
+ {% elseif preferredUnavailableReason is defined and preferredUnavailableReason %} +
+
+
+
{{ preferredUnavailableReason|trans }}
+
+
+ {% endif %} + {% endif %}

{{ 'front.shopping.delivery_info'|trans }}

diff --git a/tests/Eccube/Tests/Controller/ShoppingControllerSavePreferredShippingPaymentTest.php b/tests/Eccube/Tests/Controller/ShoppingControllerSavePreferredShippingPaymentTest.php new file mode 100644 index 00000000000..92801f0a1cc --- /dev/null +++ b/tests/Eccube/Tests/Controller/ShoppingControllerSavePreferredShippingPaymentTest.php @@ -0,0 +1,187 @@ +resetLoggerFacade(); + LoggerFacade::init($this->createStub(ContainerInterface::class), $this->createStub(Logger::class)); + + $this->entityManager = $this->createMock(EntityManagerInterface::class); + $this->flashBag = $this->createMock(FlashBagInterface::class); + + $session = $this->createMock(FlashBagAwareSessionInterface::class); + $session->method('getFlashBag')->willReturn($this->flashBag); + + // getUser() は会員を返すようにオーバーライドし, それ以外の実装は維持する. + $this->controller = $this->getMockBuilder(ShoppingController::class) + ->disableOriginalConstructor() + ->onlyMethods(['getUser']) + ->getMock(); + $this->controller->method('getUser')->willReturn(new Customer()); + + $reflection = new \ReflectionClass($this->controller); + $reflection->getProperty('entityManager')->setValue($this->controller, $this->entityManager); + $reflection->getProperty('session')->setValue($this->controller, $session); + } + + protected function tearDown(): void + { + $this->resetLoggerFacade(); + + parent::tearDown(); + } + + /** + * 保存処理で例外が発生しても再スローされず, 注文完了画面用のフラッシュが積まれること. + */ + public function testSwallowsExceptionAndAddsFlash(): void + { + $form = $this->createCheckedForm(); + $Order = $this->createSingleShippingOrder(); + + // flush で例外を発生させる. + $this->entityManager->method('flush')->willThrowException(new \RuntimeException('DB error')); + + // 注文完了画面用のフラッシュが積まれること. + $this->flashBag->expects($this->once()) + ->method('add') + ->with('preferred_save_error', 'front.shopping.preferred_save_failed'); + + // 例外が再スローされない(注文処理が継続できる)こと. + $this->callSavePreferredShippingPayment($Order, $form); + } + + /** + * 複数配送先の場合は flush を行わず, フラッシュも積まないこと(保存スキップ). + */ + public function testSkipsSaveWhenMultipleShipping(): void + { + $form = $this->createCheckedForm(); + + $Order = $this->createMock(Order::class); + $Order->method('getId')->willReturn(1); + $Order->method('getShippings')->willReturn(new ArrayCollection([ + $this->createStub(Shipping::class), + $this->createStub(Shipping::class), + ])); + + $this->entityManager->expects($this->never())->method('flush'); + $this->flashBag->expects($this->never())->method('add'); + + $this->callSavePreferredShippingPayment($Order, $form); + } + + /** + * チェックボックスが OFF の場合は flush を行わないこと. + */ + public function testSkipsSaveWhenCheckboxOff(): void + { + $childForm = $this->createMock(FormInterface::class); + $childForm->method('getData')->willReturn(false); + $form = $this->createMock(FormInterface::class); + $form->method('has')->with('save_preferred_shipping_payment')->willReturn(true); + $form->method('get')->with('save_preferred_shipping_payment')->willReturn($childForm); + + $Order = $this->createSingleShippingOrder(); + + $this->entityManager->expects($this->never())->method('flush'); + $this->flashBag->expects($this->never())->method('add'); + + $this->callSavePreferredShippingPayment($Order, $form); + } + + /** + * save_preferred_shipping_payment が ON のフォームモックを生成する. + */ + private function createCheckedForm(): FormInterface|MockObject + { + $childForm = $this->createMock(FormInterface::class); + $childForm->method('getData')->willReturn(true); + + $form = $this->createMock(FormInterface::class); + $form->method('has')->with('save_preferred_shipping_payment')->willReturn(true); + $form->method('get')->with('save_preferred_shipping_payment')->willReturn($childForm); + + return $form; + } + + /** + * 単一配送先で Payment / Delivery を持つ受注モックを生成する. + */ + private function createSingleShippingOrder(): Order|MockObject + { + $Shipping = $this->createMock(Shipping::class); + $Shipping->method('getDelivery')->willReturn($this->createStub(Delivery::class)); + + $Order = $this->createMock(Order::class); + $Order->method('getId')->willReturn(1); + $Order->method('getShippings')->willReturn(new ArrayCollection([$Shipping])); + $Order->method('getPayment')->willReturn($this->createStub(Payment::class)); + + return $Order; + } + + private function resetLoggerFacade(): void + { + $ref = new \ReflectionClass(LoggerFacade::class); + $ref->getProperty('instance')->setValue(null, null); + } + + private function callSavePreferredShippingPayment(Order $Order, FormInterface $form): void + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('savePreferredShippingPayment'); + $method->invoke($this->controller, $Order, $form); + } +} diff --git a/tests/Eccube/Tests/Controller/ShoppingControllerValidatePreferredShippingPaymentTest.php b/tests/Eccube/Tests/Controller/ShoppingControllerValidatePreferredShippingPaymentTest.php new file mode 100644 index 00000000000..e95fad54d2d --- /dev/null +++ b/tests/Eccube/Tests/Controller/ShoppingControllerValidatePreferredShippingPaymentTest.php @@ -0,0 +1,474 @@ +resetLoggerFacade(); + LoggerFacade::init($this->createStub(ContainerInterface::class), $this->createStub(Logger::class)); + + $this->deliveryRepository = $this->createMock(DeliveryRepository::class); + $this->paymentRepository = $this->createMock(PaymentRepository::class); + + $this->controller = $this->getMockBuilder(ShoppingController::class) + ->disableOriginalConstructor() + ->onlyMethods([]) + ->getMock(); + + $reflection = new \ReflectionClass($this->controller); + $reflection->getProperty('deliveryRepository')->setValue($this->controller, $this->deliveryRepository); + $reflection->getProperty('paymentRepository')->setValue($this->controller, $this->paymentRepository); + } + + protected function tearDown(): void + { + $this->resetLoggerFacade(); + + parent::tearDown(); + } + + /** + * 会員IDが存在しない場合, すべての戻り値がnullまたはfalseであること. + */ + public function testWithoutCustomerId(): void + { + $Customer = $this->createCustomerWithId(null); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertNull($result['preferredPaymentId']); + $this->assertNull($result['preferredPaymentName']); + $this->assertNull($result['preferredDeliveryId']); + $this->assertNull($result['preferredDeliveryName']); + $this->assertFalse($result['isMultipleShipping']); + $this->assertNull($result['preferredUnavailableReason']); + } + + /** + * 保存情報が存在しない場合, preferredUnavailableReasonがnullであること. + */ + public function testWithoutPreferredInfo(): void + { + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment(); + $Customer->setPreferredDelivery(); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertNull($result['preferredUnavailableReason']); + } + + /** + * 保存済み Payment が非公開の場合, 支払い方法が利用不可の理由が返ること. + */ + public function testWithInvisiblePayment(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(false); + + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(true); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertSame('front.shopping.preferred_payment_unavailable', $result['preferredUnavailableReason']); + } + + /** + * 保存済み Delivery が非公開の場合, 配送方法が利用不可の理由が返ること. + */ + public function testWithInvisibleDelivery(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(true); + + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(false); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertSame('front.shopping.preferred_delivery_unavailable', $result['preferredUnavailableReason']); + } + + /** + * Payment のみ保存されている場合(参照先削除等), 配送方法が利用不可の理由が返ること. + */ + public function testWithPaymentOnly(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(true); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery(); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertSame('front.shopping.preferred_delivery_unavailable', $result['preferredUnavailableReason']); + } + + /** + * Delivery のみ保存されている場合(参照先削除等), 支払い方法が利用不可の理由が返ること. + */ + public function testWithDeliveryOnly(): void + { + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(true); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment(); + $Customer->setPreferredDelivery($Delivery); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertSame('front.shopping.preferred_payment_unavailable', $result['preferredUnavailableReason']); + } + + /** + * 利用可能な配送方法が存在しない場合, 組み合わせ不可の理由が返ること. + */ + public function testWithoutAvailableDeliveries(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(true); + $Payment->method('getId')->willReturn(1); + + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(true); + $Delivery->method('getId')->willReturn(1); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + $Order->method('getSaleTypes')->willReturn([]); + + $this->deliveryRepository->method('getDeliveries')->willReturn([]); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertSame('front.shopping.preferred_incompatible_combination', $result['preferredUnavailableReason']); + } + + /** + * 保存された配送方法が利用可能な配送方法に含まれていない場合, 組み合わせ不可の理由が返ること. + */ + public function testWithIncompatibleDelivery(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(true); + $Payment->method('getId')->willReturn(1); + + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(true); + $Delivery->method('getId')->willReturn(1); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + $Order->method('getSaleTypes')->willReturn([]); + + // 利用可能な配送方法に保存された配送方法(ID:1)が含まれない. + $availableDelivery = $this->createMock(Delivery::class); + $availableDelivery->method('getId')->willReturn(2); + $this->deliveryRepository->method('getDeliveries')->willReturn([$availableDelivery]); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertSame('front.shopping.preferred_incompatible_combination', $result['preferredUnavailableReason']); + } + + /** + * 保存された支払い方法が利用可能な支払い方法に含まれていない場合, 組み合わせ不可の理由が返ること. + */ + public function testWithIncompatiblePayment(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(true); + $Payment->method('getId')->willReturn(1); + + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(true); + $Delivery->method('getId')->willReturn(1); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + $Order->method('getSaleTypes')->willReturn([]); + + $availableDelivery = $this->createMock(Delivery::class); + $availableDelivery->method('getId')->willReturn(1); + $this->deliveryRepository->method('getDeliveries')->willReturn([$availableDelivery]); + + // 利用可能な支払い方法に保存された支払い方法(ID:1)が含まれない. + $availablePayment = $this->createMock(Payment::class); + $availablePayment->method('getId')->willReturn(2); + $availablePayment->method('isVisible')->willReturn(true); + $this->paymentRepository->method('findAllowedPayments')->willReturn([$availablePayment]); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertSame('front.shopping.preferred_incompatible_combination', $result['preferredUnavailableReason']); + } + + /** + * すべての検証をパスした場合, ID・名称が設定され理由がnullであること. + */ + public function testSuccess(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(true); + $Payment->method('getId')->willReturn(1); + $Payment->method('getMethod')->willReturn('クレジットカード'); + + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(true); + $Delivery->method('getId')->willReturn(1); + $Delivery->method('getName')->willReturn('宅配便'); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + $Order->method('getSaleTypes')->willReturn([]); + + $availableDelivery = $this->createMock(Delivery::class); + $availableDelivery->method('getId')->willReturn(1); + $this->deliveryRepository->method('getDeliveries')->willReturn([$availableDelivery]); + + $availablePayment = $this->createMock(Payment::class); + $availablePayment->method('getId')->willReturn(1); + $availablePayment->method('isVisible')->willReturn(true); + $this->paymentRepository->method('findAllowedPayments')->willReturn([$availablePayment]); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertSame(1, $result['preferredPaymentId']); + $this->assertSame('クレジットカード', $result['preferredPaymentName']); + $this->assertSame(1, $result['preferredDeliveryId']); + $this->assertSame('宅配便', $result['preferredDeliveryName']); + $this->assertNull($result['preferredUnavailableReason']); + } + + /** + * 複数配送先の場合, isMultipleShippingがtrueであること. + */ + public function testMultipleShipping(): void + { + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment(); + $Customer->setPreferredDelivery(); + + $shipping1 = $this->createStub(Shipping::class); + $shipping2 = $this->createStub(Shipping::class); + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection([$shipping1, $shipping2])); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertTrue($result['isMultipleShipping']); + } + + /** + * 単一配送先の場合, isMultipleShippingがfalseであること. + */ + public function testSingleShipping(): void + { + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment(); + $Customer->setPreferredDelivery(); + + $shipping1 = $this->createStub(Shipping::class); + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection([$shipping1])); + + $result = $this->callValidatePreferredShippingPayment($Customer, $Order); + + $this->assertFalse($result['isMultipleShipping']); + } + + /** + * DeliveryRepository::getDeliveries が受注の販売種別で呼び出されること. + */ + public function testCallsDeliveryRepositoryWithSaleTypes(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(true); + $Payment->method('getId')->willReturn(1); + + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(true); + $Delivery->method('getId')->willReturn(1); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + + $saleTypes = ['dummy_sale_type']; + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + $Order->method('getSaleTypes')->willReturn($saleTypes); + + $availableDelivery = $this->createMock(Delivery::class); + $availableDelivery->method('getId')->willReturn(1); + $this->deliveryRepository->expects($this->once()) + ->method('getDeliveries') + ->with($saleTypes) + ->willReturn([$availableDelivery]); + + $availablePayment = $this->createMock(Payment::class); + $availablePayment->method('getId')->willReturn(1); + $availablePayment->method('isVisible')->willReturn(true); + $this->paymentRepository->method('findAllowedPayments')->willReturn([$availablePayment]); + + $this->callValidatePreferredShippingPayment($Customer, $Order); + } + + /** + * PaymentRepository::findAllowedPayments が一致した配送方法で呼び出されること. + */ + public function testCallsPaymentRepositoryWithMatchedDelivery(): void + { + $Payment = $this->createMock(Payment::class); + $Payment->method('isVisible')->willReturn(true); + $Payment->method('getId')->willReturn(1); + + $Delivery = $this->createMock(Delivery::class); + $Delivery->method('isVisible')->willReturn(true); + $Delivery->method('getId')->willReturn(1); + + $Customer = $this->createCustomerWithId(1); + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + + $Order = $this->createMock(Order::class); + $Order->method('getShippings')->willReturn(new ArrayCollection()); + $Order->method('getSaleTypes')->willReturn([]); + + $availableDelivery = $this->createMock(Delivery::class); + $availableDelivery->method('getId')->willReturn(1); + $this->deliveryRepository->method('getDeliveries')->willReturn([$availableDelivery]); + + $availablePayment = $this->createMock(Payment::class); + $availablePayment->method('getId')->willReturn(1); + $availablePayment->method('isVisible')->willReturn(true); + $this->paymentRepository->expects($this->once()) + ->method('findAllowedPayments') + ->with([$availableDelivery], true) + ->willReturn([$availablePayment]); + + $this->callValidatePreferredShippingPayment($Customer, $Order); + } + + /** + * LoggerFacade のシングルトンをリセットする. + */ + private function resetLoggerFacade(): void + { + $ref = new \ReflectionClass(LoggerFacade::class); + $ref->getProperty('instance')->setValue(null, null); + } + + /** + * 指定IDを持つ Customer を生成する(IDはIDENTITY採番のためリフレクションで設定する). + */ + private function createCustomerWithId(?int $id): Customer + { + $Customer = new Customer(); + $ref = new \ReflectionProperty(Customer::class, 'id'); + $ref->setValue($Customer, $id); + + return $Customer; + } + + /** + * private メソッド validatePreferredShippingPayment を呼び出す. + * + * @return array{preferredPaymentId: int|null, preferredPaymentName: string|null, preferredDeliveryId: int|null, preferredDeliveryName: string|null, isMultipleShipping: bool, preferredUnavailableReason: string|null} + */ + private function callValidatePreferredShippingPayment(Customer $Customer, Order $Order, string $logPrefix = '[保存情報検証]'): array + { + $reflection = new \ReflectionClass($this->controller); + $method = $reflection->getMethod('validatePreferredShippingPayment'); + + return $method->invoke($this->controller, $Customer, $Order, $logPrefix); + } +} diff --git a/tests/Eccube/Tests/Entity/CustomerTest.php b/tests/Eccube/Tests/Entity/CustomerTest.php new file mode 100644 index 00000000000..2822fddf8c2 --- /dev/null +++ b/tests/Eccube/Tests/Entity/CustomerTest.php @@ -0,0 +1,108 @@ +assertNotInstanceOf(Payment::class, $Customer->getPreferredPayment()); + $this->assertNotInstanceOf(Delivery::class, $Customer->getPreferredDelivery()); + + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + $this->assertSame($Payment, $Customer->getPreferredPayment()); + $this->assertSame($Delivery, $Customer->getPreferredDelivery()); + + $Customer->setPreferredPayment(); + $Customer->setPreferredDelivery(); + $this->assertNull($Customer->getPreferredPayment()); + $this->assertNull($Customer->getPreferredDelivery()); + } + + /** + * 優先支払方法・優先配送方法が DB に保存される. + */ + public function testPreferredPaymentAndDeliveryPersistence(): void + { + $Customer = $this->createCustomer(); + $Delivery = static::getContainer()->get(Generator::class)->createDelivery(); + $Payment = $this->createPayment($Delivery, 'テスト支払い方法'); + + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + $this->entityManager->flush(); + $this->entityManager->clear(); + + /** @var Customer $found */ + $found = $this->entityManager->getRepository(Customer::class)->find($Customer->getId()); + $this->assertSame($Payment->getId(), $found->getPreferredPayment()->getId()); + $this->assertSame($Delivery->getId(), $found->getPreferredDelivery()->getId()); + } + + /** + * 参照先の Payment / Delivery を削除すると, 外部キーの ON DELETE SET NULL により未設定へ戻る. + */ + public function testPreferredResetToNullWhenReferenceDeleted(): void + { + // SQLite は既定で外部キー制約(ON DELETE SET NULL)を強制しないため, この検証は対象外とする. + // 実 DB (PostgreSQL / MySQL) では FK により未設定へ戻ることを担保する. + if ($this->entityManager->getConnection()->getDriver()->getDatabasePlatform()->getName() === 'sqlite') { + $this->markTestSkipped('SQLite は外部キーの ON DELETE SET NULL を既定で強制しないためスキップします.'); + } + + $Customer = $this->createCustomer(); + $Delivery = static::getContainer()->get(Generator::class)->createDelivery(); + $Payment = $this->createPayment($Delivery, 'テスト支払い方法'); + + $Customer->setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + $this->entityManager->flush(); + + // 参照先を SQL で削除する(関連レコードを先に削除). + $conn = $this->entityManager->getConnection(); + $conn->executeStatement('DELETE FROM dtb_payment_option WHERE delivery_id = :id', ['id' => $Delivery->getId()]); + $conn->executeStatement('DELETE FROM dtb_delivery_fee WHERE delivery_id = :id', ['id' => $Delivery->getId()]); + $conn->executeStatement('DELETE FROM dtb_delivery_time WHERE delivery_id = :id', ['id' => $Delivery->getId()]); + $conn->executeStatement('DELETE FROM dtb_payment WHERE id = :id', ['id' => $Payment->getId()]); + $conn->executeStatement('DELETE FROM dtb_delivery WHERE id = :id', ['id' => $Delivery->getId()]); + + $this->entityManager->clear(); + + /** @var Customer $found */ + $found = $this->entityManager->getRepository(Customer::class)->find($Customer->getId()); + $this->assertNotInstanceOf(Payment::class, $found->getPreferredPayment()); + $this->assertNotInstanceOf(Delivery::class, $found->getPreferredDelivery()); + } +} diff --git a/tests/Eccube/Tests/Web/ShoppingControllerPreferredShippingPaymentTest.php b/tests/Eccube/Tests/Web/ShoppingControllerPreferredShippingPaymentTest.php new file mode 100644 index 00000000000..18e134516e4 --- /dev/null +++ b/tests/Eccube/Tests/Web/ShoppingControllerPreferredShippingPaymentTest.php @@ -0,0 +1,473 @@ +setPreferredPayment($Payment); + $Customer->setPreferredDelivery($Delivery); + $this->entityManager->flush(); + } + + private function findPayment(int $id): Payment + { + $Payment = $this->entityManager->getRepository(Payment::class)->find($id); + $this->assertInstanceOf(Payment::class, $Payment); + + return $Payment; + } + + private function findDelivery(int $id): Delivery + { + $Delivery = $this->entityManager->getRepository(Delivery::class)->find($id); + $this->assertInstanceOf(Delivery::class, $Delivery); + + return $Delivery; + } + + private function findProcessingOrder(Customer $Customer): Order + { + $Order = $this->entityManager->getRepository(Order::class)->findOneBy(['Customer' => $Customer]); + $this->assertInstanceOf(Order::class, $Order); + + return $Order; + } + + /** + * 非会員購入フローで注文手続き画面まで進める. + * + * @return array + */ + private function createNonmemberFormData(): array + { + $faker = $this->getFaker(); + $email = $faker->safeEmail; + $form = $this->createShippingFormData(); + $form['email'] = [ + 'first' => $email, + 'second' => $email, + ]; + + return $form; + } + + private function scenarioConfirmAsGuest(): Crawler + { + $this->scenarioCartIn(); + $this->scenarioInput($this->createNonmemberFormData()); + $this->client->followRedirect(); + + return $this->scenarioConfirm(); + } + + /** + * 数量2の商品を2つのお届け先に分割し, 複数配送先の受注を作成する. + */ + private function createMultiShippingOrder(Customer $Customer): Order + { + $Customer->addCustomerAddress($this->createCustomerAddress($Customer)); + + $this->scenarioCartIn($Customer); + $this->scenarioCartIn($Customer); + $this->scenarioConfirm($Customer); + + $crawler = $this->client->request(Request::METHOD_GET, $this->generateUrl('shopping_shipping_multiple')); + $shippings = $crawler->filter('#form_shipping_multiple_0_shipping_0_customer_address > option')->each( + fn ($node, $i) => [ + 'customer_address' => $node->attr('value'), + 'quantity' => 1, + ] + ); + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('shopping_shipping_multiple'), + ['form' => [ + 'shipping_multiple' => [0 => ['shipping' => $shippings]], + '_token' => 'dummy', + ]] + ); + + $Order = $this->findProcessingOrder($Customer); + $this->assertCount(2, $Order->getShippings()); + + return $Order; + } + + /** + * 保存情報がある会員は注文手続き画面に情報ボックスと復元ボタンが表示される. + */ + public function testIndexWithPreferredShowsInfoBox(): void + { + $Customer = $this->createCustomer(); + $Payment = $this->findPayment(3); + $Delivery = $this->findDelivery(1); + $this->setPreferred($Customer, $Payment, $Delivery); + + $this->scenarioCartIn($Customer); + $crawler = $this->scenarioConfirm($Customer); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $preferredBox = $crawler->filter('.ec-orderPreferred'); + $this->assertCount(1, $preferredBox); + $this->assertStringContainsString('保存された設定があります', (string) $preferredBox->text()); + $this->assertStringContainsString($Delivery->getName(), (string) $preferredBox->text()); + $this->assertStringContainsString($Payment->getMethod(), (string) $preferredBox->text()); + $this->assertStringContainsString('この設定を使用する', (string) $preferredBox->text()); + } + + /** + * 保存情報がない会員には情報ボックスが表示されない. + */ + public function testIndexWithoutPreferredHidesInfoBox(): void + { + $Customer = $this->createCustomer(); + + $this->scenarioCartIn($Customer); + $crawler = $this->scenarioConfirm($Customer); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $this->assertCount(0, $crawler->filter('.ec-orderPreferred')); + } + + /** + * ゲストユーザーには情報ボックスが表示されない. + */ + public function testIndexWithGuestHidesInfoBox(): void + { + $crawler = $this->scenarioConfirmAsGuest(); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $this->assertCount(0, $crawler->filter('.ec-orderPreferred')); + } + + /** + * 保存情報が利用できない場合, 注文手続き画面の初期表示で警告が表示され復元ボタンは出ない. + */ + public function testIndexWithUnavailablePreferredShowsWarningWithoutButton(): void + { + $Customer = $this->createCustomer(); + $Payment = $this->findPayment(3); + $this->setPreferred($Customer, $Payment, $this->findDelivery(1)); + + // 保存済みの支払い方法を非公開にする. + $Payment->setVisible(false); + $this->entityManager->flush(); + + $this->scenarioCartIn($Customer); + $crawler = $this->scenarioConfirm($Customer); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $preferredBox = $crawler->filter('.ec-orderPreferred'); + $this->assertCount(1, $preferredBox); + $this->assertStringContainsString('保存された支払い方法が現在利用できません', (string) $preferredBox->text()); + // 復元ボタン・成功表示は出ない. + $this->assertStringNotContainsString('この設定を使用する', (string) $preferredBox->text()); + $this->assertStringNotContainsString('保存された設定があります', (string) $preferredBox->text()); + } + + /** + * 復元で保存された配送方法・支払い方法が受注へ適用され, 成功メッセージが表示される. + */ + public function testRestorePreferred(): void + { + $Customer = $this->createCustomer(); + $Payment = $this->findPayment(3); + $Delivery = $this->findDelivery(1); + $this->setPreferred($Customer, $Payment, $Delivery); + + $this->scenarioCartIn($Customer); + $this->scenarioConfirm($Customer); + + // 保存値と異なる支払い方法へ変更しておく. + $Order = $this->findProcessingOrder($Customer); + $OtherPayment = $this->findPayment(4); + $Order->setPayment($OtherPayment); + $Order->setPaymentMethod($OtherPayment->getMethod()); + // 復元前にお届け時間を設定しておく(配送方法の差し替えでクリアされるべき). + $ShippingBefore = $Order->getShippings()->first(); + $this->assertNotFalse($ShippingBefore); + $ShippingBefore->setTimeId(99); + $ShippingBefore->setShippingDeliveryTime('サンプルお届け時間'); + $this->entityManager->flush(); + + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('shopping_restore_preferred'), + ['_token' => '_dummy'] + ); + + $this->assertTrue($this->client->getResponse()->isRedirect($this->generateUrl('shopping'))); + + $this->entityManager->refresh($Order); + $this->assertSame($Payment->getId(), $Order->getPayment()->getId()); + $this->assertSame($Payment->getMethod(), $Order->getPaymentMethod()); + $Shipping = $Order->getShippings()->first(); + $this->assertNotFalse($Shipping); + $this->entityManager->refresh($Shipping); + $this->assertSame($Delivery->getId(), $Shipping->getDelivery()->getId()); + $this->assertSame($Delivery->getName(), $Shipping->getShippingDeliveryName()); + // 配送方法の差し替えに伴い, お届け時間はクリアされる. + $this->assertNull($Shipping->getTimeId()); + $this->assertNull($Shipping->getShippingDeliveryTime()); + + // リダイレクト後の画面に成功メッセージが表示される. + $crawler = $this->client->followRedirect(); + $this->assertStringContainsString('保存された設定を適用しました', $crawler->html()); + } + + /** + * 保存された支払い方法が非公開の場合は復元されず, 警告メッセージが表示される. + */ + public function testRestorePreferredWithInvisiblePayment(): void + { + $Customer = $this->createCustomer(); + $Payment = $this->findPayment(3); + $Delivery = $this->findDelivery(1); + $this->setPreferred($Customer, $Payment, $Delivery); + + $Payment->setVisible(false); + $this->entityManager->flush(); + + $this->scenarioCartIn($Customer); + $this->scenarioConfirm($Customer); + + $Order = $this->findProcessingOrder($Customer); + $beforePaymentId = $Order->getPayment()->getId(); + + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('shopping_restore_preferred'), + ['_token' => '_dummy'] + ); + + $this->assertTrue($this->client->getResponse()->isRedirect($this->generateUrl('shopping'))); + + $this->entityManager->refresh($Order); + $this->assertSame($beforePaymentId, $Order->getPayment()->getId()); + + $crawler = $this->client->followRedirect(); + $this->assertStringContainsString('保存された支払い方法が現在利用できません', $crawler->html()); + } + + /** + * ゲストユーザーは復元エンドポイントへアクセスできない(403). + */ + public function testRestorePreferredForbiddenForGuest(): void + { + $this->scenarioConfirmAsGuest(); + + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('shopping_restore_preferred'), + ['_token' => '_dummy'] + ); + + $this->assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent()); + } + + /** + * 複数配送先の場合, 情報ボックスと注記は表示されるが復元ボタンは表示されない. + */ + public function testIndexWithMultipleShippingShowsInfoBoxWithoutButton(): void + { + $Customer = $this->createCustomer(); + $this->setPreferred($Customer, $this->findPayment(3), $this->findDelivery(1)); + $this->createMultiShippingOrder($Customer); + + $crawler = $this->scenarioConfirm($Customer); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $preferredBox = $crawler->filter('.ec-orderPreferred'); + $this->assertCount(1, $preferredBox); + $this->assertStringContainsString('保存された設定があります', (string) $preferredBox->text()); + $this->assertStringContainsString('複数配送先指定時は保存設定の復元はできません', (string) $preferredBox->text()); + $this->assertStringNotContainsString('この設定を使用する', (string) $preferredBox->text()); + } + + /** + * 複数配送先の場合, 復元処理はスキップされ注記メッセージが表示される. + */ + public function testRestorePreferredWithMultipleShippingSkips(): void + { + $Customer = $this->createCustomer(); + $this->setPreferred($Customer, $this->findPayment(3), $this->findDelivery(1)); + $Order = $this->createMultiShippingOrder($Customer); + + $this->scenarioConfirm($Customer); + $this->entityManager->refresh($Order); + $beforePaymentId = $Order->getPayment()->getId(); + + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('shopping_restore_preferred'), + ['_token' => '_dummy'] + ); + + $this->assertTrue($this->client->getResponse()->isRedirect($this->generateUrl('shopping'))); + + $this->entityManager->refresh($Order); + $this->assertSame($beforePaymentId, $Order->getPayment()->getId()); + + $crawler = $this->client->followRedirect(); + $this->assertStringContainsString('複数配送先指定時は保存設定の復元はできません', $crawler->html()); + } + + /** + * 複数配送先の場合, 確認画面にチェックボックスは表示されず, + * チェックボックス値を細工して送信しても注文は完了し保存はスキップされる. + */ + public function testCheckoutWithMultipleShippingSkipsSave(): void + { + $Customer = $this->createCustomer(); + $this->createMultiShippingOrder($Customer); + $this->scenarioConfirm($Customer); + + // 確認画面: チェックボックスが表示されない. + $crawler = $this->scenarioComplete($Customer, $this->generateUrl('shopping_confirm'), [ + ['Delivery' => 1, 'DeliveryTime' => 1], + ['Delivery' => 1, 'DeliveryTime' => 1], + ]); + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $this->assertCount(0, $crawler->filter('input[name="_shopping_order[save_preferred_shipping_payment]"]')); + + // チェックボックス値を細工して送信しても注文は完了する. + $this->loginTo($Customer); + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('shopping_checkout'), + [ + '_shopping_order' => [ + '_token' => 'dummy', + 'save_preferred_shipping_payment' => '1', + ], + ] + ); + + $this->assertTrue($this->client->getResponse()->isRedirect($this->generateUrl('shopping_complete'))); + + // 保存はスキップされる. + $this->entityManager->clear(); + /** @var Customer $found */ + $found = $this->entityManager->getRepository(Customer::class)->find($Customer->getId()); + $this->assertNotInstanceOf(Payment::class, $found->getPreferredPayment()); + $this->assertNotInstanceOf(Delivery::class, $found->getPreferredDelivery()); + } + + /** + * 会員かつ単一配送先の場合, 注文確認画面に保存チェックボックスが表示される. + */ + public function testConfirmShowsSaveCheckboxForMember(): void + { + $Customer = $this->createCustomer(); + + $this->scenarioCartIn($Customer); + $this->scenarioConfirm($Customer); + $crawler = $this->scenarioComplete($Customer, $this->generateUrl('shopping_confirm')); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $checkbox = $crawler->filter('input[name="_shopping_order[save_preferred_shipping_payment]"]'); + $this->assertCount(1, $checkbox); + $this->assertStringContainsString('この配送方法と支払い方法を保存する', (string) $crawler->html()); + } + + /** + * ゲストユーザーには保存チェックボックスが表示されない. + */ + public function testConfirmHidesSaveCheckboxForGuest(): void + { + $this->scenarioConfirmAsGuest(); + $crawler = $this->scenarioComplete(null, $this->generateUrl('shopping_confirm')); + + $this->assertTrue($this->client->getResponse()->isSuccessful()); + $this->assertCount(0, $crawler->filter('input[name="_shopping_order[save_preferred_shipping_payment]"]')); + } + + /** + * チェックボックスONで注文確定すると配送方法・支払い方法が保存され, 既存値は上書きされる. + */ + public function testCheckoutWithSaveOnPersistsPreferred(): void + { + $Customer = $this->createCustomer(); + // 既存の保存値(上書きされることを確認するため, 確定時と異なる支払い方法を設定). + $this->setPreferred($Customer, $this->findPayment(4), $this->findDelivery(1)); + + $this->scenarioCartIn($Customer); + $this->scenarioConfirm($Customer); + $this->scenarioComplete($Customer, $this->generateUrl('shopping_confirm')); + + $this->loginTo($Customer); + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('shopping_checkout'), + [ + '_shopping_order' => [ + '_token' => 'dummy', + 'save_preferred_shipping_payment' => '1', + ], + ] + ); + + $this->assertTrue($this->client->getResponse()->isRedirect($this->generateUrl('shopping_complete'))); + + $this->entityManager->clear(); + /** @var Customer $found */ + $found = $this->entityManager->getRepository(Customer::class)->find($Customer->getId()); + $this->assertInstanceOf(Payment::class, $found->getPreferredPayment()); + $this->assertInstanceOf(Delivery::class, $found->getPreferredDelivery()); + // scenarioComplete は Payment=3, Delivery=1 で注文する. + $this->assertSame(3, $found->getPreferredPayment()->getId()); + $this->assertSame(1, $found->getPreferredDelivery()->getId()); + } + + /** + * チェックボックスOFFで注文確定しても保存されない. + */ + public function testCheckoutWithoutSaveDoesNotPersistPreferred(): void + { + $Customer = $this->createCustomer(); + + $this->scenarioCartIn($Customer); + $this->scenarioConfirm($Customer); + $this->scenarioComplete($Customer, $this->generateUrl('shopping_confirm')); + $this->scenarioCheckout($Customer); + + $this->assertTrue($this->client->getResponse()->isRedirect($this->generateUrl('shopping_complete'))); + + $this->entityManager->clear(); + /** @var Customer $found */ + $found = $this->entityManager->getRepository(Customer::class)->find($Customer->getId()); + $this->assertNotInstanceOf(Payment::class, $found->getPreferredPayment()); + $this->assertNotInstanceOf(Delivery::class, $found->getPreferredDelivery()); + } +}