diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index d5e043792e2..4739a2402e8 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -26,12 +26,14 @@ jobs:
- admin-contents
- admin-basicinfo
- admin-system
+ - admin-refund-request
- front-top
- front-product
- front-customer
- front-other
- front-order
- front-mypage
+ - front-refund-request
- front-invoice
include:
- db: pgsql
diff --git a/app/DoctrineMigrations/Version20260615000000.php b/app/DoctrineMigrations/Version20260615000000.php
new file mode 100644
index 00000000000..cce2dbddc6a
--- /dev/null
+++ b/app/DoctrineMigrations/Version20260615000000.php
@@ -0,0 +1,94 @@
+ $lang === 'en' ? 'New' : '新規申請',
+ RefundRequestStatus::PROCESSING => $lang === 'en' ? 'Processing' : '処理中',
+ RefundRequestStatus::ACCEPTED => $lang === 'en' ? 'Accepted' : '承認済',
+ RefundRequestStatus::DECLINED => $lang === 'en' ? 'Declined' : '却下',
+ RefundRequestStatus::INFO_REQUESTED => $lang === 'en' ? 'Information Requested' : '追加情報依頼',
+ ];
+ $sortNo = 0;
+ foreach ($statuses as $id => $name) {
+ $exists = $this->connection->fetchOne(
+ 'SELECT COUNT(*) FROM mtb_refund_request_status WHERE id = :id',
+ ['id' => $id]
+ );
+ if ($exists == 0) {
+ $this->addSql(
+ "INSERT INTO mtb_refund_request_status (id, name, sort_no, discriminator_type) VALUES (?, ?, ?, 'refundrequeststatus')",
+ [$id, $name, $sortNo]
+ );
+ }
+ $sortNo++;
+ }
+
+ // dtb_mail_template(管理者向け返品申請通知メール)
+ $mailExists = $this->connection->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_mail_template WHERE id = 10'
+ );
+ if ($mailExists == 0) {
+ $name = $lang === 'en' ? 'Refund Request Notification' : '返品申請通知メール';
+ $subject = $lang === 'en' ? 'A refund request has been submitted' : '返品申請を受け付けました';
+ $this->addSql(
+ 'INSERT INTO dtb_mail_template (id, creator_id, name, file_name, mail_subject, deletable, create_date, update_date, discriminator_type) '
+ ."VALUES (10, null, ?, 'Mail/refund_request_notify.twig', ?, false, '2017-03-07 10:14:52', '2017-03-07 10:14:52', 'mailtemplate')",
+ [$name, $subject]
+ );
+ }
+
+ // dtb_csv(商品CSV出力項目 refund_allowed)
+ $csvExists = $this->connection->fetchOne(
+ 'SELECT COUNT(*) FROM dtb_csv WHERE id = 215'
+ );
+ if ($csvExists == 0) {
+ $dispName = $lang === 'en' ? 'Refund Allowed Flag' : '返品許可フラグ';
+ $this->addSql(
+ 'INSERT INTO dtb_csv (id, csv_type_id, creator_id, entity_name, field_name, reference_field_name, disp_name, sort_no, enabled, create_date, update_date, discriminator_type) '
+ ."VALUES (215, 1, null, 'Eccube\\Entity\\Product', 'refund_allowed', null, ?, 33, false, '2017-03-07 10:14:00', '2017-03-07 10:14:00', 'csv')",
+ [$dispName]
+ );
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql("DELETE FROM dtb_csv WHERE id = 215 AND entity_name = 'Eccube\\\\Entity\\\\Product' AND field_name = 'refund_allowed'");
+ $this->addSql("DELETE FROM dtb_mail_template WHERE id = 10 AND file_name = 'Mail/refund_request_notify.twig'");
+ $this->addSql("DELETE FROM mtb_refund_request_status WHERE id IN (1, 2, 3, 4, 5) AND discriminator_type = 'refundrequeststatus'");
+ }
+}
diff --git a/app/config/eccube/packages/eccube.yaml b/app/config/eccube/packages/eccube.yaml
index 0b4a38f45d5..de3e3f1223a 100644
--- a/app/config/eccube/packages/eccube.yaml
+++ b/app/config/eccube/packages/eccube.yaml
@@ -52,6 +52,10 @@ parameters:
eccube_twig_block_templates: []
eccube_save_image_dir: '%kernel.project_dir%/html/upload/save_image'
eccube_temp_image_dir: '%kernel.project_dir%/html/upload/temp_image'
+ # 返品申請のエビデンスファイル保存先。公開ドキュメントルート外(var/配下)に置き、配信はコントローラ経由(所有者チェック付き)に限定する
+ eccube_save_refund_request_file_dir: '%kernel.project_dir%/var/refund_request'
+ # 返品申請の一時アップロード先(確認画面遷移中の保留領域)。complete 時に %eccube_save_refund_request_file_dir% へ移動する
+ eccube_temp_refund_request_file_dir: '%kernel.project_dir%/var/refund_request/tmp'
eccube_csv_size: 5 # post_max_size, upload_max_filesize に任せればよい?
eccube_csv_temp_realdir: '%kernel.cache_dir%/%kernel.environment%/eccube' # upload_tmp_dir に任せればよい?
eccube_csv_split_lines: 100
@@ -126,6 +130,7 @@ parameters:
eccube_forgot_mail_template_id: 6 #パスワードリセット
eccube_reset_complete_mail_template_id: 7 #パスワードリマインダー
eccube_shipping_notify_mail_template_id: 8 #出荷通知メール
+ eccube_refund_request_notify_mail_template_id: 10 #返品申請通知メール(管理者向け)
# メールアドレスをRFC準拠でチェックするか true: チェックする、false: チェックしない
eccube_rfc_email_check: false
eccube_email_len: 254
diff --git a/app/config/eccube/packages/eccube_nav.yaml b/app/config/eccube/packages/eccube_nav.yaml
index e83df1b6ee3..54bf654f69d 100644
--- a/app/config/eccube/packages/eccube_nav.yaml
+++ b/app/config/eccube/packages/eccube_nav.yaml
@@ -44,6 +44,9 @@ parameters:
shipping_csv_import:
name: admin.order.shipping_csv_upload
url: admin_shipping_csv_import
+ refund_request:
+ name: admin.order.refund_request.list_title
+ url: admin_refund_request
customer:
name: admin.customer.customer_management
icon: fa-users
diff --git a/app/config/eccube/packages/refund_request_state_machine.php b/app/config/eccube/packages/refund_request_state_machine.php
new file mode 100644
index 00000000000..9133fd5f07c
--- /dev/null
+++ b/app/config/eccube/packages/refund_request_state_machine.php
@@ -0,0 +1,59 @@
+loadFromExtension('framework', [
+ 'workflows' => [
+ 'refund_request' => [
+ 'type' => 'state_machine',
+ 'marking_store' => [
+ 'type' => 'method',
+ ],
+ 'supports' => [
+ RefundRequestStateMachineContext::class,
+ ],
+ 'initial_marking' => (string) Status::NEW,
+ 'places' => [
+ (string) Status::NEW,
+ (string) Status::PROCESSING,
+ (string) Status::ACCEPTED,
+ (string) Status::DECLINED,
+ (string) Status::INFO_REQUESTED,
+ ],
+ 'transitions' => [
+ 'start_processing' => [
+ 'from' => (string) Status::NEW,
+ 'to' => (string) Status::PROCESSING,
+ ],
+ 'accept' => [
+ 'from' => (string) Status::PROCESSING,
+ 'to' => (string) Status::ACCEPTED,
+ ],
+ 'decline' => [
+ 'from' => (string) Status::PROCESSING,
+ 'to' => (string) Status::DECLINED,
+ ],
+ 'request_info' => [
+ 'from' => (string) Status::PROCESSING,
+ 'to' => (string) Status::INFO_REQUESTED,
+ ],
+ 'resume_processing' => [
+ 'from' => (string) Status::INFO_REQUESTED,
+ 'to' => (string) Status::PROCESSING,
+ ],
+ ],
+ ],
+ ],
+]);
diff --git a/e2e/fixtures/evidence.png b/e2e/fixtures/evidence.png
new file mode 100644
index 00000000000..63acde25432
Binary files /dev/null and b/e2e/fixtures/evidence.png differ
diff --git a/e2e/setup-fixtures.php b/e2e/setup-fixtures.php
index a5a4b4ed4af..f98e308f0d1 100644
--- a/e2e/setup-fixtures.php
+++ b/e2e/setup-fixtures.php
@@ -15,6 +15,8 @@
use Eccube\Entity\Customer;
use Eccube\Entity\Master\CustomerStatus;
use Eccube\Entity\Master\OrderStatus;
+use Eccube\Entity\Master\RefundRequestStatus;
+use Eccube\Entity\RefundRequest;
use Eccube\Kernel;
use Faker\Factory as Faker;
@@ -212,5 +214,81 @@
echo " Multi-cart test product already exists\n";
}
+// --- 返品申請テスト用(発送済み注文を持つテスト会員) ---
+$refundTestEmail = 'refund-test@test.test';
+$refundCustomer = $entityManager->getRepository(Customer::class)->findOneBy(['email' => $refundTestEmail]);
+if (!$refundCustomer) {
+ $refundCustomer = $generator->createCustomer($refundTestEmail);
+ $Status = $entityManager->getRepository(CustomerStatus::class)->find(CustomerStatus::ACTIVE);
+ $refundCustomer->setStatus($Status);
+ $entityManager->flush($refundCustomer);
+ echo " Created refund test customer: $refundTestEmail\n";
+} else {
+ echo " Refund test customer already exists: $refundTestEmail\n";
+}
+
+// 既存の場合でもE2Eの前提として「発送済み注文」が必ず1件以上あることを保証する
+$DeliveredStatus = $entityManager->getRepository(OrderStatus::class)->find(OrderStatus::DELIVERED);
+$deliveredOrderCount = (int) $entityManager->getRepository(\Eccube\Entity\Order::class)->createQueryBuilder('o')
+ ->select('COUNT(o.id)')
+ ->where('o.Customer = :customer')
+ ->andWhere('o.OrderStatus = :status')
+ ->setParameter('customer', $refundCustomer)
+ ->setParameter('status', $DeliveredStatus)
+ ->getQuery()
+ ->getSingleScalarResult();
+
+if ($deliveredOrderCount === 0) {
+ $Delivery = $entityManager->getRepository(\Eccube\Entity\Delivery::class)->findAll()[0];
+ $Product = $entityManager->getRepository(\Eccube\Entity\Product::class)->find(2);
+ $Order = $generator->createOrder($refundCustomer, $Product->getProductClasses()->toArray(), $Delivery);
+ $Order->setOrderStatus($DeliveredStatus);
+ $Order->setOrderDate(new \DateTime());
+ foreach ($Order->getShippings() as $Shipping) {
+ $Shipping->setShippingDate(new \DateTime());
+ }
+ $entityManager->flush();
+ echo " Created delivered order for refund test customer\n";
+} else {
+ echo " Delivered order already exists for refund test customer\n";
+}
+
+// 管理画面E2Eテストの前提として、返品申請を少なくとも1件保証する
+$existingRefundRequestCount = (int) $entityManager->getRepository(RefundRequest::class)->createQueryBuilder('rr')
+ ->select('COUNT(rr.id)')
+ ->where('rr.Customer = :customer')
+ ->setParameter('customer', $refundCustomer)
+ ->getQuery()
+ ->getSingleScalarResult();
+
+if ($existingRefundRequestCount === 0) {
+ $DeliveredOrder = $entityManager->getRepository(\Eccube\Entity\Order::class)->findOneBy([
+ 'Customer' => $refundCustomer,
+ 'OrderStatus' => $DeliveredStatus,
+ ]);
+ if ($DeliveredOrder !== null) {
+ $OrderItem = null;
+ foreach ($DeliveredOrder->getProductOrderItems() as $item) {
+ $OrderItem = $item;
+ break;
+ }
+ if ($OrderItem !== null) {
+ $NewStatus = $entityManager->getRepository(RefundRequestStatus::class)->find(RefundRequestStatus::NEW);
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($DeliveredOrder);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($refundCustomer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('E2Eテスト用の返品申請(fixture)');
+ $RefundRequest->setRefundRequestStatus($NewStatus);
+ $entityManager->persist($RefundRequest);
+ $entityManager->flush();
+ echo " Created refund request fixture (NEW)\n";
+ }
+ }
+} else {
+ echo " Refund request fixture already exists\n";
+}
+
echo "Fixtures setup complete.\n";
$kernel->shutdown();
diff --git a/e2e/tests/admin-refund-request.spec.ts b/e2e/tests/admin-refund-request.spec.ts
new file mode 100644
index 00000000000..6c3f72c20f2
--- /dev/null
+++ b/e2e/tests/admin-refund-request.spec.ts
@@ -0,0 +1,214 @@
+import { test, expect } from '@playwright/test';
+
+const adminRoute = process.env.ECCUBE_ADMIN_ROUTE || 'admin';
+
+test.describe('Admin Refund Request', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test('返品申請一覧が表示される', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ // ページタイトル
+ await expect(page.locator('.c-pageTitle')).toContainText('返品申請管理');
+ });
+
+ test('返品申請の検索ができる', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ // 空検索(全件表示)
+ await page.locator('button.btn-ec-conversion', { hasText: '検索' }).click();
+ await page.waitForLoadState('load');
+
+ await expect(page.locator('.c-pageTitle')).toContainText('返品申請管理');
+ });
+
+ test('返品申請詳細が表示される', async ({ page }) => {
+ // まず一覧を表示して検索
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ // 検索して結果を取得
+ await page.locator('button.btn-ec-conversion', { hasText: '検索' }).click();
+ await page.waitForLoadState('load');
+
+ // 前提: fixture により返品申請が少なくとも1件存在する
+ const editBtn = page.locator('a.btn-ec-actionIcon').first();
+ await expect(editBtn).toBeVisible();
+ await editBtn.click();
+ await page.waitForLoadState('load');
+
+ // 詳細ページの項目確認(申請内容カードに各項目が並ぶ)
+ await expect(page.locator('.c-pageTitle')).toContainText('返品申請詳細');
+ const detailCard = page.locator('.card-body').first();
+ await expect(detailCard).toContainText('申請ID');
+ await expect(detailCard).toContainText('ステータス');
+ await expect(detailCard).toContainText('注文番号');
+ await expect(detailCard).toContainText('返品理由');
+ });
+
+ test('管理者メモを保存できる', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ await page.locator('button.btn-ec-conversion', { hasText: '検索' }).click();
+ await page.waitForLoadState('load');
+
+ const editBtn = page.locator('a.btn-ec-actionIcon').first();
+ await expect(editBtn).toBeVisible();
+ await editBtn.click();
+ await page.waitForLoadState('load');
+
+ // 管理者メモを入力して保存
+ const memo = `E2Eテストメモ_${Date.now()}`;
+ await page.locator('#admin_refund_request_edit_admin_note').fill(memo);
+
+ await page.locator('button.btn-ec-conversion', { hasText: '保存' }).click();
+ await page.waitForLoadState('load');
+
+ // 保存成功メッセージ
+ await expect(page.locator('.alert-success').first()).toContainText('保存しました');
+
+ // メモが保存されている
+ const savedMemo = await page.locator('#admin_refund_request_edit_admin_note').inputValue();
+ expect(savedMemo).toBe(memo);
+ });
+
+ test('ステータス変更(処理開始)ができる', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ await page.locator('button.btn-ec-conversion', { hasText: '検索' }).click();
+ await page.waitForLoadState('load');
+
+ // ステータスが「新規申請」の申請の編集ボタンをクリック
+ const rows = page.locator('table.table tbody tr');
+ const rowCount = await rows.count();
+ let foundNew = false;
+
+ for (let i = 0; i < rowCount; i++) {
+ const statusText = await rows.nth(i).locator('td').nth(4).innerText();
+ if (statusText.includes('新規申請')) {
+ await rows.nth(i).locator('a.btn-ec-actionIcon').click();
+ foundNew = true;
+ break;
+ }
+ }
+
+ // 前提: fixture により「新規申請」状態の返品申請が必ず存在する
+ expect(foundNew, '「新規申請」ステータスの返品申請が一覧に存在しません').toBe(true);
+ await page.waitForLoadState('load');
+
+ // 遷移選択肢が表示される
+ const transitionSelect = page.locator('#admin_refund_request_edit_transition');
+ await expect(transitionSelect).toBeVisible();
+
+ // 処理開始を選択して保存
+ await transitionSelect.selectOption('start_processing');
+ await page.locator('button.btn-ec-conversion', { hasText: '保存' }).click();
+ await page.waitForLoadState('load');
+
+ await expect(page.locator('.alert-success').first()).toContainText('保存しました');
+
+ // ステータスが「処理中」になっている
+ await expect(page.locator('.card-body').first()).toContainText('処理中');
+ });
+
+ test('承認済・却下ではステータス変更欄が表示されない', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ await page.locator('button.btn-ec-conversion', { hasText: '検索' }).click();
+ await page.waitForLoadState('load');
+
+ // ステータスが「処理中」の申請を承認する(前のテストで「処理中」に遷移済み)
+ const rows = page.locator('table.table tbody tr');
+ const rowCount = await rows.count();
+ let foundProcessing = false;
+
+ for (let i = 0; i < rowCount; i++) {
+ const statusText = await rows.nth(i).locator('td').nth(4).innerText();
+ if (statusText.includes('処理中')) {
+ await rows.nth(i).locator('a.btn-ec-actionIcon').click();
+ foundProcessing = true;
+ break;
+ }
+ }
+
+ expect(foundProcessing, '「処理中」ステータスの返品申請が一覧に存在しません').toBe(true);
+ await page.waitForLoadState('load');
+
+ // 承認を選択して保存
+ const transitionSelect = page.locator('#admin_refund_request_edit_transition');
+ await transitionSelect.selectOption('accept');
+ await page.locator('button.btn-ec-conversion', { hasText: '保存' }).click();
+ await page.waitForLoadState('load');
+
+ await expect(page.locator('.alert-success').first()).toContainText('保存しました');
+ await expect(page.locator('.card-body').first()).toContainText('承認済');
+
+ // 承認済ではステータス変更欄が非表示
+ await expect(page.locator('#admin_refund_request_edit_transition')).not.toBeVisible();
+ });
+
+ test('CSVエクスポートボタンが表示される', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ await page.locator('button.btn-ec-conversion', { hasText: '検索' }).click();
+ await page.waitForLoadState('load');
+
+ // CSV エクスポートボタンが存在する
+ const csvBtn = page.locator('a', { hasText: 'CSVエクスポート' });
+ await expect(csvBtn).toBeVisible();
+ });
+
+ test('一覧の件数セレクタが表示され、切替で URL が変わる', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ const select = page.locator('select.form-select').first();
+ await expect(select).toBeVisible();
+
+ // option に 10件/20件/.../100件 が並ぶ
+ await expect(select.locator('option', { hasText: '10件' })).toHaveCount(1);
+ await expect(select.locator('option', { hasText: '100件' })).toHaveCount(1);
+
+ // 20件に切り替え(onchange="location = this.value;")
+ await select.selectOption({ label: '20件' });
+ await page.waitForLoadState('load');
+
+ // URL に page_count=20 が反映されている
+ await expect(page).toHaveURL(/page_count=20/);
+ // 切替後も「20件」が selected
+ await expect(page.locator('select.form-select option[selected]').first()).toHaveText('20件');
+ });
+
+ test('検索結果初期表示で結果ゼロにならない(status IN (NULL) 回帰防止)', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ // 初期表示でデータがある環境では「検索結果に合致するデータが見つかりませんでした」が出ないこと
+ await expect(page.locator('body')).not.toContainText('検索条件に合致するデータが見つかりませんでした');
+ });
+
+ test('一覧から返品申請一覧へ戻れる', async ({ page }) => {
+ await page.goto(`/${adminRoute}/order/refund_request`);
+ await page.waitForLoadState('load');
+
+ await page.locator('button.btn-ec-conversion', { hasText: '検索' }).click();
+ await page.waitForLoadState('load');
+
+ const editBtn = page.locator('a.btn-ec-actionIcon').first();
+ await expect(editBtn).toBeVisible();
+ await editBtn.click();
+ await page.waitForLoadState('load');
+
+ // 一覧へ戻る
+ await page.locator('a.c-baseLink', { hasText: '返品申請一覧へ戻る' }).click();
+ await page.waitForLoadState('load');
+
+ await expect(page.locator('.c-pageTitle')).toContainText('返品申請管理');
+ });
+});
diff --git a/e2e/tests/front-refund-request.spec.ts b/e2e/tests/front-refund-request.spec.ts
new file mode 100644
index 00000000000..d258e56c131
--- /dev/null
+++ b/e2e/tests/front-refund-request.spec.ts
@@ -0,0 +1,230 @@
+import { test, expect, Page } from '@playwright/test';
+import * as path from 'path';
+
+const refundTestEmail = 'refund-test@test.test';
+const refundTestPassword = 'password';
+
+async function loginAsRefundTestCustomer(page: Page) {
+ await page.goto('/mypage/login');
+ await page.waitForLoadState('load');
+ await page.locator('input[name="login_email"]').fill(refundTestEmail);
+ await page.locator('input[name="login_pass"]').fill(refundTestPassword);
+ await page.getByRole('button', { name: 'ログイン' }).click();
+ await page.waitForLoadState('load');
+}
+
+/**
+ * 発送済み注文の注文番号と商品明細IDを取得する.
+ * マイページ購入履歴詳細から返品申請リンクのURLを解析して取得.
+ */
+async function getDeliveredOrderInfo(page: Page): Promise<{ orderNo: string; orderItemId: string }> {
+ await page.goto('/mypage/');
+ await page.waitForLoadState('load');
+
+ // 詳細を見るリンクをクリック
+ const detailLink = page.locator('p.ec-historyListHeader__action a').first();
+ await expect(detailLink).toBeVisible({ timeout: 10_000 });
+ await detailLink.click();
+ await page.waitForLoadState('load');
+
+ // 返品申請リンクからURLパラメータを取得
+ const refundLink = page.locator('a.ec-inlineBtn--action', { hasText: '返品申請' }).first();
+ await expect(refundLink).toBeVisible({ timeout: 10_000 });
+
+ const href = await refundLink.getAttribute('href');
+ if (!href) throw new Error('返品申請リンクが見つかりません');
+
+ // /mypage/refund_request/{order_no}/{order_item_id} からパラメータ抽出
+ const match = href.match(/\/mypage\/refund_request\/([^/]+)\/(\d+)/);
+ if (!match) throw new Error(`返品申請リンクのURLパースに失敗: ${href}`);
+
+ return { orderNo: match[1], orderItemId: match[2] };
+}
+
+test.describe('Front Refund Request', () => {
+ test.describe.configure({ mode: 'serial' });
+
+ test('発送済み注文の詳細に返品申請リンクが表示される', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+
+ await page.goto('/mypage/');
+ await page.waitForLoadState('load');
+
+ // 詳細を見る
+ await page.locator('p.ec-historyListHeader__action a').first().click();
+ await page.waitForLoadState('load');
+
+ // 返品申請リンクが存在する
+ await expect(page.locator('a.ec-inlineBtn--action', { hasText: '返品申請' }).first()).toBeVisible();
+ // 返品申請履歴リンクが存在する
+ await expect(page.locator('a.ec-inlineBtn', { hasText: '返品申請履歴' }).first()).toBeVisible();
+ });
+
+ test('返品申請フォームが表示される', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+ const { orderNo, orderItemId } = await getDeliveredOrderInfo(page);
+
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}`);
+ await page.waitForLoadState('load');
+
+ // タイトル
+ await expect(page.locator('div.ec-pageHeader h1')).toContainText('返品申請');
+
+ // フォームフィールドが存在する
+ await expect(page.locator('#refund_request_quantity')).toBeVisible();
+ await expect(page.locator('#refund_request_reason')).toBeVisible();
+ await expect(page.locator('#refund_request_files')).toBeVisible();
+
+ // 確認画面へボタン
+ await expect(page.locator('button.ec-blockBtn--action', { hasText: '確認画面へ' })).toBeVisible();
+ });
+
+ test('返品申請(ファイルなし):入力→確認→完了', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+ const { orderNo, orderItemId } = await getDeliveredOrderInfo(page);
+
+ // 入力画面
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}`);
+ await page.waitForLoadState('load');
+
+ await page.locator('#refund_request_quantity').fill('1');
+ await page.locator('#refund_request_reason').fill('商品に不具合がありました。E2Eテスト用の返品申請です。');
+
+ // 確認画面へ
+ await page.locator('button.ec-blockBtn--action', { hasText: '確認画面へ' }).click();
+ await page.waitForLoadState('load');
+
+ // 確認画面の表示確認
+ await expect(page.locator('div.ec-pageHeader h1')).toContainText('返品申請内容確認');
+ await expect(page.locator('.ec-borderedDefs')).toContainText('1');
+ await expect(page.locator('.ec-borderedDefs')).toContainText('商品に不具合がありました');
+
+ // 申請する
+ await page.locator('button.ec-blockBtn--action', { hasText: '申請する' }).click();
+ await page.waitForLoadState('load');
+
+ // 完了画面
+ await expect(page.locator('div.ec-pageHeader h1')).toContainText('返品申請完了');
+ await expect(page.locator('.ec-reportHeading h2')).toContainText('返品申請を受け付けました');
+ });
+
+ test('返品申請履歴が表示される', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+ const { orderNo, orderItemId } = await getDeliveredOrderInfo(page);
+
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}/history`);
+ await page.waitForLoadState('load');
+
+ await expect(page.locator('div.ec-pageHeader h1')).toContainText('返品申請履歴');
+ // 先ほど申請した内容が表示される
+ await expect(page.locator('main')).toContainText('新規申請');
+ });
+
+ test('数量0でバリデーションエラー', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+ const { orderNo, orderItemId } = await getDeliveredOrderInfo(page);
+
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}`);
+ await page.waitForLoadState('load');
+
+ await page.locator('#refund_request_quantity').fill('0');
+ await page.locator('#refund_request_reason').fill('テスト理由');
+
+ await page.locator('button.ec-blockBtn--action', { hasText: '確認画面へ' }).click();
+ await page.waitForLoadState('load');
+
+ // 入力画面に留まる(確認画面に遷移しない)
+ await expect(page.locator('div.ec-pageHeader h1')).toContainText('返品申請');
+ await expect(page.locator('div.ec-pageHeader h1')).not.toContainText('確認');
+ });
+
+ test('確認画面にはサマリー表示のみで編集可能な入力欄が残らない(H-2 回帰防止)', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+ const { orderNo, orderItemId } = await getDeliveredOrderInfo(page);
+
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}`);
+ await page.waitForLoadState('load');
+
+ await page.locator('#refund_request_quantity').fill('1');
+ await page.locator('#refund_request_reason').fill('H-2 回帰防止:確認画面に編集欄が残らないこと');
+ await page.locator('button.ec-blockBtn--action', { hasText: '確認画面へ' }).click();
+ await page.waitForLoadState('load');
+
+ // 確認画面に遷移している
+ await expect(page).toHaveURL(/\/confirm$/);
+
+ // 確認画面のフォーム内に編集可能な input/textarea/file が残っていないこと
+ const confirmForm = page.locator('form[action$="/confirm"]');
+ await expect(confirmForm.locator('input[name="refund_request[quantity]"]')).toHaveCount(0);
+ await expect(confirmForm.locator('textarea[name="refund_request[reason]"]')).toHaveCount(0);
+ await expect(confirmForm.locator('input[type="file"]')).toHaveCount(0);
+ // hidden の _token は存在する
+ await expect(confirmForm.locator('input[name="_token"]')).toHaveCount(1);
+ });
+
+ test('返品申請(ファイル添付):入力→確認→完了で DB にファイルが保存される(H-1 回帰防止)', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+ const { orderNo, orderItemId } = await getDeliveredOrderInfo(page);
+
+ // 入力画面
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}`);
+ await page.waitForLoadState('load');
+
+ await page.locator('#refund_request_quantity').fill('1');
+ await page.locator('#refund_request_reason').fill('H-1 回帰防止:エビデンスファイルが本当に保存されること');
+
+ // 同梱フィクスチャ画像を添付
+ const fixture = path.resolve(__dirname, '../fixtures/evidence.png');
+ await page.locator('#refund_request_files').setInputFiles(fixture);
+
+ await page.locator('button.ec-blockBtn--action', { hasText: '確認画面へ' }).click();
+ await page.waitForLoadState('load');
+
+ // 確認画面でファイル名が見える
+ await expect(page).toHaveURL(/\/confirm$/);
+ await expect(page.locator('main')).toContainText('evidence.png');
+
+ // 申請する
+ await page.locator('button.ec-blockBtn--action', { hasText: '申請する' }).click();
+ await page.waitForLoadState('load');
+
+ // 完了画面に遷移
+ await expect(page.locator('div.ec-pageHeader h1')).toContainText('返品申請完了');
+
+ // 履歴ページで「画像/動画ファイルあり」が見えること(保存された証拠)
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}/history`);
+ await page.waitForLoadState('load');
+ // 履歴に保存ファイルのリンクが含まれていること
+ await expect(page.locator('a[href*="/mypage/refund_request/file/"]').first()).toBeVisible();
+ });
+
+ test('確認画面はセッション喪失時に入力画面へ戻る', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+ const { orderNo, orderItemId } = await getDeliveredOrderInfo(page);
+
+ // セッション未投入のままで confirm に直アクセス
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}/confirm`);
+ await page.waitForLoadState('load');
+
+ // 入力画面(confirm が付かない URL)に戻されている
+ await expect(page).toHaveURL(new RegExp(`/mypage/refund_request/${orderNo}/${orderItemId}$`));
+ });
+
+ test('理由空欄でバリデーションエラー', async ({ page }) => {
+ await loginAsRefundTestCustomer(page);
+ const { orderNo, orderItemId } = await getDeliveredOrderInfo(page);
+
+ await page.goto(`/mypage/refund_request/${orderNo}/${orderItemId}`);
+ await page.waitForLoadState('load');
+
+ await page.locator('#refund_request_quantity').fill('1');
+ await page.locator('#refund_request_reason').fill('');
+
+ await page.locator('button.ec-blockBtn--action', { hasText: '確認画面へ' }).click();
+ await page.waitForLoadState('load');
+
+ // 入力画面に留まる
+ await expect(page.locator('div.ec-pageHeader h1')).toContainText('返品申請');
+ await expect(page.locator('div.ec-pageHeader h1')).not.toContainText('確認');
+ });
+});
diff --git a/src/Eccube/Controller/Admin/Order/RefundRequestController.php b/src/Eccube/Controller/Admin/Order/RefundRequestController.php
new file mode 100644
index 00000000000..de0d14faf10
--- /dev/null
+++ b/src/Eccube/Controller/Admin/Order/RefundRequestController.php
@@ -0,0 +1,355 @@
+
+ */
+ #[Route(path: '/%eccube_admin_route%/order/refund_request', name: 'admin_refund_request', methods: ['GET', 'POST'])]
+ #[Route(path: '/%eccube_admin_route%/order/refund_request/page/{page_no}', name: 'admin_refund_request_page', requirements: ['page_no' => '\d+'], methods: ['GET', 'POST'])]
+ #[Template(template: '@admin/Order/refund_request.twig')]
+ public function index(Request $request, ?int $page_no = null): array
+ {
+ $builder = $this->formFactory->createBuilder(SearchRefundRequestType::class);
+
+ $event = new EventArgs(
+ [
+ 'builder' => $builder,
+ ],
+ $request
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::ADMIN_REFUND_REQUEST_INDEX_INITIALIZE);
+
+ $searchForm = $builder->getForm();
+
+ $page_count = $this->session->get('eccube.admin.refund_request.search.page_count',
+ $this->eccubeConfig->get('eccube_default_page_count'));
+
+ $page_count_param = (int) $request->get('page_count');
+ $pageMaxis = $this->pageMaxRepository->findAll();
+
+ if ($page_count_param) {
+ foreach ($pageMaxis as $pageMax) {
+ if ($page_count_param == $pageMax->getName()) {
+ $page_count = $pageMax->getName();
+ $this->session->set('eccube.admin.refund_request.search.page_count', $page_count);
+ break;
+ }
+ }
+ }
+
+ if ('POST' === $request->getMethod()) {
+ $searchForm->handleRequest($request);
+
+ if ($searchForm->isValid()) {
+ $page_no = 1;
+ $searchData = $searchForm->getData();
+
+ $this->session->set('eccube.admin.refund_request.search', FormUtil::getViewData($searchForm));
+ $this->session->set('eccube.admin.refund_request.search.page_no', $page_no);
+ } else {
+ return [
+ 'searchForm' => $searchForm->createView(),
+ 'pagination' => [],
+ 'pageMaxis' => $pageMaxis,
+ 'page_no' => $page_no,
+ 'page_count' => $page_count,
+ 'has_errors' => true,
+ ];
+ }
+ } else {
+ if (null !== $page_no || $request->get('resume')) {
+ if ($page_no) {
+ $this->session->set('eccube.admin.refund_request.search.page_no', (int) $page_no);
+ } else {
+ $page_no = $this->session->get('eccube.admin.refund_request.search.page_no', 1);
+ }
+ $viewData = $this->session->get('eccube.admin.refund_request.search', []);
+ $searchData = FormUtil::submitAndGetData($searchForm, $viewData);
+ } else {
+ $page_no = 1;
+ $viewData = [];
+ $searchData = FormUtil::submitAndGetData($searchForm, $viewData);
+
+ $this->session->set('eccube.admin.refund_request.search', $viewData);
+ $this->session->set('eccube.admin.refund_request.search.page_no', $page_no);
+ }
+ }
+
+ $qb = $this->refundRequestRepository->getQueryBuilderBySearchData($searchData);
+
+ $event = new EventArgs(
+ [
+ 'qb' => $qb,
+ 'searchData' => $searchData,
+ ],
+ $request
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::ADMIN_REFUND_REQUEST_INDEX_SEARCH);
+
+ $pagination = $this->paginator->paginate(
+ $qb,
+ $page_no,
+ $page_count
+ );
+
+ return [
+ 'searchForm' => $searchForm->createView(),
+ 'pagination' => $pagination,
+ 'pageMaxis' => $pageMaxis,
+ 'page_no' => $page_no,
+ 'page_count' => $page_count,
+ 'has_errors' => false,
+ ];
+ }
+
+ /**
+ * 返品申請詳細画面.
+ *
+ * @return Response|array
+ */
+ #[Route(path: '/%eccube_admin_route%/order/refund_request/{id}/edit', name: 'admin_refund_request_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
+ #[Template(template: '@admin/Order/refund_request_edit.twig')]
+ public function edit(Request $request, RefundRequest $RefundRequest): Response|array
+ {
+ $form = $this->createForm(RefundRequestEditType::class, null, [
+ 'refund_request' => $RefundRequest,
+ ]);
+ $form->get('admin_note')->setData($RefundRequest->getAdminNote());
+
+ $event = new EventArgs(
+ [
+ 'form' => $form,
+ 'RefundRequest' => $RefundRequest,
+ ],
+ $request
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::ADMIN_REFUND_REQUEST_EDIT_INITIALIZE);
+
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $adminNote = $form->get('admin_note')->getData();
+ $RefundRequest->setAdminNote($adminNote);
+ $this->entityManager->flush();
+
+ $transition = $form->get('transition')->getData();
+ if ($transition) {
+ try {
+ $this->refundRequestService->changeStatus($RefundRequest, $transition);
+ $this->addSuccess('admin.common.save_complete', 'admin');
+ } catch (\InvalidArgumentException) {
+ $this->addError('admin.order.refund_request.transition_error', 'admin');
+ }
+ } else {
+ $this->addSuccess('admin.common.save_complete', 'admin');
+ }
+
+ $event = new EventArgs(
+ [
+ 'form' => $form,
+ 'RefundRequest' => $RefundRequest,
+ ],
+ $request
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::ADMIN_REFUND_REQUEST_EDIT_COMPLETE);
+
+ return $this->redirectToRoute('admin_refund_request_edit', ['id' => $RefundRequest->getId()]);
+ }
+
+ $availableTransitions = $this->refundRequestService->getAvailableTransitions($RefundRequest);
+
+ return [
+ 'form' => $form->createView(),
+ 'RefundRequest' => $RefundRequest,
+ 'availableTransitions' => $availableTransitions,
+ ];
+ }
+
+ /**
+ * ステータス変更 API.
+ */
+ #[Route(path: '/%eccube_admin_route%/order/refund_request/{id}/update_status', name: 'admin_refund_request_update_status', requirements: ['id' => '\d+'], methods: ['PUT'])]
+ public function updateStatus(Request $request, RefundRequest $RefundRequest): JsonResponse
+ {
+ // isTokenValid() は無効トークン時に AccessDeniedHttpException を投げる(戻り値は常に true)。
+ // 破壊的な PUT API のため、ここで CSRF を強制する。
+ $this->isTokenValid();
+
+ $transition = $request->get('transition');
+ if (!$transition) {
+ throw new BadRequestHttpException();
+ }
+
+ // 現在のステータスから実行できない遷移(未定義・適用不可)は適用前に弾く.
+ if (!$this->refundRequestService->canApplyTransition($RefundRequest, $transition)) {
+ return $this->json([
+ 'success' => false,
+ 'message' => trans('admin.order.refund_request.transition_error'),
+ ], Response::HTTP_BAD_REQUEST);
+ }
+
+ try {
+ $this->refundRequestService->changeStatus($RefundRequest, $transition);
+
+ return $this->json([
+ 'success' => true,
+ 'status' => (string) $RefundRequest->getRefundRequestStatus(),
+ ]);
+ } catch (\InvalidArgumentException) {
+ return $this->json([
+ 'success' => false,
+ 'message' => trans('admin.order.refund_request.transition_error'),
+ ], Response::HTTP_BAD_REQUEST);
+ }
+ }
+
+ /**
+ * CSVエクスポート.
+ */
+ #[Route(path: '/%eccube_admin_route%/order/refund_request/export', name: 'admin_refund_request_export', methods: ['GET'])]
+ public function export(Request $request): StreamedResponse
+ {
+ set_time_limit(0);
+
+ $this->entityManager->getConfiguration()->setSQLLogger();
+
+ $viewData = $this->session->get('eccube.admin.refund_request.search', []);
+ $searchForm = $this->formFactory->createBuilder(SearchRefundRequestType::class)->getForm();
+ $searchData = FormUtil::submitAndGetData($searchForm, $viewData);
+ $qb = $this->refundRequestRepository->getQueryBuilderBySearchData($searchData);
+
+ $response = new StreamedResponse();
+ $response->setCallback(function () use ($qb): void {
+ $out = fopen('php://output', 'w');
+ fwrite($out, "\xEF\xBB\xBF");
+
+ $header = [
+ trans('admin.order.refund_request.id'),
+ trans('admin.order.refund_request.order_no'),
+ trans('admin.order.refund_request.customer'),
+ trans('admin.order.refund_request.product'),
+ trans('admin.order.refund_request.status'),
+ trans('admin.order.refund_request.quantity'),
+ trans('admin.order.refund_request.reason'),
+ trans('admin.order.refund_request.create_date'),
+ trans('admin.order.refund_request.update_date'),
+ ];
+ fputcsv($out, $header);
+
+ $sanitize = static function (mixed $value): string {
+ $value = (string) ($value ?? '');
+
+ return preg_match('/^[=+\-@]/', $value) ? "'".$value : $value;
+ };
+
+ foreach ($qb->getQuery()->toIterable() as $RefundRequest) {
+ $row = [
+ $sanitize($RefundRequest->getId()),
+ $sanitize($RefundRequest->getOrder()?->getOrderNo()),
+ $sanitize($RefundRequest->getCustomer() ? $RefundRequest->getCustomer()->getName01().' '.$RefundRequest->getCustomer()->getName02() : ''),
+ $sanitize($RefundRequest->getOrderItem()?->getProductName()),
+ $sanitize((string) $RefundRequest->getRefundRequestStatus()),
+ $sanitize($RefundRequest->getQuantity()),
+ $sanitize($RefundRequest->getReason()),
+ $sanitize($RefundRequest->getCreateDate()?->format('Y-m-d H:i:s')),
+ $sanitize($RefundRequest->getUpdateDate()?->format('Y-m-d H:i:s')),
+ ];
+ fputcsv($out, $row);
+ $this->entityManager->detach($RefundRequest);
+ }
+
+ fclose($out);
+ });
+
+ $filename = 'refund_request_'.(new \DateTime())->format('YmdHis').'.csv';
+ $response->headers->set('Content-Type', 'application/octet-stream');
+ $response->headers->set('Content-Disposition', 'attachment; filename='.$filename);
+
+ log_info('返品申請CSV出力', [$filename]);
+
+ return $response;
+ }
+
+ /**
+ * エビデンスファイル配信(管理画面).
+ */
+ #[Route(path: '/%eccube_admin_route%/order/refund_request/{id}/file/{file_id}', name: 'admin_refund_request_file', requirements: ['id' => '\d+', 'file_id' => '\d+'], methods: ['GET'])]
+ public function downloadFile(RefundRequest $RefundRequest, int $file_id): BinaryFileResponse
+ {
+ $RefundRequestFile = null;
+ foreach ($RefundRequest->getRefundRequestFiles() as $File) {
+ if ($File->getId() === $file_id) {
+ $RefundRequestFile = $File;
+ break;
+ }
+ }
+ if (!$RefundRequestFile) {
+ throw new NotFoundHttpException();
+ }
+
+ $filePath = $this->eccubeConfig['eccube_save_refund_request_file_dir'].'/'.$RefundRequestFile->getFileName();
+ $topDir = $this->eccubeConfig['eccube_save_refund_request_file_dir'];
+
+ if (str_contains($filePath, '..')) {
+ throw new NotFoundHttpException();
+ }
+
+ $realPath = realpath($filePath);
+ $realTopDir = realpath($topDir);
+
+ if ($realPath === false || $realTopDir === false || !str_starts_with($realPath, $realTopDir.DIRECTORY_SEPARATOR)) {
+ throw new NotFoundHttpException();
+ }
+
+ $response = new BinaryFileResponse($realPath);
+ $response->headers->set('Content-Type', (string) $RefundRequestFile->getMimeType());
+ $response->headers->set('X-Content-Type-Options', 'nosniff');
+
+ return $response;
+ }
+}
diff --git a/src/Eccube/Controller/Mypage/MypageController.php b/src/Eccube/Controller/Mypage/MypageController.php
index 5df506e4984..253a8405a47 100644
--- a/src/Eccube/Controller/Mypage/MypageController.php
+++ b/src/Eccube/Controller/Mypage/MypageController.php
@@ -26,6 +26,7 @@
use Eccube\Repository\CustomerFavoriteProductRepository;
use Eccube\Repository\OrderRepository;
use Eccube\Repository\ProductRepository;
+use Eccube\Repository\RefundRequestRepository;
use Eccube\Service\CartService;
use Eccube\Service\PurchaseFlow\PurchaseContext;
use Eccube\Service\PurchaseFlow\PurchaseFlow;
@@ -56,6 +57,7 @@ public function __construct(
protected PurchaseFlow $purchaseFlow,
private readonly AuthenticationUtils $utils,
private readonly PaginatorInterface $paginator,
+ private readonly RefundRequestRepository $refundRequestRepository,
) {
$this->BaseInfo = $baseInfoRepository->get();
}
@@ -187,9 +189,16 @@ public function history(Request $request, $order_no): array
}
}
+ // 返品申請履歴ボタンを「申請がある明細にのみ」表示するため, 注文明細ID別の申請件数を一括取得する(N+1回避).
+ $Customer = $this->getUser();
+ $refundRequestCounts = $Customer instanceof Customer
+ ? $this->refundRequestRepository->getRefundRequestCountsByCustomer($Customer)
+ : [];
+
return [
'Order' => $Order,
'stockOrder' => $stockOrder,
+ 'refundRequestCounts' => $refundRequestCounts,
];
}
diff --git a/src/Eccube/Controller/Mypage/RefundRequestController.php b/src/Eccube/Controller/Mypage/RefundRequestController.php
new file mode 100644
index 00000000000..8f0c4943ff5
--- /dev/null
+++ b/src/Eccube/Controller/Mypage/RefundRequestController.php
@@ -0,0 +1,409 @@
+
+ */
+ #[Route(path: '/mypage/refund_request/{order_no}/{order_item_id}', name: 'mypage_refund_request', requirements: ['order_item_id' => '\d+'], methods: ['GET', 'POST'])]
+ #[Template(template: 'Mypage/refund_request.twig')]
+ public function index(Request $request, string $order_no, int $order_item_id): Response|RedirectResponse|array
+ {
+ $Order = $this->getValidOrder($order_no);
+ $OrderItem = $this->getValidOrderItem($Order, $order_item_id);
+
+ /** @var Customer $Customer */
+ $Customer = $this->getUser();
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+
+ $maxQuantity = (int) $OrderItem->getQuantity();
+ $form = $this->createForm(RefundRequestType::class, $RefundRequest, [
+ 'max_quantity' => $maxQuantity,
+ ]);
+
+ $event = new EventArgs(
+ [
+ 'form' => $form,
+ 'Order' => $Order,
+ 'OrderItem' => $OrderItem,
+ ],
+ $request
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::FRONT_MYPAGE_REFUND_REQUEST_INDEX_INITIALIZE);
+
+ $form->handleRequest($request);
+
+ if ($form->isSubmitted() && $form->isValid()) {
+ $quantity = (string) $form->get('quantity')->getData();
+ $reason = (string) $form->get('reason')->getData();
+
+ $sessionId = $request->getSession()->getId();
+
+ // 既存の一時データがあれば前回分の一時ファイルを掃除(複数回 confirm に進むケース)
+ $previous = $this->session->get(self::SESSION_KEY);
+ if (is_array($previous) && isset($previous['temp_files']) && is_array($previous['temp_files'])) {
+ foreach ($previous['temp_files'] as $info) {
+ if (isset($info['key'])) {
+ $this->refundRequestService->removeTempFile($sessionId, (string) $info['key']);
+ }
+ }
+ }
+
+ $uploadedFiles = $form->get('files')->getData() ?? [];
+ $tempFiles = [];
+ foreach ($uploadedFiles as $uploadedFile) {
+ if ($uploadedFile instanceof UploadedFile) {
+ $tempFiles[] = $this->refundRequestService->saveTempFile($uploadedFile, $sessionId);
+ }
+ }
+
+ $this->session->set(self::SESSION_KEY, [
+ 'order_no' => $order_no,
+ 'order_item_id' => $order_item_id,
+ 'quantity' => $quantity,
+ 'reason' => $reason,
+ 'temp_files' => $tempFiles,
+ ]);
+
+ log_info('返品申請確認画面へ遷移', ['order_no' => $order_no, 'order_item_id' => $order_item_id, 'files' => count($tempFiles)]);
+
+ return $this->redirectToRoute('mypage_refund_request_confirm', [
+ 'order_no' => $order_no,
+ 'order_item_id' => $order_item_id,
+ ]);
+ }
+
+ return [
+ 'form' => $form->createView(),
+ 'Order' => $Order,
+ 'OrderItem' => $OrderItem,
+ 'max_quantity' => $maxQuantity,
+ ];
+ }
+
+ /**
+ * 返品申請確認画面.
+ *
+ * セッションに保持した入力値・一時ファイルを表示する。POST で確定処理を行う。
+ *
+ * @return Response|RedirectResponse|array
+ */
+ #[Route(path: '/mypage/refund_request/{order_no}/{order_item_id}/confirm', name: 'mypage_refund_request_confirm', requirements: ['order_item_id' => '\d+'], methods: ['GET', 'POST'])]
+ #[Template(template: 'Mypage/refund_request_confirm.twig')]
+ public function confirm(Request $request, string $order_no, int $order_item_id): Response|RedirectResponse|array
+ {
+ $data = $this->session->get(self::SESSION_KEY);
+ if (!is_array($data) || ($data['order_no'] ?? null) !== $order_no || (int) ($data['order_item_id'] ?? 0) !== $order_item_id) {
+ return $this->redirectToRoute('mypage_refund_request', [
+ 'order_no' => $order_no,
+ 'order_item_id' => $order_item_id,
+ ]);
+ }
+
+ $Order = $this->getValidOrder($order_no);
+ $OrderItem = $this->getValidOrderItem($Order, $order_item_id);
+
+ /** @var Customer $Customer */
+ $Customer = $this->getUser();
+
+ $event = new EventArgs(
+ [
+ 'Order' => $Order,
+ 'OrderItem' => $OrderItem,
+ 'Customer' => $Customer,
+ 'data' => $data,
+ ],
+ $request
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::FRONT_MYPAGE_REFUND_REQUEST_CONFIRM_INITIALIZE);
+
+ if ($request->isMethod('POST')) {
+ if (!$this->isTokenValid()) {
+ $this->addError('front.mypage.refund_request.invalid_token');
+
+ return $this->redirectToRoute('mypage_refund_request', [
+ 'order_no' => $order_no,
+ 'order_item_id' => $order_item_id,
+ ]);
+ }
+
+ $sessionId = $request->getSession()->getId();
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity((string) $data['quantity']);
+ $RefundRequest->setReason((string) $data['reason']);
+
+ $tempFiles = is_array($data['temp_files'] ?? null) ? $data['temp_files'] : [];
+
+ log_info('返品申請処理開始', ['order_no' => $order_no, 'order_item_id' => $order_item_id]);
+
+ try {
+ $this->refundRequestService->createRefundRequest($RefundRequest, $tempFiles, $sessionId);
+ } catch (\RuntimeException $e) {
+ log_error('返品申請処理失敗', ['error' => $e->getMessage()]);
+ $this->addError('front.mypage.refund_request.error');
+
+ return $this->redirectToRoute('mypage_refund_request', [
+ 'order_no' => $order_no,
+ 'order_item_id' => $order_item_id,
+ ]);
+ }
+
+ $this->session->remove(self::SESSION_KEY);
+
+ log_info('返品申請処理完了', ['id' => $RefundRequest->getId()]);
+
+ $event = new EventArgs(
+ [
+ 'Order' => $Order,
+ 'OrderItem' => $OrderItem,
+ 'RefundRequest' => $RefundRequest,
+ ],
+ $request
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::FRONT_MYPAGE_REFUND_REQUEST_CONFIRM_COMPLETE);
+
+ return $this->redirectToRoute('mypage_refund_request_complete', [
+ 'order_no' => $order_no,
+ 'order_item_id' => $order_item_id,
+ ]);
+ }
+
+ return [
+ 'Order' => $Order,
+ 'OrderItem' => $OrderItem,
+ 'data' => $data,
+ 'temp_files' => is_array($data['temp_files'] ?? null) ? $data['temp_files'] : [],
+ ];
+ }
+
+ /**
+ * 返品申請完了画面.
+ *
+ * @return array
+ */
+ #[Route(path: '/mypage/refund_request/{order_no}/{order_item_id}/complete', name: 'mypage_refund_request_complete', requirements: ['order_item_id' => '\d+'], methods: ['GET'])]
+ #[Template(template: 'Mypage/refund_request_complete.twig')]
+ public function complete(string $order_no, int $order_item_id): array
+ {
+ return [
+ 'order_no' => $order_no,
+ 'order_item_id' => $order_item_id,
+ ];
+ }
+
+ /**
+ * 商品別返品申請履歴画面.
+ *
+ * @return array
+ */
+ #[Route(path: '/mypage/refund_request/{order_no}/{order_item_id}/history', name: 'mypage_refund_request_item_history', requirements: ['order_item_id' => '\d+'], methods: ['GET'])]
+ #[Template(template: 'Mypage/refund_request_item_history.twig')]
+ public function itemHistory(Request $request, string $order_no, int $order_item_id): array
+ {
+ $Order = $this->getValidOrder($order_no);
+ $OrderItem = $this->getValidOrderItem($Order, $order_item_id);
+
+ /** @var Customer $Customer */
+ $Customer = $this->getUser();
+ $RefundRequests = $this->refundRequestRepository->findByOrderItemAndCustomer($OrderItem, $Customer);
+
+ $event = new EventArgs(
+ [
+ 'Order' => $Order,
+ 'OrderItem' => $OrderItem,
+ 'RefundRequests' => $RefundRequests,
+ ],
+ $request
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::FRONT_MYPAGE_REFUND_REQUEST_ITEM_HISTORY_INITIALIZE);
+
+ return [
+ 'Order' => $Order,
+ 'OrderItem' => $OrderItem,
+ 'RefundRequests' => $RefundRequests,
+ ];
+ }
+
+ /**
+ * エビデンスファイル配信(会員所有チェック付き).
+ */
+ #[Route(path: '/mypage/refund_request/file/{refund_request_id}/{file_id}', name: 'mypage_refund_request_file', requirements: ['refund_request_id' => '\d+', 'file_id' => '\d+'], methods: ['GET'])]
+ public function downloadFile(int $refund_request_id, int $file_id): BinaryFileResponse
+ {
+ /** @var Customer $Customer */
+ $Customer = $this->getUser();
+
+ $RefundRequest = $this->refundRequestRepository->find($refund_request_id);
+ if (!$RefundRequest || $RefundRequest->getCustomer()?->getId() !== $Customer->getId()) {
+ throw new NotFoundHttpException();
+ }
+
+ $RefundRequestFile = null;
+ foreach ($RefundRequest->getRefundRequestFiles() as $File) {
+ if ($File->getId() === $file_id) {
+ $RefundRequestFile = $File;
+ break;
+ }
+ }
+ if (!$RefundRequestFile) {
+ throw new NotFoundHttpException();
+ }
+
+ $filePath = $this->eccubeConfig['eccube_save_refund_request_file_dir'].'/'.$RefundRequestFile->getFileName();
+ $topDir = $this->eccubeConfig['eccube_save_refund_request_file_dir'];
+
+ $realPath = realpath($filePath);
+ $realTopDir = realpath($topDir);
+
+ if ($realPath === false || $realTopDir === false || !str_starts_with($realPath, $realTopDir.DIRECTORY_SEPARATOR)) {
+ throw new NotFoundHttpException();
+ }
+
+ $response = new BinaryFileResponse($realPath);
+ $response->headers->set('Content-Type', (string) $RefundRequestFile->getMimeType());
+ $response->headers->set('X-Content-Type-Options', 'nosniff');
+
+ return $response;
+ }
+
+ /**
+ * 確認画面プレビュー用に、一時保存中のエビデンスファイルを配信する.
+ * 自セッションに紐づく一時ファイルのみ参照可。
+ */
+ #[Route(path: '/mypage/refund_request/temp_file/{key}', name: 'mypage_refund_request_temp_file', requirements: ['key' => '[A-Za-z0-9._-]+'], methods: ['GET'])]
+ public function downloadTempFile(Request $request, string $key): BinaryFileResponse
+ {
+ $data = $this->session->get(self::SESSION_KEY);
+ if (!is_array($data) || !isset($data['temp_files']) || !is_array($data['temp_files'])) {
+ throw new NotFoundHttpException();
+ }
+
+ $matched = null;
+ foreach ($data['temp_files'] as $info) {
+ if (isset($info['key']) && $info['key'] === $key) {
+ $matched = $info;
+ break;
+ }
+ }
+ if ($matched === null) {
+ throw new NotFoundHttpException();
+ }
+
+ $sessionId = $request->getSession()->getId();
+ $real = $this->refundRequestService->getTempFilePath($sessionId, $key);
+ if ($real === null) {
+ throw new NotFoundHttpException();
+ }
+
+ $response = new BinaryFileResponse($real);
+ $response->headers->set('Content-Type', (string) ($matched['mime_type'] ?? 'application/octet-stream'));
+ $response->headers->set('X-Content-Type-Options', 'nosniff');
+
+ return $response;
+ }
+
+ /**
+ * 注文の所有チェック + 発送済みステータス検証.
+ */
+ private function getValidOrder(string $order_no): Order
+ {
+ $this->entityManager->getFilters()->enable('incomplete_order_status_hidden');
+
+ /** @var Order|null $Order */
+ $Order = $this->orderRepository->findOneBy([
+ 'order_no' => $order_no,
+ 'Customer' => $this->getUser(),
+ ]);
+
+ if (!$Order) {
+ throw new NotFoundHttpException();
+ }
+
+ if ($Order->getOrderStatus()->getId() !== OrderStatus::DELIVERED) {
+ throw new AccessDeniedHttpException();
+ }
+
+ return $Order;
+ }
+
+ /**
+ * 注文明細の所有チェック + 商品明細かつ返品許可チェック.
+ */
+ private function getValidOrderItem(Order $Order, int $order_item_id): OrderItem
+ {
+ $OrderItem = null;
+ foreach ($Order->getOrderItems() as $item) {
+ if ($item->getId() === $order_item_id && $item->isProduct()) {
+ $OrderItem = $item;
+ break;
+ }
+ }
+
+ if (!$OrderItem) {
+ throw new NotFoundHttpException();
+ }
+
+ if ($OrderItem->getProduct() && !$OrderItem->getProduct()->isRefundAllowed()) {
+ throw new AccessDeniedHttpException();
+ }
+
+ return $OrderItem;
+ }
+}
diff --git a/src/Eccube/Entity/Master/RefundRequestStatus.php b/src/Eccube/Entity/Master/RefundRequestStatus.php
new file mode 100644
index 00000000000..b32bb649f48
--- /dev/null
+++ b/src/Eccube/Entity/Master/RefundRequestStatus.php
@@ -0,0 +1,42 @@
+ true])]
+ private bool $refund_allowed = true;
+
/**
* @var \DateTime
*/
@@ -703,6 +706,24 @@ public function getFreeArea(): ?string
return $this->free_area;
}
+ /**
+ * 返品申請を許可するかどうかを設定する.
+ */
+ public function setRefundAllowed(bool $refundAllowed): Product
+ {
+ $this->refund_allowed = $refundAllowed;
+
+ return $this;
+ }
+
+ /**
+ * 返品申請を許可するかどうかを取得する.
+ */
+ public function isRefundAllowed(): bool
+ {
+ return $this->refund_allowed;
+ }
+
/**
* Set createDate.
*/
diff --git a/src/Eccube/Entity/RefundRequest.php b/src/Eccube/Entity/RefundRequest.php
new file mode 100644
index 00000000000..b7acaa317ff
--- /dev/null
+++ b/src/Eccube/Entity/RefundRequest.php
@@ -0,0 +1,234 @@
+ true])]
+ #[ORM\Id]
+ #[ORM\GeneratedValue(strategy: 'IDENTITY')]
+ private ?int $id = null;
+
+ #[ORM\Column(name: 'quantity', type: Types::DECIMAL, precision: 10, scale: 0, options: ['default' => 0])]
+ private ?string $quantity = '0';
+
+ #[ORM\Column(name: 'reason', type: Types::TEXT)]
+ private ?string $reason = null;
+
+ #[ORM\Column(name: 'admin_note', type: Types::TEXT, nullable: true)]
+ private ?string $admin_note = null;
+
+ /**
+ * @var \DateTime
+ */
+ #[ORM\Column(name: 'create_date', type: Types::DATETIMETZ_MUTABLE)]
+ private $create_date;
+
+ /**
+ * @var \DateTime
+ */
+ #[ORM\Column(name: 'update_date', type: Types::DATETIMETZ_MUTABLE)]
+ private $update_date;
+
+ #[ORM\ManyToOne(targetEntity: Order::class)]
+ #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id')]
+ private ?Order $Order = null;
+
+ #[ORM\ManyToOne(targetEntity: OrderItem::class)]
+ #[ORM\JoinColumn(name: 'order_item_id', referencedColumnName: 'id')]
+ private ?OrderItem $OrderItem = null;
+
+ #[ORM\ManyToOne(targetEntity: Customer::class)]
+ #[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id')]
+ private ?Customer $Customer = null;
+
+ #[ORM\ManyToOne(targetEntity: RefundRequestStatus::class)]
+ #[ORM\JoinColumn(name: 'refund_request_status_id', referencedColumnName: 'id')]
+ private ?RefundRequestStatus $RefundRequestStatus = null;
+
+ /**
+ * @var Collection
+ */
+ #[ORM\OneToMany(targetEntity: RefundRequestFile::class, mappedBy: 'RefundRequest', cascade: ['persist', 'remove'])]
+ #[ORM\OrderBy(['sort_no' => 'ASC'])]
+ private Collection $RefundRequestFiles;
+
+ public function __construct()
+ {
+ $this->RefundRequestFiles = new ArrayCollection();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setQuantity(string $quantity): self
+ {
+ $this->quantity = $quantity;
+
+ return $this;
+ }
+
+ public function getQuantity(): ?string
+ {
+ return $this->quantity;
+ }
+
+ public function setReason(?string $reason): self
+ {
+ $this->reason = $reason;
+
+ return $this;
+ }
+
+ public function getReason(): ?string
+ {
+ return $this->reason;
+ }
+
+ public function setAdminNote(?string $adminNote): self
+ {
+ $this->admin_note = $adminNote;
+
+ return $this;
+ }
+
+ public function getAdminNote(): ?string
+ {
+ return $this->admin_note;
+ }
+
+ public function setCreateDate(\DateTime $createDate): self
+ {
+ $this->create_date = $createDate;
+
+ return $this;
+ }
+
+ public function getCreateDate(): ?\DateTime
+ {
+ return $this->create_date;
+ }
+
+ public function setUpdateDate(\DateTime $updateDate): self
+ {
+ $this->update_date = $updateDate;
+
+ return $this;
+ }
+
+ public function getUpdateDate(): ?\DateTime
+ {
+ return $this->update_date;
+ }
+
+ public function setOrder(?Order $order = null): self
+ {
+ $this->Order = $order;
+
+ return $this;
+ }
+
+ public function getOrder(): ?Order
+ {
+ return $this->Order;
+ }
+
+ public function setOrderItem(?OrderItem $orderItem = null): self
+ {
+ $this->OrderItem = $orderItem;
+
+ return $this;
+ }
+
+ public function getOrderItem(): ?OrderItem
+ {
+ return $this->OrderItem;
+ }
+
+ public function setCustomer(?Customer $customer = null): self
+ {
+ $this->Customer = $customer;
+
+ return $this;
+ }
+
+ public function getCustomer(): ?Customer
+ {
+ return $this->Customer;
+ }
+
+ public function setRefundRequestStatus(?RefundRequestStatus $refundRequestStatus = null): self
+ {
+ $this->RefundRequestStatus = $refundRequestStatus;
+
+ return $this;
+ }
+
+ public function getRefundRequestStatus(): ?RefundRequestStatus
+ {
+ return $this->RefundRequestStatus;
+ }
+
+ /**
+ * @return Collection
+ */
+ public function getRefundRequestFiles(): Collection
+ {
+ return $this->RefundRequestFiles;
+ }
+
+ public function addRefundRequestFile(RefundRequestFile $refundRequestFile): self
+ {
+ if (!$this->RefundRequestFiles->contains($refundRequestFile)) {
+ $this->RefundRequestFiles[] = $refundRequestFile;
+ $refundRequestFile->setRefundRequest($this);
+ }
+
+ return $this;
+ }
+
+ public function removeRefundRequestFile(RefundRequestFile $refundRequestFile): bool
+ {
+ if (!$this->RefundRequestFiles->removeElement($refundRequestFile)) {
+ return false;
+ }
+ if ($refundRequestFile->getRefundRequest() === $this) {
+ $refundRequestFile->setRefundRequest();
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/Eccube/Entity/RefundRequestFile.php b/src/Eccube/Entity/RefundRequestFile.php
new file mode 100644
index 00000000000..8de075ca1d0
--- /dev/null
+++ b/src/Eccube/Entity/RefundRequestFile.php
@@ -0,0 +1,153 @@
+ true])]
+ #[ORM\Id]
+ #[ORM\GeneratedValue(strategy: 'IDENTITY')]
+ private ?int $id = null;
+
+ #[ORM\Column(name: 'file_name', type: Types::STRING, length: 255)]
+ private ?string $file_name = null;
+
+ #[ORM\Column(name: 'mime_type', type: Types::STRING, length: 100)]
+ private ?string $mime_type = null;
+
+ #[ORM\Column(name: 'file_size', type: Types::INTEGER, options: ['unsigned' => true])]
+ private ?int $file_size = null;
+
+ #[ORM\Column(name: 'sort_no', type: Types::SMALLINT, options: ['unsigned' => true, 'default' => 0])]
+ private ?int $sort_no = 0;
+
+ /**
+ * @var \DateTime
+ */
+ #[ORM\Column(name: 'create_date', type: Types::DATETIMETZ_MUTABLE)]
+ private $create_date;
+
+ #[ORM\ManyToOne(targetEntity: RefundRequest::class, inversedBy: 'RefundRequestFiles')]
+ #[ORM\JoinColumn(name: 'refund_request_id', referencedColumnName: 'id')]
+ private ?RefundRequest $RefundRequest = null;
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function setFileName(?string $fileName): self
+ {
+ $this->file_name = $fileName;
+
+ return $this;
+ }
+
+ public function getFileName(): ?string
+ {
+ return $this->file_name;
+ }
+
+ public function setMimeType(?string $mimeType): self
+ {
+ $this->mime_type = $mimeType;
+
+ return $this;
+ }
+
+ public function getMimeType(): ?string
+ {
+ return $this->mime_type;
+ }
+
+ public function setFileSize(?int $fileSize): self
+ {
+ $this->file_size = $fileSize;
+
+ return $this;
+ }
+
+ public function getFileSize(): ?int
+ {
+ return $this->file_size;
+ }
+
+ public function setSortNo(?int $sortNo): self
+ {
+ $this->sort_no = $sortNo;
+
+ return $this;
+ }
+
+ public function getSortNo(): ?int
+ {
+ return $this->sort_no;
+ }
+
+ public function setCreateDate(\DateTime $createDate): self
+ {
+ $this->create_date = $createDate;
+
+ return $this;
+ }
+
+ public function getCreateDate(): ?\DateTime
+ {
+ return $this->create_date;
+ }
+
+ public function setRefundRequest(?RefundRequest $refundRequest = null): self
+ {
+ $this->RefundRequest = $refundRequest;
+
+ return $this;
+ }
+
+ public function getRefundRequest(): ?RefundRequest
+ {
+ return $this->RefundRequest;
+ }
+
+ /**
+ * ファイルが画像かどうか.
+ */
+ public function isImage(): bool
+ {
+ return str_starts_with((string) $this->mime_type, 'image/');
+ }
+
+ /**
+ * ファイルが動画かどうか.
+ */
+ public function isVideo(): bool
+ {
+ return str_starts_with((string) $this->mime_type, 'video/');
+ }
+ }
+}
diff --git a/src/Eccube/Event/EccubeEvents.php b/src/Eccube/Event/EccubeEvents.php
index 49dbaea2319..a73ce862cdd 100644
--- a/src/Eccube/Event/EccubeEvents.php
+++ b/src/Eccube/Event/EccubeEvents.php
@@ -594,4 +594,32 @@ final class EccubeEvents
public const MAIL_PASSWORD_RESET_COMPLETE = 'mail.password.reset.complete';
public const MAIL_SHIPPING_NOTIFY = 'mail.shipping.notify';
public const MAIL_CUSTOMER_CHANGE_NOTIFY = 'mail.customer.change.notify';
+ public const MAIL_REFUND_REQUEST = 'mail.refund_request';
+
+ /**
+ * Mypage RefundRequestController
+ */
+ // index(申請フォーム)
+ public const FRONT_MYPAGE_REFUND_REQUEST_INDEX_INITIALIZE = 'front.mypage.refund_request.index.initialize';
+ public const FRONT_MYPAGE_REFUND_REQUEST_INDEX_COMPLETE = 'front.mypage.refund_request.index.complete';
+ // confirm(確認画面)
+ public const FRONT_MYPAGE_REFUND_REQUEST_CONFIRM_INITIALIZE = 'front.mypage.refund_request.confirm.initialize';
+ public const FRONT_MYPAGE_REFUND_REQUEST_CONFIRM_COMPLETE = 'front.mypage.refund_request.confirm.complete';
+ // itemHistory(商品別申請履歴)
+ public const FRONT_MYPAGE_REFUND_REQUEST_ITEM_HISTORY_INITIALIZE = 'front.mypage.refund_request.item_history.initialize';
+
+ /**
+ * Admin RefundRequestController
+ */
+ // index(一覧・検索)
+ public const ADMIN_REFUND_REQUEST_INDEX_INITIALIZE = 'admin.refund_request.index.initialize';
+ public const ADMIN_REFUND_REQUEST_INDEX_SEARCH = 'admin.refund_request.index.search';
+ // edit(詳細・編集)
+ public const ADMIN_REFUND_REQUEST_EDIT_INITIALIZE = 'admin.refund_request.edit.initialize';
+ public const ADMIN_REFUND_REQUEST_EDIT_COMPLETE = 'admin.refund_request.edit.complete';
+
+ /**
+ * RefundRequest ステータス変更
+ */
+ public const REFUND_REQUEST_STATUS_CHANGE = 'refund_request.status.change';
}
diff --git a/src/Eccube/Event/RefundRequestEvent.php b/src/Eccube/Event/RefundRequestEvent.php
new file mode 100644
index 00000000000..4698763f3b3
--- /dev/null
+++ b/src/Eccube/Event/RefundRequestEvent.php
@@ -0,0 +1,48 @@
+refundRequest;
+ }
+
+ public function getPreviousStatus(): ?RefundRequestStatus
+ {
+ return $this->previousStatus;
+ }
+
+ public function getNewStatus(): ?RefundRequestStatus
+ {
+ return $this->newStatus;
+ }
+}
diff --git a/src/Eccube/Form/Type/Admin/RefundRequestEditType.php b/src/Eccube/Form/Type/Admin/RefundRequestEditType.php
new file mode 100644
index 00000000000..ea677fe9992
--- /dev/null
+++ b/src/Eccube/Form/Type/Admin/RefundRequestEditType.php
@@ -0,0 +1,79 @@
+ $options
+ */
+ #[\Override]
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ /** @var RefundRequest $RefundRequest */
+ $RefundRequest = $options['refund_request'];
+ $transitions = $this->refundRequestStateMachine->getAvailableTransitions($RefundRequest);
+
+ $choices = [];
+ foreach ($transitions as $transitionName => $Status) {
+ $choices[(string) $Status] = $transitionName;
+ }
+
+ $builder
+ ->add('admin_note', TextareaType::class, [
+ 'required' => false,
+ 'mapped' => false,
+ ])
+ ->add('transition', ChoiceType::class, [
+ 'required' => false,
+ 'mapped' => false,
+ 'choices' => $choices,
+ 'placeholder' => 'admin.order.refund_request.transition_placeholder',
+ ]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setRequired('refund_request');
+ $resolver->setAllowedTypes('refund_request', RefundRequest::class);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function getBlockPrefix(): string
+ {
+ return 'admin_refund_request_edit';
+ }
+}
diff --git a/src/Eccube/Form/Type/Admin/SearchRefundRequestType.php b/src/Eccube/Form/Type/Admin/SearchRefundRequestType.php
new file mode 100644
index 00000000000..c155e19bae7
--- /dev/null
+++ b/src/Eccube/Form/Type/Admin/SearchRefundRequestType.php
@@ -0,0 +1,99 @@
+ $options
+ */
+ #[\Override]
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $builder
+ // 複合検索(申請ID・注文番号・会員ID・会員名)
+ ->add('multi', TextType::class, [
+ 'label' => 'admin.order.refund_request.multi_search_label',
+ 'required' => false,
+ 'constraints' => [
+ new Assert\Length(['max' => $this->eccubeConfig['eccube_stext_len']]),
+ ],
+ ])
+ ->add('status', RefundRequestStatusType::class, [
+ 'label' => 'admin.order.refund_request.status',
+ 'required' => false,
+ 'expanded' => true,
+ 'multiple' => true,
+ ])
+ ->add('create_date_start', DateType::class, [
+ 'label' => 'admin.order.refund_request.create_date__start',
+ 'required' => false,
+ 'input' => 'datetime',
+ 'widget' => 'single_text',
+ ])
+ ->add('create_date_end', DateType::class, [
+ 'label' => 'admin.order.refund_request.create_date__end',
+ 'required' => false,
+ 'input' => 'datetime',
+ 'widget' => 'single_text',
+ ])
+ ->add('update_date_start', DateType::class, [
+ 'label' => 'admin.order.refund_request.update_date__start',
+ 'required' => false,
+ 'input' => 'datetime',
+ 'widget' => 'single_text',
+ ])
+ ->add('update_date_end', DateType::class, [
+ 'label' => 'admin.order.refund_request.update_date__end',
+ 'required' => false,
+ 'input' => 'datetime',
+ 'widget' => 'single_text',
+ ]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'csrf_protection' => false,
+ ]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function getBlockPrefix(): string
+ {
+ return 'admin_search_refund_request';
+ }
+}
diff --git a/src/Eccube/Form/Type/Front/RefundRequestType.php b/src/Eccube/Form/Type/Front/RefundRequestType.php
new file mode 100644
index 00000000000..34bcfce0288
--- /dev/null
+++ b/src/Eccube/Form/Type/Front/RefundRequestType.php
@@ -0,0 +1,117 @@
+ $options
+ */
+ #[\Override]
+ public function buildForm(FormBuilderInterface $builder, array $options): void
+ {
+ $maxQuantity = $options['max_quantity'];
+
+ $builder
+ // quantity は decimal(string) のため unmapped にし、Controller で文字列化してセットする
+ ->add('quantity', IntegerType::class, [
+ 'mapped' => false,
+ 'constraints' => [
+ new Assert\NotBlank(),
+ new Assert\GreaterThan(0),
+ new Assert\LessThanOrEqual([
+ 'value' => $maxQuantity,
+ 'message' => 'form_error.refund_request.quantity_exceed',
+ ]),
+ ],
+ ])
+ ->add('reason', TextareaType::class, [
+ 'constraints' => [
+ new Assert\NotBlank(),
+ new Assert\Length(['max' => 4000]),
+ ],
+ ])
+ ->add('files', FileType::class, [
+ 'mapped' => false,
+ 'multiple' => true,
+ 'required' => false,
+ 'constraints' => [
+ new Assert\Count([
+ 'max' => self::MAX_FILE_COUNT,
+ 'maxMessage' => 'form_error.refund_request.max_files',
+ ]),
+ new Assert\All([
+ new Assert\File([
+ 'maxSize' => self::MAX_FILE_SIZE,
+ 'maxSizeMessage' => 'form_error.refund_request.file_size',
+ 'mimeTypes' => self::ALLOWED_MIME_TYPES,
+ 'mimeTypesMessage' => 'form_error.refund_request.file_type',
+ ]),
+ ]),
+ ],
+ ]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function configureOptions(OptionsResolver $resolver): void
+ {
+ $resolver->setDefaults([
+ 'data_class' => RefundRequest::class,
+ 'max_quantity' => 1,
+ ]);
+ $resolver->setAllowedTypes('max_quantity', ['int', 'string']);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function getBlockPrefix(): string
+ {
+ return 'refund_request';
+ }
+}
diff --git a/src/Eccube/Form/Type/Master/RefundRequestStatusType.php b/src/Eccube/Form/Type/Master/RefundRequestStatusType.php
new file mode 100644
index 00000000000..c660d071408
--- /dev/null
+++ b/src/Eccube/Form/Type/Master/RefundRequestStatusType.php
@@ -0,0 +1,51 @@
+setDefaults([
+ 'class' => RefundRequestStatus::class,
+ ]);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function getBlockPrefix(): string
+ {
+ return 'refund_request_status';
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public function getParent(): string
+ {
+ return MasterType::class;
+ }
+}
diff --git a/src/Eccube/Repository/Master/RefundRequestStatusRepository.php b/src/Eccube/Repository/Master/RefundRequestStatusRepository.php
new file mode 100644
index 00000000000..38d8df3d70d
--- /dev/null
+++ b/src/Eccube/Repository/Master/RefundRequestStatusRepository.php
@@ -0,0 +1,31 @@
+
+ */
+class RefundRequestStatusRepository extends AbstractRepository
+{
+ public function __construct(RegistryInterface $registry)
+ {
+ parent::__construct($registry, RefundRequestStatus::class);
+ }
+}
diff --git a/src/Eccube/Repository/RefundRequestFileRepository.php b/src/Eccube/Repository/RefundRequestFileRepository.php
new file mode 100644
index 00000000000..0b27db8496d
--- /dev/null
+++ b/src/Eccube/Repository/RefundRequestFileRepository.php
@@ -0,0 +1,30 @@
+
+ */
+class RefundRequestFileRepository extends AbstractRepository
+{
+ public function __construct(RegistryInterface $registry)
+ {
+ parent::__construct($registry, RefundRequestFile::class);
+ }
+}
diff --git a/src/Eccube/Repository/RefundRequestRepository.php b/src/Eccube/Repository/RefundRequestRepository.php
new file mode 100644
index 00000000000..92bc46050a4
--- /dev/null
+++ b/src/Eccube/Repository/RefundRequestRepository.php
@@ -0,0 +1,141 @@
+
+ */
+class RefundRequestRepository extends AbstractRepository
+{
+ public function __construct(RegistryInterface $registry)
+ {
+ parent::__construct($registry, RefundRequest::class);
+ }
+
+ /**
+ * 管理画面の検索条件から QueryBuilder を生成する.
+ *
+ * @param array $searchData
+ */
+ public function getQueryBuilderBySearchData(array $searchData): QueryBuilder
+ {
+ $qb = $this->createQueryBuilder('rr')
+ ->innerJoin('rr.Order', 'o')
+ ->innerJoin('rr.OrderItem', 'oi')
+ ->innerJoin('rr.Customer', 'c')
+ ->innerJoin('rr.RefundRequestStatus', 's');
+
+ // 複合検索(申請ID・注文番号・会員ID・会員名)
+ if (isset($searchData['multi']) && StringUtil::isNotBlank($searchData['multi'])) {
+ $multi = preg_match('/^\d{0,10}$/', (string) $searchData['multi']) ? $searchData['multi'] : null;
+ $qb->andWhere('rr.id = :multi_id OR o.order_no LIKE :multi_like OR c.id = :multi_id '
+ .'OR CONCAT(c.name01, c.name02) LIKE :multi_like')
+ ->setParameter('multi_id', $multi)
+ ->setParameter('multi_like', '%'.$this->escapeLike($searchData['multi']).'%');
+ }
+
+ // ステータス(複数選択)。Form の EntityType(multiple) は空でも ArrayCollection を返すため
+ // is_iterable は常に true。要素ゼロのまま IN 句を組むと "IN (NULL)" になり何もマッチしないので
+ // 必ず件数チェックする。
+ if (!empty($searchData['status']) && is_iterable($searchData['status']) && count($searchData['status']) > 0) {
+ $qb->andWhere($qb->expr()->in('rr.RefundRequestStatus', ':status'))
+ ->setParameter('status', $searchData['status']);
+ }
+
+ // 申請日時
+ if (!empty($searchData['create_date_start'])) {
+ $qb->andWhere('rr.create_date >= :create_date_start')
+ ->setParameter('create_date_start', $searchData['create_date_start']);
+ }
+ if (!empty($searchData['create_date_end'])) {
+ $date = clone $searchData['create_date_end'];
+ $date = $date->modify('+1 days');
+ $qb->andWhere('rr.create_date < :create_date_end')
+ ->setParameter('create_date_end', $date);
+ }
+
+ // 更新日時
+ if (!empty($searchData['update_date_start'])) {
+ $qb->andWhere('rr.update_date >= :update_date_start')
+ ->setParameter('update_date_start', $searchData['update_date_start']);
+ }
+ if (!empty($searchData['update_date_end'])) {
+ $date = clone $searchData['update_date_end'];
+ $date = $date->modify('+1 days');
+ $qb->andWhere('rr.update_date < :update_date_end')
+ ->setParameter('update_date_end', $date);
+ }
+
+ $qb->orderBy('rr.update_date', 'DESC');
+
+ return $qb;
+ }
+
+ /**
+ * 指定した注文明細に対する会員の返品申請を時系列で取得する.
+ *
+ * @return RefundRequest[]
+ */
+ public function findByOrderItemAndCustomer(OrderItem $OrderItem, Customer $Customer): array
+ {
+ return $this->createQueryBuilder('rr')
+ ->innerJoin('rr.RefundRequestStatus', 's')
+ ->leftJoin('rr.RefundRequestFiles', 'f')
+ ->addSelect('s', 'f')
+ ->where('rr.OrderItem = :OrderItem')
+ ->andWhere('rr.Customer = :Customer')
+ ->setParameter('OrderItem', $OrderItem)
+ ->setParameter('Customer', $Customer)
+ ->orderBy('rr.create_date', 'ASC')
+ ->getQuery()
+ ->getResult();
+ }
+
+ /**
+ * 会員の返品申請件数を、注文明細ID別に一括取得する(N+1回避).
+ *
+ * @return array [order_item_id => count]
+ */
+ public function getRefundRequestCountsByCustomer(Customer $Customer): array
+ {
+ $rows = $this->createQueryBuilder('rr')
+ ->select('IDENTITY(rr.OrderItem) AS order_item_id', 'COUNT(rr.id) AS cnt')
+ ->where('rr.Customer = :Customer')
+ ->setParameter('Customer', $Customer)
+ ->groupBy('rr.OrderItem')
+ ->getQuery()
+ ->getResult();
+
+ $counts = [];
+ foreach ($rows as $row) {
+ $counts[(int) $row['order_item_id']] = (int) $row['cnt'];
+ }
+
+ return $counts;
+ }
+
+ private function escapeLike(string $value): string
+ {
+ return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $value);
+ }
+}
diff --git a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml
index bd8383c3485..eac38ddfc90 100644
--- a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml
+++ b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml
@@ -10,6 +10,7 @@
- mtb_order_item_type.csv
- mtb_order_status.csv
- mtb_order_status_color.csv
+- mtb_refund_request_status.csv
- mtb_page_max.csv
- mtb_pref.csv
- mtb_product_list_max.csv
diff --git a/src/Eccube/Resource/doctrine/import_csv/en/dtb_csv.csv b/src/Eccube/Resource/doctrine/import_csv/en/dtb_csv.csv
index 08c4a5cbf69..1cd93d82184 100644
--- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_csv.csv
+++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_csv.csv
@@ -209,4 +209,5 @@ id,csv_type_id,creator_id,entity_name,field_name,reference_field_name,disp_name,
210,7,,Eccube\\Entity\\ClassCategory,id,,Class Category ID,1,1,2021-05-18 01:26:41,2021-05-18 01:26:41,csv
211,7,,Eccube\\Entity\\ClassCategory,ClassName,id,Class NameID,2,1,2021-05-18 01:26:41,2021-05-18 01:26:41,csv
212,7,,Eccube\\Entity\\ClassCategory,name,,Class Category Name,3,1,2021-05-18 01:26:41,2021-05-18 01:26:41,csv
-213,7,,Eccube\\Entity\\ClassCategory,backend_name,,Class Category Backend Name,4,1,2021-05-18 01:26:41,2021-05-18 01:26:41,csv
\ No newline at end of file
+213,7,,Eccube\\Entity\\ClassCategory,backend_name,,Class Category Backend Name,4,1,2021-05-18 01:26:41,2021-05-18 01:26:41,csv
+215,1,,Eccube\\Entity\\Product,refund_allowed,,Refund Allowed Flag,33,0,2017-03-07 10:14:00,2017-03-07 10:14:00,csv
\ No newline at end of file
diff --git a/src/Eccube/Resource/doctrine/import_csv/en/dtb_mail_template.csv b/src/Eccube/Resource/doctrine/import_csv/en/dtb_mail_template.csv
index 9d899a2ecca..91c81797fb7 100644
--- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_mail_template.csv
+++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_mail_template.csv
@@ -8,3 +8,4 @@ id,creator_id,name,file_name,mail_subject,deletable,create_date,update_date,disc
7,,Password Reminder,Mail/reset_complete_mail.twig,Your password has been changed,0,2017-03-07 10:14:52,2017-03-07 10:14:52,mailtemplate
8,,Shipping Notice,Mail/shipping_notify.twig,Your order has been shipped,0,2017-03-07 10:14:52,2017-03-07 10:14:52,mailtemplate
9,,Notification email,Mail/customer_change_notify.twig,Your account information has been changed.,0,2017-03-07 10:14:52,2017-03-07 10:14:52,mailtemplate
+10,,Refund Request Notification,Mail/refund_request_notify.twig,A refund request has been submitted,0,2017-03-07 10:14:52,2017-03-07 10:14:52,mailtemplate
diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_refund_request_status.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_refund_request_status.csv
new file mode 100644
index 00000000000..1ef09b41afb
--- /dev/null
+++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_refund_request_status.csv
@@ -0,0 +1,6 @@
+id,name,sort_no,discriminator_type
+1,New,0,refundrequeststatus
+2,Processing,1,refundrequeststatus
+3,Accepted,2,refundrequeststatus
+4,Declined,3,refundrequeststatus
+5,Information Requested,4,refundrequeststatus
diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml
index bd8383c3485..eac38ddfc90 100644
--- a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml
+++ b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml
@@ -10,6 +10,7 @@
- mtb_order_item_type.csv
- mtb_order_status.csv
- mtb_order_status_color.csv
+- mtb_refund_request_status.csv
- mtb_page_max.csv
- mtb_pref.csv
- mtb_product_list_max.csv
diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_csv.csv b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_csv.csv
index a91a0f717fa..cf24babe5b6 100644
--- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_csv.csv
+++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_csv.csv
@@ -211,3 +211,4 @@
"212","7",,"Eccube\\Entity\\ClassCategory","name",,"規格分類名","3","1","2021-05-18 01:26:41","2021-05-18 01:26:41","csv"
"213","7",,"Eccube\\Entity\\ClassCategory","backend_name",,"分類管理名","4","1","2021-05-18 01:26:41","2021-05-18 01:26:41","csv"
"214","2",,"Eccube\\Entity\\Customer","buy_total",,"購入金額","34","0","2017-03-07 10:14:00","2017-03-07 10:14:00","csv"
+"215","1",,"Eccube\\Entity\\Product","refund_allowed",,"返品許可フラグ","33","0","2017-03-07 10:14:00","2017-03-07 10:14:00","csv"
diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_mail_template.csv b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_mail_template.csv
index 8c4591668ef..552c39c4ce1 100644
--- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_mail_template.csv
+++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_mail_template.csv
@@ -8,3 +8,4 @@ id,creator_id,name,file_name,mail_subject,deletable,create_date,update_date,disc
"7",,"パスワードリマインダー","Mail/reset_complete_mail.twig","パスワード変更のお知らせ",0,"2017-03-07 10:14:52","2017-03-07 10:14:52","mailtemplate"
"8",,"出荷通知メール","Mail/shipping_notify.twig","商品出荷のお知らせ",0,"2017-03-07 10:14:52","2017-03-07 10:14:52","mailtemplate"
"9",,"会員情報変更通知メール","Mail/customer_change_notify.twig","会員情報変更のお知らせ",0,"2017-03-07 10:14:52","2017-03-07 10:14:52","mailtemplate"
+"10",,"返品申請通知メール","Mail/refund_request_notify.twig","返品申請を受け付けました",0,"2017-03-07 10:14:52","2017-03-07 10:14:52","mailtemplate"
diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_refund_request_status.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_refund_request_status.csv
new file mode 100644
index 00000000000..b869bab0b1f
--- /dev/null
+++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_refund_request_status.csv
@@ -0,0 +1,6 @@
+id,name,sort_no,discriminator_type
+1,新規申請,0,refundrequeststatus
+2,処理中,1,refundrequeststatus
+3,承認済,2,refundrequeststatus
+4,却下,3,refundrequeststatus
+5,追加情報依頼,4,refundrequeststatus
diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml
index 3807b8b6380..299d4522baf 100644
--- a/src/Eccube/Resource/locale/messages.en.yaml
+++ b/src/Eccube/Resource/locale/messages.en.yaml
@@ -274,6 +274,32 @@ front.mypage.withdraw_complete_message__body: |
front.mypage.customer.notify_title: Change customer information
front.mypage.delivery.notify_title: Change delivery information
+#------------------------------------------------------------------------------------
+# Refund Request (Front)
+#------------------------------------------------------------------------------------
+front.mypage.refund_request.title: Refund Request
+front.mypage.refund_request.confirm_title: Confirm Refund Request
+front.mypage.refund_request.complete_title: Refund Request Complete
+front.mypage.refund_request.item_history_title: Refund Request History
+front.mypage.refund_request.quantity: Quantity
+front.mypage.refund_request.quantity_max: "Up to %max% items"
+front.mypage.refund_request.reason: Reason
+front.mypage.refund_request.reason_placeholder: Please enter the reason for refund
+front.mypage.refund_request.files: Evidence Files
+front.mypage.refund_request.files_help: "You can attach up to 3 files (JPEG, PNG, GIF, WebP, MP4, MOV, AVI). Max 15MB each."
+front.mypage.refund_request.confirm: Confirm
+front.mypage.refund_request.submit: Submit Request
+front.mypage.refund_request.complete_message: Your refund request has been submitted
+front.mypage.refund_request.complete_description: We will review your request and contact you shortly. Please wait.
+front.mypage.refund_request.back_to_mypage: Back to My Page
+front.mypage.refund_request.request_date: Request Date
+front.mypage.refund_request.status: Status
+front.mypage.refund_request.no_history: No refund request history.
+front.mypage.refund_request.apply: Refund Request
+front.mypage.refund_request.history: Request History
+front.mypage.refund_request.error: Failed to process the refund request.
+front.mypage.refund_request.invalid_token: Invalid request. Please try again.
+
#------------------------------------------------------------------------------------
# Products
#------------------------------------------------------------------------------------
@@ -966,6 +992,35 @@ admin.order.shipping_csv.tracking_number_description: Enter alphanumeric charact
admin.order.shipping_csv.shipping_date_col: Shipping Date
admin.order.shipping_csv.shipping_date_description: Enter the shipping date in the format of MM/DD/YYYY
+#------------------------------------------------------------------------------------
+# Refund Request (Admin)
+#------------------------------------------------------------------------------------
+admin.order.refund_request.list_title: Refund Requests
+admin.order.refund_request.edit_title: Refund Request Detail
+admin.order.refund_request.detail: Request Detail
+admin.order.refund_request.operation: Operation
+admin.order.refund_request.id: Request ID
+admin.order.refund_request.order_no: Order No
+admin.order.refund_request.customer: Customer
+admin.order.refund_request.product: Product
+admin.order.refund_request.status: Status
+admin.order.refund_request.quantity: Quantity
+admin.order.refund_request.reason: Reason
+admin.order.refund_request.create_date: Request Date
+admin.order.refund_request.create_date__start: Request Date (from)
+admin.order.refund_request.create_date__end: Request Date (to)
+admin.order.refund_request.update_date: Updated
+admin.order.refund_request.update_date__start: Updated (from)
+admin.order.refund_request.update_date__end: Updated (to)
+admin.order.refund_request.files: Attached Files
+admin.order.refund_request.admin_note: Admin Note
+admin.order.refund_request.transition: Change Status
+admin.order.refund_request.transition_placeholder: -- No Change --
+admin.order.refund_request.transition_error: Failed to change status.
+admin.order.refund_request.back_to_list: Back to list
+admin.order.refund_request.multi_search_label: Search by Request ID, Order No, Customer ID, Customer Name
+admin.order.refund_request.csv_export: CSV Export
+
#------------------------------------------------------------------------------------
# Customer
#------------------------------------------------------------------------------------
diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml
index 96445e787c0..834afc4dfbd 100644
--- a/src/Eccube/Resource/locale/messages.ja.yaml
+++ b/src/Eccube/Resource/locale/messages.ja.yaml
@@ -274,6 +274,32 @@ front.mypage.withdraw_complete_message__body: |
front.mypage.customer.notify_title: 会員情報編集
front.mypage.delivery.notify_title: お届け先情報編集
+#------------------------------------------------------------------------------------
+# 返品申請(フロント)
+#------------------------------------------------------------------------------------
+front.mypage.refund_request.title: 返品申請
+front.mypage.refund_request.confirm_title: 返品申請内容確認
+front.mypage.refund_request.complete_title: 返品申請完了
+front.mypage.refund_request.item_history_title: 返品申請履歴
+front.mypage.refund_request.quantity: 返品希望数量
+front.mypage.refund_request.quantity_max: "最大 %max% 個まで申請できます"
+front.mypage.refund_request.reason: 返品理由
+front.mypage.refund_request.reason_placeholder: 返品理由を入力してください
+front.mypage.refund_request.files: 証拠ファイル
+front.mypage.refund_request.files_help: "画像(JPEG, PNG, GIF, WebP)または動画(MP4, MOV, AVI)を最大3件まで添付できます。1ファイル15MBまで。"
+front.mypage.refund_request.confirm: 確認画面へ
+front.mypage.refund_request.submit: 申請する
+front.mypage.refund_request.complete_message: 返品申請を受け付けました
+front.mypage.refund_request.complete_description: 返品申請の内容を確認のうえ、ご連絡いたします。しばらくお待ちください。
+front.mypage.refund_request.back_to_mypage: マイページへ戻る
+front.mypage.refund_request.request_date: 申請日時
+front.mypage.refund_request.status: ステータス
+front.mypage.refund_request.no_history: 返品申請履歴はありません。
+front.mypage.refund_request.apply: 返品申請
+front.mypage.refund_request.history: 返品申請履歴
+front.mypage.refund_request.error: 返品申請の処理に失敗しました。
+front.mypage.refund_request.invalid_token: 不正なリクエストです。お手数ですがやり直してください。
+
#------------------------------------------------------------------------------------
# 商品
#------------------------------------------------------------------------------------
@@ -965,6 +991,35 @@ admin.order.shipping_csv.tracking_number_description: 半角英数字かハイ
admin.order.shipping_csv.shipping_date_col: 出荷日
admin.order.shipping_csv.shipping_date_description: "出荷日を「YYYY-MM-DD」の形式で設定"
+#------------------------------------------------------------------------------------
+# 返品申請(管理画面)
+#------------------------------------------------------------------------------------
+admin.order.refund_request.list_title: 返品申請管理
+admin.order.refund_request.edit_title: 返品申請詳細
+admin.order.refund_request.detail: 申請内容
+admin.order.refund_request.operation: 操作
+admin.order.refund_request.id: 申請ID
+admin.order.refund_request.order_no: 注文番号
+admin.order.refund_request.customer: 会員
+admin.order.refund_request.product: 商品
+admin.order.refund_request.status: ステータス
+admin.order.refund_request.quantity: 返品数量
+admin.order.refund_request.reason: 返品理由
+admin.order.refund_request.create_date: 申請日時
+admin.order.refund_request.create_date__start: 申請日(開始)
+admin.order.refund_request.create_date__end: 申請日(終了)
+admin.order.refund_request.update_date: 更新日時
+admin.order.refund_request.update_date__start: 更新日(開始)
+admin.order.refund_request.update_date__end: 更新日(終了)
+admin.order.refund_request.files: 添付ファイル
+admin.order.refund_request.admin_note: 管理者メモ
+admin.order.refund_request.transition: ステータス変更
+admin.order.refund_request.transition_placeholder: -- 変更なし --
+admin.order.refund_request.transition_error: ステータスの変更に失敗しました。
+admin.order.refund_request.back_to_list: 返品申請一覧へ戻る
+admin.order.refund_request.multi_search_label: 申請ID・注文番号・会員ID・会員名で検索
+admin.order.refund_request.csv_export: CSVエクスポート
+
#------------------------------------------------------------------------------------
# 会員
#------------------------------------------------------------------------------------
diff --git a/src/Eccube/Resource/locale/validators.en.yaml b/src/Eccube/Resource/locale/validators.en.yaml
index 188d0b50b08..7b0193f42f2 100644
--- a/src/Eccube/Resource/locale/validators.en.yaml
+++ b/src/Eccube/Resource/locale/validators.en.yaml
@@ -72,3 +72,7 @@ form.type.add.quantity: Please enter more than 1.
form.type.select.select_is_future_or_now_date: Invalid Date of Birth.
form.type.admin.nottrackingnumberstyle: Tracking No. entry must be alphanumeric chars and hypens.
form.type.float.invalid: Only numbers and decimal points are accepted.
+form_error.refund_request.quantity_exceed: Quantity must be {{ compared_value }} or less.
+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.
diff --git a/src/Eccube/Resource/locale/validators.ja.yaml b/src/Eccube/Resource/locale/validators.ja.yaml
index 341700bddea..c57fd0b769f 100644
--- a/src/Eccube/Resource/locale/validators.ja.yaml
+++ b/src/Eccube/Resource/locale/validators.ja.yaml
@@ -75,3 +75,7 @@ form.type.add.quantity: 1以上で入力してください。
form.type.select.select_is_future_or_now_date: 生年月日が不正な日付です。
form.type.admin.nottrackingnumberstyle: 送り状番号は半角英数字かハイフンのみを入力してください。
form.type.float.invalid: 数字と小数点のみ入力できます。
+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)を添付してください。
diff --git a/src/Eccube/Resource/template/admin/Order/refund_request.twig b/src/Eccube/Resource/template/admin/Order/refund_request.twig
new file mode 100644
index 00000000000..e05a6756e0a
--- /dev/null
+++ b/src/Eccube/Resource/template/admin/Order/refund_request.twig
@@ -0,0 +1,172 @@
+{#
+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.
+#}
+{% extends '@admin/default_frame.twig' %}
+{% set menus = ['order', 'refund_request'] %}
+
+{% block title %}{{ 'admin.order.refund_request.list_title'|trans }}{% endblock %}
+{% block sub_title %}{{ 'admin.order.order_management'|trans }}{% endblock %}
+
+{% form_theme searchForm '@admin/Form/bootstrap_4_layout.html.twig' %}
+{% block javascript %}
+{% endblock %}
+
+{% block main %}
+
+
+ {% if pagination is not empty and pagination|length > 0 %}
+
+
+
+
+ {{ 'admin.common.search_result'|trans({ '%count%': pagination.getTotalItemCount }) }}
+
+
+
+
+
+
+
+
+
+ {{ 'admin.order.refund_request.id'|trans }}
+ {{ 'admin.order.refund_request.order_no'|trans }}
+ {{ 'admin.order.refund_request.customer'|trans }}
+ {{ 'admin.order.refund_request.product'|trans }}
+ {{ 'admin.order.refund_request.status'|trans }}
+ {{ 'admin.order.refund_request.create_date'|trans }}
+
+
+
+
+ {% for RefundRequest in pagination %}
+
+ {{ RefundRequest.id }}
+
+ {{ RefundRequest.Order.order_no }}
+
+
+ {% if RefundRequest.Customer %}
+ {{ RefundRequest.Customer.name01 }} {{ RefundRequest.Customer.name02 }}
+ {% endif %}
+
+ {{ RefundRequest.OrderItem.productName }}
+ {{ RefundRequest.RefundRequestStatus }}
+ {{ RefundRequest.create_date|date_sec }}
+
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+
+
+ {% if pagination.totalItemCount > 0 %}
+ {% include "@admin/pager.twig" with { 'pages': pagination.getPaginationData, 'routes': 'admin_refund_request_page' } %}
+ {% endif %}
+
+
+
+ {% elseif has_errors %}
+ {% else %}
+
+
+
+
+
{{ 'admin.common.search_no_result'|trans }}
+
{{ 'admin.common.search_try_change_condition'|trans }}
+
{{ 'admin.common.search_try_advanced_search'|trans }}
+
+
+
+
+ {% endif %}
+{% endblock %}
diff --git a/src/Eccube/Resource/template/admin/Order/refund_request_edit.twig b/src/Eccube/Resource/template/admin/Order/refund_request_edit.twig
new file mode 100644
index 00000000000..0eb7b93e4a2
--- /dev/null
+++ b/src/Eccube/Resource/template/admin/Order/refund_request_edit.twig
@@ -0,0 +1,145 @@
+{#
+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.
+#}
+{% extends '@admin/default_frame.twig' %}
+{% set menus = ['order', 'refund_request'] %}
+
+{% block title %}{{ 'admin.order.refund_request.edit_title'|trans }}{% endblock %}
+{% block sub_title %}{{ 'admin.order.order_management'|trans }}{% endblock %}
+
+{% form_theme form '@admin/Form/bootstrap_4_layout.html.twig' %}
+{% block javascript %}
+{% endblock %}
+
+{% block main %}
+
+
+
+
+
+
{{ 'admin.order.refund_request.id'|trans }}
+
{{ RefundRequest.id }}
+
+
+
{{ 'admin.order.refund_request.status'|trans }}
+
{{ RefundRequest.RefundRequestStatus }}
+
+
+
{{ 'admin.order.refund_request.order_no'|trans }}
+
+
+
+
{{ 'admin.order.refund_request.customer'|trans }}
+
+
+
+
{{ 'admin.order.refund_request.product'|trans }}
+
{{ RefundRequest.OrderItem.productName }}
+
+
+
{{ 'admin.order.refund_request.quantity'|trans }}
+
{{ RefundRequest.quantity }}
+
+
+
{{ 'admin.order.refund_request.reason'|trans }}
+
{{ RefundRequest.reason|nl2br }}
+
+
+
{{ 'admin.order.refund_request.create_date'|trans }}
+
{{ RefundRequest.create_date|date_sec }}
+
+
+
{{ 'admin.order.refund_request.update_date'|trans }}
+
{{ RefundRequest.update_date|date_sec }}
+
+
+ {% if RefundRequest.RefundRequestFiles is not empty %}
+
+
{{ 'admin.order.refund_request.files'|trans }}
+
+ {% for File in RefundRequest.RefundRequestFiles %}
+
+ {% if File.isImage %}
+
+
+
+ {% elseif File.isVideo %}
+
+
+
+ {% else %}
+
{{ File.file_name }}
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ 'admin.common.save'|trans }}
+
+
+
+
+
+
+{% endblock %}
diff --git a/src/Eccube/Resource/template/default/Mail/refund_request_notify.twig b/src/Eccube/Resource/template/default/Mail/refund_request_notify.twig
new file mode 100644
index 00000000000..a079c42dc90
--- /dev/null
+++ b/src/Eccube/Resource/template/default/Mail/refund_request_notify.twig
@@ -0,0 +1,39 @@
+{#
+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.
+#}
+{% autoescape 'safe_textmail' %}
+返品申請を受け付けました。
+
+************************************************
+ 返品申請内容
+************************************************
+
+申請日時:{{ RefundRequest.create_date|date_sec }}
+注文番号:{{ RefundRequest.Order.order_no }}
+会員名:{{ RefundRequest.Customer.name01 }} {{ RefundRequest.Customer.name02 }}
+
+商品名:{{ RefundRequest.OrderItem.productName }}
+{% if RefundRequest.OrderItem.ProductClass is not null %}
+{% if RefundRequest.OrderItem.ProductClass.ClassCategory1 is not null %}規格1:{{ RefundRequest.OrderItem.ProductClass.ClassCategory1 }}
+{% endif %}
+{% if RefundRequest.OrderItem.ProductClass.ClassCategory2 is not null %}規格2:{{ RefundRequest.OrderItem.ProductClass.ClassCategory2 }}
+{% endif %}
+{% endif %}
+返品希望数量:{{ RefundRequest.quantity }}
+
+返品理由:
+{{ RefundRequest.reason }}
+
+{% if RefundRequest.RefundRequestFiles is not empty %}
+添付ファイル数:{{ RefundRequest.RefundRequestFiles|length }}件
+{% endif %}
+
+※ このメールは自動送信です。管理画面より返品申請の詳細をご確認ください。
+{% endautoescape %}
diff --git a/src/Eccube/Resource/template/default/Mypage/history.twig b/src/Eccube/Resource/template/default/Mypage/history.twig
index 7ad1b0fc34d..667c729aa3d 100644
--- a/src/Eccube/Resource/template/default/Mypage/history.twig
+++ b/src/Eccube/Resource/template/default/Mypage/history.twig
@@ -95,6 +95,14 @@ file that was distributed with this source code.
{{ 'front.mypage.current_price'|trans }}{{ orderItem.productClass.price02IncTax|price }}
{% set remessage = true %}
{% endif %}
+ {% if Order.OrderStatus.id == constant('Eccube\\Entity\\Master\\OrderStatus::DELIVERED') and orderItem.Product is not null and orderItem.Product.isRefundAllowed %}
+
+ {% endif %}
diff --git a/src/Eccube/Resource/template/default/Mypage/refund_request.twig b/src/Eccube/Resource/template/default/Mypage/refund_request.twig
new file mode 100644
index 00000000000..30f8be0daf2
--- /dev/null
+++ b/src/Eccube/Resource/template/default/Mypage/refund_request.twig
@@ -0,0 +1,97 @@
+{#
+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.
+#}
+{% extends 'default_frame.twig' %}
+
+{% set mypageno = 'index' %}
+
+{% set body_class = 'mypage' %}
+
+{% block main %}
+
+
+
+{% endblock %}
diff --git a/src/Eccube/Resource/template/default/Mypage/refund_request_complete.twig b/src/Eccube/Resource/template/default/Mypage/refund_request_complete.twig
new file mode 100644
index 00000000000..56d684a2910
--- /dev/null
+++ b/src/Eccube/Resource/template/default/Mypage/refund_request_complete.twig
@@ -0,0 +1,39 @@
+{#
+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.
+#}
+{% extends 'default_frame.twig' %}
+
+{% set mypageno = 'index' %}
+
+{% set body_class = 'mypage' %}
+
+{% block main %}
+
+
+
+{% endblock %}
diff --git a/src/Eccube/Resource/template/default/Mypage/refund_request_confirm.twig b/src/Eccube/Resource/template/default/Mypage/refund_request_confirm.twig
new file mode 100644
index 00000000000..3387126bd58
--- /dev/null
+++ b/src/Eccube/Resource/template/default/Mypage/refund_request_confirm.twig
@@ -0,0 +1,89 @@
+{#
+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.
+#}
+{% extends 'default_frame.twig' %}
+
+{% set mypageno = 'index' %}
+
+{% set body_class = 'mypage' %}
+
+{% block main %}
+
+
+
+{% endblock %}
diff --git a/src/Eccube/Resource/template/default/Mypage/refund_request_item_history.twig b/src/Eccube/Resource/template/default/Mypage/refund_request_item_history.twig
new file mode 100644
index 00000000000..d77c0825a99
--- /dev/null
+++ b/src/Eccube/Resource/template/default/Mypage/refund_request_item_history.twig
@@ -0,0 +1,110 @@
+{#
+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.
+#}
+{% extends 'default_frame.twig' %}
+
+{% set mypageno = 'index' %}
+
+{% set body_class = 'mypage' %}
+
+{% block main %}
+
+
+
+{% endblock %}
diff --git a/src/Eccube/Service/MailService.php b/src/Eccube/Service/MailService.php
index 5a5328fec2e..941b96e86fe 100644
--- a/src/Eccube/Service/MailService.php
+++ b/src/Eccube/Service/MailService.php
@@ -21,6 +21,7 @@
use Eccube\Entity\MailTemplate;
use Eccube\Entity\Order;
use Eccube\Entity\OrderItem;
+use Eccube\Entity\RefundRequest;
use Eccube\Entity\Shipping;
use Eccube\Event\EccubeEvents;
use Eccube\Event\EventArgs;
@@ -858,4 +859,65 @@ public function convertRFCViolatingEmail(string $email): Address
return new Address($email);
}
+
+ /**
+ * 管理者へ返品申請の通知メールを送信する.
+ */
+ public function sendRefundRequestNotifyMail(RefundRequest $RefundRequest): void
+ {
+ log_info('返品申請通知メール送信開始', ['id' => $RefundRequest->getId()]);
+
+ $MailTemplate = $this->mailTemplateRepository->find($this->eccubeConfig['eccube_refund_request_notify_mail_template_id']);
+
+ $body = $this->twig->render($MailTemplate->getFileName(), [
+ 'RefundRequest' => $RefundRequest,
+ ]);
+
+ $message = (new Email())
+ ->subject('['.$this->BaseInfo->getShopName().'] '.$MailTemplate->getMailSubject())
+ ->from(new Address($this->BaseInfo->getEmail01(), $this->BaseInfo->getShopName()))
+ ->to($this->BaseInfo->getEmail01())
+ ->replyTo($this->BaseInfo->getEmail03())
+ ->returnPath($this->BaseInfo->getEmail04());
+
+ $htmlFileName = $this->getHtmlTemplate($MailTemplate->getFileName());
+ if (!is_null($htmlFileName)) {
+ $htmlBody = $this->twig->render($htmlFileName, [
+ 'RefundRequest' => $RefundRequest,
+ ]);
+ $message
+ ->text($body)
+ ->html($htmlBody);
+ } else {
+ $message->text($body);
+ }
+
+ $event = new EventArgs(
+ [
+ 'message' => $message,
+ 'RefundRequest' => $RefundRequest,
+ 'MailTemplate' => $MailTemplate,
+ 'BaseInfo' => $this->BaseInfo,
+ ]
+ );
+ $this->eventDispatcher->dispatch($event, EccubeEvents::MAIL_REFUND_REQUEST);
+
+ $message = $event->getArgument('message');
+
+ try {
+ $this->mailer->send($message);
+
+ $MailHistory = new MailHistory();
+ $MailHistory->setMailSubject($message->getSubject())
+ ->setMailBody($message->getTextBody())
+ ->setOrder($RefundRequest->getOrder())
+ ->setSendDate(new \DateTime());
+ $this->mailHistoryRepository->save($MailHistory);
+ } catch (TransportExceptionInterface $e) {
+ log_error('返品申請通知メールの送信に失敗しました。', [
+ 'RefundRequest' => $RefundRequest->getId(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
}
diff --git a/src/Eccube/Service/RefundRequestService.php b/src/Eccube/Service/RefundRequestService.php
new file mode 100644
index 00000000000..180c85070e2
--- /dev/null
+++ b/src/Eccube/Service/RefundRequestService.php
@@ -0,0 +1,206 @@
+ は HTML 仕様上 value を再描画できず確認画面を跨いで再 POST できないため、
+ * 入力 → 確認の段階で一時領域(var/refund_request/tmp/{sessionId}/)に move し、
+ * セッションに参照キー(ファイル名と元 MIME/サイズ)を保持して complete 時に本領域へ rename する。
+ */
+class RefundRequestService
+{
+ public function __construct(
+ private readonly EntityManagerInterface $entityManager,
+ private readonly RefundRequestStatusRepository $refundRequestStatusRepository,
+ private readonly RefundRequestStateMachine $refundRequestStateMachine,
+ private readonly MailService $mailService,
+ private readonly EventDispatcherInterface $eventDispatcher,
+ private readonly EccubeConfig $eccubeConfig,
+ ) {
+ }
+
+ /**
+ * アップロードされたエビデンスファイルを一時領域に保存する.
+ *
+ * 戻り値はセッションで保持する参照情報。
+ *
+ * @return array{key: string, client_name: string, mime_type: string, size: int}
+ */
+ public function saveTempFile(UploadedFile $uploadedFile, string $sessionId): array
+ {
+ $dir = $this->getTempDir($sessionId);
+ if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
+ throw new \RuntimeException(sprintf('Failed to create temp dir: %s', $dir));
+ }
+
+ $extension = $uploadedFile->guessExtension() ?: $uploadedFile->getClientOriginalExtension();
+ $key = bin2hex(random_bytes(16)).'.'.$extension;
+ $clientName = $uploadedFile->getClientOriginalName();
+ $mimeType = (string) $uploadedFile->getMimeType();
+ $size = (int) $uploadedFile->getSize();
+
+ $uploadedFile->move($dir, $key);
+
+ return [
+ 'key' => $key,
+ 'client_name' => $clientName,
+ 'mime_type' => $mimeType,
+ 'size' => $size,
+ ];
+ }
+
+ /**
+ * 一時領域のファイルの実パスを返す(所有セッションのもののみ).
+ */
+ public function getTempFilePath(string $sessionId, string $key): ?string
+ {
+ $dir = $this->getTempDir($sessionId);
+ $path = $dir.'/'.$key;
+ $real = realpath($path);
+ $realDir = realpath($dir);
+ if ($real === false || $realDir === false || !str_starts_with($real, $realDir.DIRECTORY_SEPARATOR)) {
+ return null;
+ }
+
+ return $real;
+ }
+
+ /**
+ * 一時領域のファイルを削除する.
+ */
+ public function removeTempFile(string $sessionId, string $key): void
+ {
+ $real = $this->getTempFilePath($sessionId, $key);
+ if ($real !== null) {
+ @unlink($real);
+ }
+ }
+
+ /**
+ * 返品申請を確定する.
+ *
+ * セッションに保持していた一時ファイルを本領域へ移動して RefundRequest にひも付け、
+ * ステータスを「新規申請」に設定して永続化したのち、管理者へ通知メールを送信する。
+ *
+ * @param list $tempFiles
+ */
+ public function createRefundRequest(RefundRequest $RefundRequest, array $tempFiles, string $sessionId): RefundRequest
+ {
+ $NewStatus = $this->refundRequestStatusRepository->find(RefundRequestStatus::NEW);
+ $RefundRequest->setRefundRequestStatus($NewStatus);
+
+ $finalDir = $this->eccubeConfig['eccube_save_refund_request_file_dir'];
+ if (!is_dir($finalDir) && !@mkdir($finalDir, 0755, true) && !is_dir($finalDir)) {
+ throw new \RuntimeException(sprintf('Failed to create save dir: %s', $finalDir));
+ }
+
+ $sortNo = 1;
+ foreach ($tempFiles as $info) {
+ $tempPath = $this->getTempFilePath($sessionId, $info['key']);
+ if ($tempPath === null) {
+ continue;
+ }
+ $finalPath = $finalDir.'/'.$info['key'];
+ if (!@rename($tempPath, $finalPath)) {
+ throw new \RuntimeException(sprintf('Failed to move temp file: %s', $info['key']));
+ }
+ $File = new RefundRequestFile();
+ $File->setFileName($info['key'])
+ ->setMimeType($info['mime_type'])
+ ->setFileSize($info['size'])
+ ->setSortNo($sortNo++);
+ $RefundRequest->addRefundRequestFile($File);
+ }
+
+ $this->entityManager->persist($RefundRequest);
+ $this->entityManager->flush();
+
+ $this->cleanupTempDir($sessionId);
+
+ $this->mailService->sendRefundRequestNotifyMail($RefundRequest);
+
+ return $RefundRequest;
+ }
+
+ /**
+ * ステータスを遷移させる.
+ *
+ * @throws \InvalidArgumentException 不正な遷移の場合
+ */
+ public function changeStatus(RefundRequest $RefundRequest, string $transition): void
+ {
+ $PreviousStatus = $RefundRequest->getRefundRequestStatus();
+
+ $this->refundRequestStateMachine->applyTransition($RefundRequest, $transition);
+ $this->entityManager->flush();
+
+ $event = new RefundRequestEvent($RefundRequest, $PreviousStatus, $RefundRequest->getRefundRequestStatus());
+ $this->eventDispatcher->dispatch($event, EccubeEvents::REFUND_REQUEST_STATUS_CHANGE);
+ }
+
+ /**
+ * 指定した遷移が実行可能かどうかを判定する.
+ */
+ public function canApplyTransition(RefundRequest $RefundRequest, string $transition): bool
+ {
+ return $this->refundRequestStateMachine->can($RefundRequest, $transition);
+ }
+
+ /**
+ * 現在のステータスから実行可能な遷移を取得する.
+ *
+ * @return array [遷移名 => 遷移先ステータス]
+ */
+ public function getAvailableTransitions(RefundRequest $RefundRequest): array
+ {
+ return $this->refundRequestStateMachine->getAvailableTransitions($RefundRequest);
+ }
+
+ private function getTempDir(string $sessionId): string
+ {
+ $safeId = preg_replace('/[^A-Za-z0-9_-]/', '', $sessionId) ?: 'anon';
+
+ return rtrim((string) $this->eccubeConfig['eccube_temp_refund_request_file_dir'], '/').'/'.$safeId;
+ }
+
+ private function cleanupTempDir(string $sessionId): void
+ {
+ $dir = $this->getTempDir($sessionId);
+ if (!is_dir($dir)) {
+ return;
+ }
+ foreach (glob($dir.'/*') ?: [] as $file) {
+ @unlink($file);
+ }
+ @rmdir($dir);
+ }
+}
diff --git a/src/Eccube/Service/RefundRequestStateMachine.php b/src/Eccube/Service/RefundRequestStateMachine.php
new file mode 100644
index 00000000000..87f1d02a755
--- /dev/null
+++ b/src/Eccube/Service/RefundRequestStateMachine.php
@@ -0,0 +1,118 @@
+newContext($RefundRequest);
+ if (!$this->_refundRequestStateMachine->can($context, $transition)) {
+ throw new \InvalidArgumentException(sprintf('Cannot apply transition "%s".', $transition));
+ }
+ $this->_refundRequestStateMachine->apply($context, $transition);
+ }
+
+ /**
+ * 指定した遷移を実行できるかどうかを判定する.
+ */
+ public function can(RefundRequest $RefundRequest, string $transition): bool
+ {
+ if (!$RefundRequest->getRefundRequestStatus()) {
+ return false;
+ }
+
+ return $this->_refundRequestStateMachine->can($this->newContext($RefundRequest), $transition);
+ }
+
+ /**
+ * 現在のステータスから実行可能な遷移を取得する.
+ *
+ * @return array [遷移名 => 遷移先ステータス]
+ */
+ public function getAvailableTransitions(RefundRequest $RefundRequest): array
+ {
+ if (!$RefundRequest->getRefundRequestStatus()) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($this->_refundRequestStateMachine->getEnabledTransitions($this->newContext($RefundRequest)) as $transition) {
+ $toId = (int) $transition->getTos()[0];
+ $Status = $this->refundRequestStatusRepository->find($toId);
+ if ($Status instanceof RefundRequestStatus) {
+ $result[$transition->getName()] = $Status;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ #[\Override]
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ 'workflow.refund_request.completed' => ['onCompleted'],
+ ];
+ }
+
+ /**
+ * 申請ステータスを再設定する.
+ * StateMachine による遷移完了時には marking(id)が変更されるだけなので、
+ * RefundRequestStatus エンティティを設定し直す.
+ */
+ public function onCompleted(Event $event): void
+ {
+ /** @var RefundRequestStateMachineContext $context */
+ $context = $event->getSubject();
+ $RefundRequest = $context->getRefundRequest();
+ $CompletedStatus = $this->refundRequestStatusRepository->find((int) $context->getStatus());
+ $RefundRequest->setRefundRequestStatus($CompletedStatus);
+ }
+
+ private function newContext(RefundRequest $RefundRequest): RefundRequestStateMachineContext
+ {
+ $status = $RefundRequest->getRefundRequestStatus();
+ $statusId = $status ? (string) $status->getId() : '';
+
+ return new RefundRequestStateMachineContext($statusId, $RefundRequest);
+ }
+}
diff --git a/src/Eccube/Service/RefundRequestStateMachineContext.php b/src/Eccube/Service/RefundRequestStateMachineContext.php
new file mode 100644
index 00000000000..df3be6e67cb
--- /dev/null
+++ b/src/Eccube/Service/RefundRequestStateMachineContext.php
@@ -0,0 +1,60 @@
+status;
+ }
+
+ public function setStatus(string $status): void
+ {
+ $this->status = $status;
+ }
+
+ public function getRefundRequest(): RefundRequest
+ {
+ return $this->RefundRequest;
+ }
+
+ /**
+ * Alias of getStatus()
+ */
+ public function getMarking(): string
+ {
+ return $this->getStatus();
+ }
+
+ /**
+ * Alias of setStatus()
+ */
+ public function setMarking(string $status): void
+ {
+ $this->setStatus($status);
+ }
+}
diff --git a/tests/Eccube/Tests/Form/Type/Admin/SearchRefundRequestTypeTest.php b/tests/Eccube/Tests/Form/Type/Admin/SearchRefundRequestTypeTest.php
new file mode 100644
index 00000000000..99a6f0781af
--- /dev/null
+++ b/tests/Eccube/Tests/Form/Type/Admin/SearchRefundRequestTypeTest.php
@@ -0,0 +1,65 @@
+form = $this->formFactory
+ ->createBuilder(SearchRefundRequestType::class)
+ ->getForm();
+ }
+
+ public function testValidEmptyData(): void
+ {
+ $this->form->submit([]);
+ $this->assertTrue($this->form->isValid());
+ }
+
+ public function testValidMulti(): void
+ {
+ $this->form->submit([
+ 'multi' => 'テスト',
+ ]);
+ $this->assertTrue($this->form->isValid());
+ }
+
+ public function testValidStatus(): void
+ {
+ $this->form->submit([
+ 'status' => ['1', '2'],
+ ]);
+ $this->assertTrue($this->form->isValid());
+ }
+
+ public function testFormHasExpectedFields(): void
+ {
+ $this->assertTrue($this->form->has('multi'));
+ $this->assertTrue($this->form->has('status'));
+ $this->assertTrue($this->form->has('create_date_start'));
+ $this->assertTrue($this->form->has('create_date_end'));
+ $this->assertTrue($this->form->has('update_date_start'));
+ $this->assertTrue($this->form->has('update_date_end'));
+ }
+}
diff --git a/tests/Eccube/Tests/Form/Type/Front/RefundRequestTypeTest.php b/tests/Eccube/Tests/Form/Type/Front/RefundRequestTypeTest.php
new file mode 100644
index 00000000000..01ccd52d36d
--- /dev/null
+++ b/tests/Eccube/Tests/Form/Type/Front/RefundRequestTypeTest.php
@@ -0,0 +1,229 @@
+form = $this->formFactory
+ ->createBuilder(RefundRequestType::class, null, [
+ 'csrf_protection' => false,
+ 'max_quantity' => 10,
+ ])
+ ->getForm();
+ }
+
+ public function testValidData(): void
+ {
+ $this->form->submit([
+ 'quantity' => '1',
+ 'reason' => 'テスト返品理由',
+ ]);
+
+ $this->assertTrue($this->form->isValid());
+ }
+
+ public function testValidMaxQuantity(): void
+ {
+ $this->form->submit([
+ 'quantity' => '10',
+ 'reason' => 'テスト返品理由',
+ ]);
+
+ $this->assertTrue($this->form->isValid());
+ }
+
+ public function testInvalidQuantityZero(): void
+ {
+ $this->form->submit([
+ 'quantity' => '0',
+ 'reason' => 'テスト返品理由',
+ ]);
+
+ $this->assertFalse($this->form->isValid());
+ }
+
+ public function testInvalidQuantityExceed(): void
+ {
+ $this->form->submit([
+ 'quantity' => '11',
+ 'reason' => 'テスト返品理由',
+ ]);
+
+ $this->assertFalse($this->form->isValid());
+ }
+
+ public function testInvalidQuantityNegative(): void
+ {
+ $this->form->submit([
+ 'quantity' => '-1',
+ 'reason' => 'テスト返品理由',
+ ]);
+
+ $this->assertFalse($this->form->isValid());
+ }
+
+ public function testInvalidReasonBlank(): void
+ {
+ $this->form->submit([
+ 'quantity' => '1',
+ 'reason' => '',
+ ]);
+
+ $this->assertFalse($this->form->isValid());
+ }
+
+ public function testInvalidReasonTooLong(): void
+ {
+ $this->form->submit([
+ 'quantity' => '1',
+ 'reason' => str_repeat('あ', 4001),
+ ]);
+
+ $this->assertFalse($this->form->isValid());
+ }
+
+ public function testValidReasonMaxLength(): void
+ {
+ $this->form->submit([
+ 'quantity' => '1',
+ 'reason' => str_repeat('あ', 4000),
+ ]);
+
+ $this->assertTrue($this->form->isValid());
+ }
+
+ public function testInvalidQuantityBlank(): void
+ {
+ $this->form->submit([
+ 'quantity' => '',
+ 'reason' => 'テスト返品理由',
+ ]);
+
+ $this->assertFalse($this->form->isValid());
+ }
+
+ public function testValidWithFiles(): void
+ {
+ $tmpFile = tempnam(sys_get_temp_dir(), 'refund_type_').'.gif';
+ // GIF89a ヘッダ
+ file_put_contents($tmpFile, "GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;");
+
+ $form = $this->formFactory
+ ->createBuilder(RefundRequestType::class, null, [
+ 'csrf_protection' => false,
+ 'max_quantity' => 10,
+ ])
+ ->getForm();
+
+ $form->submit([
+ 'quantity' => '1',
+ 'reason' => 'テスト返品理由',
+ 'files' => [
+ new UploadedFile($tmpFile, 'test.gif', 'image/gif', null, true),
+ ],
+ ]);
+
+ $this->assertTrue($form->isValid());
+ }
+
+ public function testInvalidFilesExceedMaxCount(): void
+ {
+ $files = [];
+ for ($i = 0; $i < 4; $i++) {
+ $tmpFile = tempnam(sys_get_temp_dir(), 'refund_type_');
+ file_put_contents($tmpFile, str_repeat("\x00", 100));
+ $files[] = new UploadedFile($tmpFile, "test_{$i}.jpg", 'image/jpeg', null, true);
+ }
+
+ $form = $this->formFactory
+ ->createBuilder(RefundRequestType::class, null, [
+ 'csrf_protection' => false,
+ 'max_quantity' => 10,
+ ])
+ ->getForm();
+
+ $form->submit([
+ 'quantity' => '1',
+ 'reason' => 'テスト返品理由',
+ 'files' => $files,
+ ]);
+
+ $this->assertFalse($form->isValid());
+ }
+
+ public function testInvalidFileDisallowedMimeType(): void
+ {
+ $tmpFile = tempnam(sys_get_temp_dir(), 'refund_type_');
+ file_put_contents($tmpFile, '%PDF-1.4');
+
+ $form = $this->formFactory
+ ->createBuilder(RefundRequestType::class, null, [
+ 'csrf_protection' => false,
+ 'max_quantity' => 10,
+ ])
+ ->getForm();
+
+ $form->submit([
+ 'quantity' => '1',
+ 'reason' => 'テスト返品理由',
+ 'files' => [
+ new UploadedFile($tmpFile, 'test.pdf', 'application/pdf', null, true),
+ ],
+ ]);
+
+ $this->assertFalse($form->isValid());
+ }
+
+ public function testInvalidFileExceedMaxSize(): void
+ {
+ // 許可MIME(GIF)ヘッダを書いた上で MAX_FILE_SIZE(15M) を超えるサイズにし, サイズ超過のみで弾かれることを検証する.
+ $tmpFile = tempnam(sys_get_temp_dir(), 'refund_type_').'.gif';
+ $fp = fopen($tmpFile, 'wb');
+ fwrite($fp, "GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;");
+ fseek($fp, 16 * 1024 * 1024); // 16MB(>15M)。sparseファイルなので実書き込みは僅か.
+ fwrite($fp, "\x00");
+ fclose($fp);
+
+ $form = $this->formFactory
+ ->createBuilder(RefundRequestType::class, null, [
+ 'csrf_protection' => false,
+ 'max_quantity' => 10,
+ ])
+ ->getForm();
+
+ $form->submit([
+ 'quantity' => '1',
+ 'reason' => 'テスト返品理由',
+ 'files' => [
+ new UploadedFile($tmpFile, 'test.gif', 'image/gif', null, true),
+ ],
+ ]);
+
+ $this->assertFalse($form->isValid());
+
+ @unlink($tmpFile);
+ }
+}
diff --git a/tests/Eccube/Tests/Repository/RefundRequestRepositoryTest.php b/tests/Eccube/Tests/Repository/RefundRequestRepositoryTest.php
new file mode 100644
index 00000000000..aed9910ec16
--- /dev/null
+++ b/tests/Eccube/Tests/Repository/RefundRequestRepositoryTest.php
@@ -0,0 +1,230 @@
+refundRequestRepository = $this->entityManager->getRepository(RefundRequest::class);
+ }
+
+ public function testGetQueryBuilderBySearchDataEmpty(): void
+ {
+ $this->createTestRefundRequest();
+
+ $qb = $this->refundRequestRepository->getQueryBuilderBySearchData([]);
+ $result = $qb->getQuery()->getResult();
+
+ $this->assertNotEmpty($result);
+ }
+
+ public function testGetQueryBuilderBySearchDataMultiById(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $qb = $this->refundRequestRepository->getQueryBuilderBySearchData([
+ 'multi' => (string) $RefundRequest->getId(),
+ ]);
+ $result = $qb->getQuery()->getResult();
+
+ $this->assertCount(1, $result);
+ $this->assertSame($RefundRequest->getId(), $result[0]->getId());
+ }
+
+ public function testGetQueryBuilderBySearchDataMultiByOrderNo(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+ $orderNo = $RefundRequest->getOrder()->getOrderNo();
+
+ $qb = $this->refundRequestRepository->getQueryBuilderBySearchData([
+ 'multi' => $orderNo,
+ ]);
+ $result = $qb->getQuery()->getResult();
+
+ $this->assertNotEmpty($result);
+ }
+
+ public function testGetQueryBuilderBySearchDataEmptyStatusCollection(): void
+ {
+ // Form の EntityType(multiple) は空でも ArrayCollection を返す。
+ // この空コレクションで status 条件が IN (NULL) になって全件 0 になる回帰を防ぐ。
+ $this->createTestRefundRequest();
+
+ $qb = $this->refundRequestRepository->getQueryBuilderBySearchData([
+ 'status' => new ArrayCollection(),
+ ]);
+ $result = $qb->getQuery()->getResult();
+
+ $this->assertNotEmpty($result, '空 status コレクションは全件表示でなければならない');
+ }
+
+ public function testGetQueryBuilderBySearchDataByStatus(): void
+ {
+ $this->createTestRefundRequest();
+
+ $Status = $this->entityManager->find(RefundRequestStatus::class, RefundRequestStatus::NEW);
+ $qb = $this->refundRequestRepository->getQueryBuilderBySearchData([
+ 'status' => [$Status],
+ ]);
+ $result = $qb->getQuery()->getResult();
+
+ $this->assertNotEmpty($result);
+ foreach ($result as $rr) {
+ $this->assertSame(RefundRequestStatus::NEW, $rr->getRefundRequestStatus()->getId());
+ }
+ }
+
+ public function testGetQueryBuilderBySearchDataByCreateDate(): void
+ {
+ $this->createTestRefundRequest();
+
+ $yesterday = new \DateTime('-1 day');
+ $tomorrow = new \DateTime('+1 day');
+
+ $qb = $this->refundRequestRepository->getQueryBuilderBySearchData([
+ 'create_date_start' => $yesterday,
+ 'create_date_end' => $tomorrow,
+ ]);
+ $result = $qb->getQuery()->getResult();
+
+ $this->assertNotEmpty($result);
+ }
+
+ public function testFindByOrderItemAndCustomer(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+ $OrderItem = $RefundRequest->getOrderItem();
+ $Customer = $RefundRequest->getCustomer();
+
+ $result = $this->refundRequestRepository->findByOrderItemAndCustomer($OrderItem, $Customer);
+
+ $this->assertCount(1, $result);
+ $this->assertSame($RefundRequest->getId(), $result[0]->getId());
+ }
+
+ public function testFindByOrderItemAndCustomerEmpty(): void
+ {
+ $this->createTestRefundRequest();
+ $OtherCustomer = $this->createCustomer();
+ $OrderItem = $this->createTestRefundRequest()->getOrderItem();
+
+ $result = $this->refundRequestRepository->findByOrderItemAndCustomer($OrderItem, $OtherCustomer);
+
+ $this->assertEmpty($result);
+ }
+
+ public function testGetRefundRequestCountsByCustomer(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+ $Customer = $RefundRequest->getCustomer();
+ $orderItemId = $RefundRequest->getOrderItem()->getId();
+
+ $counts = $this->refundRequestRepository->getRefundRequestCountsByCustomer($Customer);
+
+ $this->assertArrayHasKey($orderItemId, $counts);
+ $this->assertSame(1, $counts[$orderItemId]);
+ }
+
+ public function testGetRefundRequestCountsByCustomerEmpty(): void
+ {
+ $Customer = $this->createCustomer();
+ $counts = $this->refundRequestRepository->getRefundRequestCountsByCustomer($Customer);
+
+ $this->assertEmpty($counts);
+ }
+
+ public function testGetRefundRequestCountsByCustomerMultiple(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItems = $Order->getProductOrderItems();
+ $OrderItem = $OrderItems[0];
+ $NewStatus = $this->entityManager->find(RefundRequestStatus::class, RefundRequestStatus::NEW);
+ $this->assertInstanceOf(RefundRequestStatus::class, $NewStatus);
+
+ $rr1 = new RefundRequest();
+ $rr1->setOrder($Order);
+ $rr1->setOrderItem($OrderItem);
+ $rr1->setCustomer($Customer);
+ $rr1->setQuantity('1');
+ $rr1->setReason('理由1');
+ $rr1->setRefundRequestStatus($NewStatus);
+ $this->entityManager->persist($rr1);
+
+ $rr2 = new RefundRequest();
+ $rr2->setOrder($Order);
+ $rr2->setOrderItem($OrderItem);
+ $rr2->setCustomer($Customer);
+ $rr2->setQuantity('1');
+ $rr2->setReason('理由2');
+ $rr2->setRefundRequestStatus($NewStatus);
+ $this->entityManager->persist($rr2);
+
+ $this->entityManager->flush();
+
+ $counts = $this->refundRequestRepository->getRefundRequestCountsByCustomer($Customer);
+
+ $this->assertSame(2, $counts[$OrderItem->getId()]);
+ }
+
+ private function createTestRefundRequest(): RefundRequest
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $NewStatus = $this->entityManager->find(RefundRequestStatus::class, RefundRequestStatus::NEW);
+ $this->assertInstanceOf(RefundRequestStatus::class, $NewStatus);
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('テスト返品理由');
+ $RefundRequest->setRefundRequestStatus($NewStatus);
+
+ $this->entityManager->persist($RefundRequest);
+ $this->entityManager->flush();
+
+ return $RefundRequest;
+ }
+
+ private function setOrderStatus(Order $Order, int $statusId): void
+ {
+ $Status = $this->entityManager->find(OrderStatus::class, $statusId);
+ $this->assertInstanceOf(OrderStatus::class, $Status);
+ $Order->setOrderStatus($Status);
+ }
+}
diff --git a/tests/Eccube/Tests/Service/RefundRequestServiceTest.php b/tests/Eccube/Tests/Service/RefundRequestServiceTest.php
new file mode 100644
index 00000000000..ebcfcd62612
--- /dev/null
+++ b/tests/Eccube/Tests/Service/RefundRequestServiceTest.php
@@ -0,0 +1,343 @@
+refundRequestService = static::getContainer()->get(RefundRequestService::class);
+ }
+
+ public function testCreateRefundRequest(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('商品に破損がありました');
+
+ $result = $this->refundRequestService->createRefundRequest($RefundRequest, [], self::SESSION_ID);
+
+ $this->assertNotNull($result->getId());
+ $this->assertSame(RefundRequestStatus::NEW, $result->getRefundRequestStatus()->getId());
+ $this->assertSame('1', $result->getQuantity());
+ $this->assertSame('商品に破損がありました', $result->getReason());
+ $this->assertEmailCount(1);
+ }
+
+ public function testCreateRefundRequestWithoutFiles(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('2');
+ $RefundRequest->setReason('サイズが合いませんでした');
+
+ $result = $this->refundRequestService->createRefundRequest($RefundRequest, [], self::SESSION_ID);
+
+ $this->assertNotNull($result->getId());
+ $this->assertCount(0, $result->getRefundRequestFiles());
+ }
+
+ public function testChangeStatus(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('テスト理由');
+
+ $this->refundRequestService->createRefundRequest($RefundRequest, [], self::SESSION_ID);
+
+ $this->refundRequestService->changeStatus($RefundRequest, 'start_processing');
+ $this->assertSame(RefundRequestStatus::PROCESSING, $RefundRequest->getRefundRequestStatus()->getId());
+
+ $this->refundRequestService->changeStatus($RefundRequest, 'accept');
+ $this->assertSame(RefundRequestStatus::ACCEPTED, $RefundRequest->getRefundRequestStatus()->getId());
+ }
+
+ public function testChangeStatusInvalid(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('テスト理由');
+
+ $this->refundRequestService->createRefundRequest($RefundRequest, [], self::SESSION_ID);
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->refundRequestService->changeStatus($RefundRequest, 'accept');
+ }
+
+ public function testCanApplyTransition(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('テスト理由');
+
+ $this->refundRequestService->createRefundRequest($RefundRequest, [], self::SESSION_ID);
+
+ $this->assertTrue($this->refundRequestService->canApplyTransition($RefundRequest, 'start_processing'));
+ $this->assertFalse($this->refundRequestService->canApplyTransition($RefundRequest, 'accept'));
+ }
+
+ public function testGetAvailableTransitions(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('テスト理由');
+
+ $this->refundRequestService->createRefundRequest($RefundRequest, [], self::SESSION_ID);
+
+ $transitions = $this->refundRequestService->getAvailableTransitions($RefundRequest);
+ $this->assertArrayHasKey('start_processing', $transitions);
+ $this->assertCount(1, $transitions);
+ }
+
+ public function testSaveTempFile(): void
+ {
+ $uploadedFile = $this->createUploadedFile('test.jpg', 'image/jpeg');
+ $info = $this->refundRequestService->saveTempFile($uploadedFile, self::SESSION_ID);
+
+ $this->assertArrayHasKey('key', $info);
+ $this->assertSame('test.jpg', $info['client_name']);
+ $this->assertSame('image/jpeg', $info['mime_type']);
+ $this->assertGreaterThan(0, $info['size']);
+ $this->assertNotNull($this->refundRequestService->getTempFilePath(self::SESSION_ID, $info['key']));
+ }
+
+ public function testCreateRefundRequestWithTempFiles(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ // 事前に一時保存
+ $uploadedFile = $this->createUploadedFile('test.jpg', 'image/jpeg');
+ $info = $this->refundRequestService->saveTempFile($uploadedFile, self::SESSION_ID);
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('ファイル添付テスト');
+
+ $result = $this->refundRequestService->createRefundRequest($RefundRequest, [$info], self::SESSION_ID);
+
+ $this->assertNotNull($result->getId());
+ $this->assertCount(1, $result->getRefundRequestFiles());
+
+ $file = $result->getRefundRequestFiles()->first();
+ $this->assertSame('image/jpeg', $file->getMimeType());
+ $this->assertSame(1, $file->getSortNo());
+ $this->assertSame($info['key'], $file->getFileName());
+ $this->assertEmailCount(1);
+
+ // 一時ファイルが本領域に移動して掃除されていること
+ $finalDir = static::getContainer()->get(EccubeConfig::class)['eccube_save_refund_request_file_dir'];
+ $this->assertFileExists($finalDir.'/'.$info['key']);
+ $this->assertNull($this->refundRequestService->getTempFilePath(self::SESSION_ID, $info['key']));
+ }
+
+ public function testCreateRefundRequestWithMultipleTempFiles(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('複数ファイル添付テスト');
+
+ $infos = [];
+ for ($i = 0; $i < 3; $i++) {
+ $up = $this->createUploadedFile("test_{$i}.png", 'image/png');
+ $infos[] = $this->refundRequestService->saveTempFile($up, self::SESSION_ID);
+ }
+
+ $result = $this->refundRequestService->createRefundRequest($RefundRequest, $infos, self::SESSION_ID);
+
+ $this->assertCount(3, $result->getRefundRequestFiles());
+
+ $sortNos = [];
+ foreach ($result->getRefundRequestFiles() as $file) {
+ $sortNos[] = $file->getSortNo();
+ }
+ $this->assertSame([1, 2, 3], $sortNos);
+ }
+
+ public function testMailBodyContainsRefundRequestInfo(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('3');
+ $RefundRequest->setReason('メール本文検証用の理由テキスト');
+
+ $this->refundRequestService->createRefundRequest($RefundRequest, [], self::SESSION_ID);
+
+ $this->assertEmailCount(1);
+
+ /** @var Email $email */
+ $email = $this->getMailerMessage();
+ $body = $email->getTextBody();
+
+ $this->assertStringContainsString($Order->getOrderNo(), (string) $body);
+ $this->assertStringContainsString('メール本文検証用の理由テキスト', (string) $body);
+ }
+
+ public function testChangeStatusDispatchesEvent(): void
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('イベント検証');
+
+ $this->refundRequestService->createRefundRequest($RefundRequest, [], self::SESSION_ID);
+
+ $dispatched = false;
+ $dispatcher = static::getContainer()->get(EventDispatcherInterface::class);
+ $dispatcher->addListener(EccubeEvents::REFUND_REQUEST_STATUS_CHANGE, function () use (&$dispatched): void {
+ $dispatched = true;
+ });
+
+ $this->refundRequestService->changeStatus($RefundRequest, 'start_processing');
+
+ $this->assertTrue($dispatched);
+ }
+
+ private function setOrderStatus(Order $Order, int $statusId): void
+ {
+ $Status = $this->entityManager->find(OrderStatus::class, $statusId);
+ $this->assertInstanceOf(OrderStatus::class, $Status);
+ $Order->setOrderStatus($Status);
+ }
+
+ private function createUploadedFile(string $name, string $mime): UploadedFile
+ {
+ $tmpFile = tempnam(sys_get_temp_dir(), 'refund_test_');
+ // MIME 推定 (Symfony MimeTypeGuesser/file コマンド) が通るように本物のマジックバイトを入れる
+ $content = match ($mime) {
+ 'image/jpeg' => "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xFF\xD9",
+ 'image/png' => base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='),
+ default => str_repeat("\x00", 100),
+ };
+ file_put_contents($tmpFile, $content);
+
+ return new UploadedFile($tmpFile, $name, $mime, null, true);
+ }
+}
diff --git a/tests/Eccube/Tests/Service/RefundRequestStateMachineTest.php b/tests/Eccube/Tests/Service/RefundRequestStateMachineTest.php
new file mode 100644
index 00000000000..95fad17af05
--- /dev/null
+++ b/tests/Eccube/Tests/Service/RefundRequestStateMachineTest.php
@@ -0,0 +1,181 @@
+stateMachine = static::getContainer()->get(RefundRequestStateMachine::class);
+ }
+
+ #[DataProvider(methodName: 'canProvider')]
+ public function testCan(string $transition, int $fromId, bool $expected): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus($fromId);
+ $this->assertSame($expected, $this->stateMachine->can($RefundRequest, $transition));
+ }
+
+ public static function canProvider(): \Iterator
+ {
+ // start_processing: NEW → PROCESSING
+ yield ['start_processing', RefundRequestStatus::NEW, true];
+ yield ['start_processing', RefundRequestStatus::PROCESSING, false];
+ yield ['start_processing', RefundRequestStatus::ACCEPTED, false];
+ yield ['start_processing', RefundRequestStatus::DECLINED, false];
+ yield ['start_processing', RefundRequestStatus::INFO_REQUESTED, false];
+
+ // accept: PROCESSING → ACCEPTED
+ yield ['accept', RefundRequestStatus::NEW, false];
+ yield ['accept', RefundRequestStatus::PROCESSING, true];
+ yield ['accept', RefundRequestStatus::ACCEPTED, false];
+ yield ['accept', RefundRequestStatus::DECLINED, false];
+ yield ['accept', RefundRequestStatus::INFO_REQUESTED, false];
+
+ // decline: PROCESSING → DECLINED
+ yield ['decline', RefundRequestStatus::NEW, false];
+ yield ['decline', RefundRequestStatus::PROCESSING, true];
+ yield ['decline', RefundRequestStatus::ACCEPTED, false];
+ yield ['decline', RefundRequestStatus::DECLINED, false];
+ yield ['decline', RefundRequestStatus::INFO_REQUESTED, false];
+
+ // request_info: PROCESSING → INFO_REQUESTED
+ yield ['request_info', RefundRequestStatus::NEW, false];
+ yield ['request_info', RefundRequestStatus::PROCESSING, true];
+ yield ['request_info', RefundRequestStatus::ACCEPTED, false];
+ yield ['request_info', RefundRequestStatus::DECLINED, false];
+ yield ['request_info', RefundRequestStatus::INFO_REQUESTED, false];
+
+ // resume_processing: INFO_REQUESTED → PROCESSING
+ yield ['resume_processing', RefundRequestStatus::NEW, false];
+ yield ['resume_processing', RefundRequestStatus::PROCESSING, false];
+ yield ['resume_processing', RefundRequestStatus::ACCEPTED, false];
+ yield ['resume_processing', RefundRequestStatus::DECLINED, false];
+ yield ['resume_processing', RefundRequestStatus::INFO_REQUESTED, true];
+ }
+
+ public function testApplyStartProcessing(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::NEW);
+ $this->stateMachine->applyTransition($RefundRequest, 'start_processing');
+ $this->assertSame(RefundRequestStatus::PROCESSING, $RefundRequest->getRefundRequestStatus()->getId());
+ }
+
+ public function testApplyAccept(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::PROCESSING);
+ $this->stateMachine->applyTransition($RefundRequest, 'accept');
+ $this->assertSame(RefundRequestStatus::ACCEPTED, $RefundRequest->getRefundRequestStatus()->getId());
+ }
+
+ public function testApplyDecline(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::PROCESSING);
+ $this->stateMachine->applyTransition($RefundRequest, 'decline');
+ $this->assertSame(RefundRequestStatus::DECLINED, $RefundRequest->getRefundRequestStatus()->getId());
+ }
+
+ public function testApplyRequestInfo(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::PROCESSING);
+ $this->stateMachine->applyTransition($RefundRequest, 'request_info');
+ $this->assertSame(RefundRequestStatus::INFO_REQUESTED, $RefundRequest->getRefundRequestStatus()->getId());
+ }
+
+ public function testApplyResumeProcessing(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::INFO_REQUESTED);
+ $this->stateMachine->applyTransition($RefundRequest, 'resume_processing');
+ $this->assertSame(RefundRequestStatus::PROCESSING, $RefundRequest->getRefundRequestStatus()->getId());
+ }
+
+ public function testApplyInvalidTransition(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::NEW);
+ $this->expectException(\InvalidArgumentException::class);
+ $this->stateMachine->applyTransition($RefundRequest, 'accept');
+ }
+
+ public function testGetAvailableTransitionsFromNew(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::NEW);
+ $transitions = $this->stateMachine->getAvailableTransitions($RefundRequest);
+
+ $this->assertArrayHasKey('start_processing', $transitions);
+ $this->assertCount(1, $transitions);
+ $this->assertSame(RefundRequestStatus::PROCESSING, $transitions['start_processing']->getId());
+ }
+
+ public function testGetAvailableTransitionsFromProcessing(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::PROCESSING);
+ $transitions = $this->stateMachine->getAvailableTransitions($RefundRequest);
+
+ $this->assertCount(3, $transitions);
+ $this->assertArrayHasKey('accept', $transitions);
+ $this->assertArrayHasKey('decline', $transitions);
+ $this->assertArrayHasKey('request_info', $transitions);
+ }
+
+ public function testGetAvailableTransitionsFromAccepted(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::ACCEPTED);
+ $transitions = $this->stateMachine->getAvailableTransitions($RefundRequest);
+
+ $this->assertCount(0, $transitions);
+ }
+
+ public function testGetAvailableTransitionsFromDeclined(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::DECLINED);
+ $transitions = $this->stateMachine->getAvailableTransitions($RefundRequest);
+
+ $this->assertCount(0, $transitions);
+ }
+
+ public function testGetAvailableTransitionsFromInfoRequested(): void
+ {
+ $RefundRequest = $this->createRefundRequestWithStatus(RefundRequestStatus::INFO_REQUESTED);
+ $transitions = $this->stateMachine->getAvailableTransitions($RefundRequest);
+
+ $this->assertArrayHasKey('resume_processing', $transitions);
+ $this->assertCount(1, $transitions);
+ }
+
+ public function testCanWithNullStatus(): void
+ {
+ $RefundRequest = new RefundRequest();
+ $this->assertFalse($this->stateMachine->can($RefundRequest, 'start_processing'));
+ }
+
+ private function createRefundRequestWithStatus(int $statusId): RefundRequest
+ {
+ $Status = $this->entityManager->find(RefundRequestStatus::class, $statusId);
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setRefundRequestStatus($Status);
+
+ return $RefundRequest;
+ }
+}
diff --git a/tests/Eccube/Tests/Web/Admin/Order/RefundRequestControllerTest.php b/tests/Eccube/Tests/Web/Admin/Order/RefundRequestControllerTest.php
new file mode 100644
index 00000000000..40ddcac643a
--- /dev/null
+++ b/tests/Eccube/Tests/Web/Admin/Order/RefundRequestControllerTest.php
@@ -0,0 +1,408 @@
+client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request')
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testIndexWithSearch(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('admin_refund_request'),
+ [
+ 'admin_search_refund_request' => [
+ 'multi' => (string) $RefundRequest->getId(),
+ ],
+ ]
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testIndexWithStatusSearch(): void
+ {
+ $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('admin_refund_request'),
+ [
+ 'admin_search_refund_request' => [
+ 'status' => [(string) RefundRequestStatus::NEW],
+ ],
+ ]
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ /**
+ * 件数セレクタの page_count パラメータがセッションに保存されることを検証する.
+ */
+ public function testIndexPageCountPersistsInSession(): void
+ {
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_page', ['page_no' => 1, 'page_count' => 20])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ $this->assertEquals(20, $this->client->getRequest()->getSession()->get('eccube.admin.refund_request.search.page_count'));
+ }
+
+ /**
+ * 一覧で初期表示時にゼロ件にならないこと(status=空コレクション → IN (NULL) 回帰防止).
+ */
+ public function testIndexInitialShowsAllRefundRequests(): void
+ {
+ $this->createTestRefundRequest();
+
+ $crawler = $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request')
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ // 「検索結果に合致するデータが見つかりませんでした」が出ていないこと
+ $this->assertStringNotContainsString(
+ 'admin.common.search_no_result',
+ (string) $this->client->getResponse()->getContent()
+ );
+ // テーブル行が 1 行以上存在すること
+ $this->assertGreaterThanOrEqual(1, $crawler->filter('table tbody tr')->count());
+ }
+
+ public function testEdit(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_edit', ['id' => $RefundRequest->getId()])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testEditPostAdminNote(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('admin_refund_request_edit', ['id' => $RefundRequest->getId()]),
+ [
+ 'admin_refund_request_edit' => [
+ '_token' => 'dummy',
+ 'admin_note' => '管理者メモテスト',
+ 'transition' => '',
+ ],
+ ]
+ );
+
+ $this->assertTrue($this->client->getResponse()->isRedirection());
+
+ $this->entityManager->refresh($RefundRequest);
+ $this->assertSame('管理者メモテスト', $RefundRequest->getAdminNote());
+ }
+
+ public function testEditPostWithTransition(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('admin_refund_request_edit', ['id' => $RefundRequest->getId()]),
+ [
+ 'admin_refund_request_edit' => [
+ '_token' => 'dummy',
+ 'admin_note' => '',
+ 'transition' => 'start_processing',
+ ],
+ ]
+ );
+
+ $this->assertTrue($this->client->getResponse()->isRedirection());
+
+ $this->entityManager->refresh($RefundRequest);
+ $this->assertSame(RefundRequestStatus::PROCESSING, $RefundRequest->getRefundRequestStatus()->getId());
+ }
+
+ public function testUpdateStatus(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_PUT,
+ $this->generateUrl('admin_refund_request_update_status', ['id' => $RefundRequest->getId()]),
+ [
+ 'transition' => 'start_processing',
+ '_token' => 'dummy',
+ ]
+ );
+
+ $response = $this->client->getResponse();
+ $this->assertTrue($response->isSuccessful());
+ $this->assertSame('application/json', $response->headers->get('Content-Type'));
+
+ $data = json_decode($response->getContent(), true);
+ $this->assertTrue($data['success']);
+ }
+
+ public function testUpdateStatusInvalidTransition(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_PUT,
+ $this->generateUrl('admin_refund_request_update_status', ['id' => $RefundRequest->getId()]),
+ [
+ 'transition' => 'accept',
+ '_token' => 'dummy',
+ ]
+ );
+
+ $response = $this->client->getResponse();
+ $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode(), (string) $response->getContent());
+
+ $data = json_decode($response->getContent(), true);
+ $this->assertFalse($data['success']);
+ }
+
+ public function testUpdateStatusNoTransition(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_PUT,
+ $this->generateUrl('admin_refund_request_update_status', ['id' => $RefundRequest->getId()]),
+ [
+ '_token' => 'dummy',
+ ]
+ );
+
+ $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testExport(): void
+ {
+ $this->createTestRefundRequest();
+
+ $session = $this->createSession($this->client);
+ $session->set('eccube.admin.refund_request.search', []);
+ $session->save();
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_export')
+ );
+
+ $response = $this->client->getResponse();
+ $this->assertTrue($response->isSuccessful());
+ $this->assertSame('application/octet-stream', $response->headers->get('Content-Type'));
+ $this->assertStringContainsString('refund_request_', (string) $response->headers->get('Content-Disposition'));
+ }
+
+ public function testEditNotFound(): void
+ {
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_edit', ['id' => 999999])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testPagination(): void
+ {
+ $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_page', ['page_no' => 1])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testExportCsvHeaderColumns(): void
+ {
+ $this->createTestRefundRequest();
+
+ $session = $this->createSession($this->client);
+ $session->set('eccube.admin.refund_request.search', []);
+ $session->save();
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_export')
+ );
+
+ $response = $this->client->getResponse();
+ $this->assertTrue($response->isSuccessful());
+ $this->assertSame('application/octet-stream', $response->headers->get('Content-Type'));
+
+ $content = $this->client->getInternalResponse()->getContent();
+
+ $bom = "\xEF\xBB\xBF";
+ if (str_starts_with($content, $bom)) {
+ $content = substr($content, 3);
+ }
+
+ $lines = array_filter(explode("\n", $content), fn ($line) => $line !== '');
+ $this->assertGreaterThanOrEqual(1, count($lines));
+
+ $header = str_getcsv($lines[0]);
+ $this->assertCount(9, $header);
+ }
+
+ public function testDownloadFile(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+ $file = $this->createTestFile($RefundRequest);
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_file', [
+ 'id' => $RefundRequest->getId(),
+ 'file_id' => $file->getId(),
+ ])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testDownloadFileNotFound(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_file', [
+ 'id' => $RefundRequest->getId(),
+ 'file_id' => 999999,
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testDownloadFilePathTraversal(): void
+ {
+ $RefundRequest = $this->createTestRefundRequest();
+
+ $file = new RefundRequestFile();
+ $file->setFileName('../../etc/passwd');
+ $file->setMimeType('text/plain');
+ $file->setFileSize(100);
+ $file->setSortNo(1);
+ $RefundRequest->addRefundRequestFile($file);
+ $this->entityManager->persist($file);
+ $this->entityManager->flush();
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('admin_refund_request_file', [
+ 'id' => $RefundRequest->getId(),
+ 'file_id' => $file->getId(),
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ private function createTestRefundRequest(): RefundRequest
+ {
+ $Customer = $this->createCustomer();
+ $Order = $this->createOrder($Customer);
+ $this->setOrderStatus($Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+
+ $OrderItem = $Order->getProductOrderItems()[0];
+ $NewStatus = $this->entityManager->find(RefundRequestStatus::class, RefundRequestStatus::NEW);
+ $this->assertInstanceOf(RefundRequestStatus::class, $NewStatus);
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('テスト返品理由');
+ $RefundRequest->setRefundRequestStatus($NewStatus);
+
+ $this->entityManager->persist($RefundRequest);
+ $this->entityManager->flush();
+
+ return $RefundRequest;
+ }
+
+ private function setOrderStatus(Order $Order, int $statusId): void
+ {
+ $Status = $this->entityManager->find(OrderStatus::class, $statusId);
+ $this->assertInstanceOf(OrderStatus::class, $Status);
+ $Order->setOrderStatus($Status);
+ }
+
+ private function createTestFile(RefundRequest $RefundRequest): RefundRequestFile
+ {
+ $dir = static::getContainer()->get(EccubeConfig::class)['eccube_save_refund_request_file_dir'];
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ $fileName = bin2hex(random_bytes(16)).'.jpg';
+ file_put_contents($dir.'/'.$fileName, str_repeat("\x00", 100));
+
+ $file = new RefundRequestFile();
+ $file->setFileName($fileName);
+ $file->setMimeType('image/jpeg');
+ $file->setFileSize(100);
+ $file->setSortNo(1);
+ $RefundRequest->addRefundRequestFile($file);
+
+ $this->entityManager->persist($file);
+ $this->entityManager->flush();
+
+ return $file;
+ }
+}
diff --git a/tests/Eccube/Tests/Web/Mypage/RefundRequestControllerTest.php b/tests/Eccube/Tests/Web/Mypage/RefundRequestControllerTest.php
new file mode 100644
index 00000000000..5e3e7737085
--- /dev/null
+++ b/tests/Eccube/Tests/Web/Mypage/RefundRequestControllerTest.php
@@ -0,0 +1,590 @@
+Customer = $this->createCustomer();
+ $this->Order = $this->createOrder($this->Customer);
+ $this->setOrderStatus($this->Order, OrderStatus::DELIVERED);
+ $this->entityManager->flush();
+ }
+
+ public function testIndex(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testIndexPostRedirectsToConfirm(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]),
+ [
+ 'refund_request' => [
+ '_token' => 'dummy',
+ 'quantity' => '1',
+ 'reason' => 'テスト理由です',
+ ],
+ ]
+ );
+
+ $this->assertTrue($this->client->getResponse()->isRedirection());
+ $this->assertStringContainsString('/confirm', (string) $this->client->getResponse()->headers->get('Location'));
+ }
+
+ public function testConfirmDisplay(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ // 入力画面の POST でセッションに格納
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]),
+ [
+ 'refund_request' => [
+ '_token' => 'dummy',
+ 'quantity' => '1',
+ 'reason' => 'テスト理由です',
+ ],
+ ]
+ );
+
+ // 確認画面の GET 表示
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_confirm', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testConfirmWithoutSessionRedirectsToIndex(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_confirm', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isRedirection());
+ }
+
+ public function testConfirmPostCreatesRefundRequest(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ // 入力画面の POST でセッションに格納
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]),
+ [
+ 'refund_request' => [
+ '_token' => 'dummy',
+ 'quantity' => '1',
+ 'reason' => 'テスト理由です',
+ ],
+ ]
+ );
+
+ // 確認画面の POST で確定
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('mypage_refund_request_confirm', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]),
+ ['_token' => 'dummy']
+ );
+
+ $this->assertTrue($this->client->getResponse()->isRedirection());
+ $count = (int) $this->entityManager->createQueryBuilder()
+ ->select('COUNT(rr)')
+ ->from(RefundRequest::class, 'rr')
+ ->where('rr.Customer = :c')
+ ->setParameter('c', $this->Customer)
+ ->getQuery()->getSingleScalarResult();
+ $this->assertSame(1, $count);
+ }
+
+ public function testConfirmPostWithFilePersistsFile(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ // テスト用ダミーアップロードファイルを作成
+ $tmpFile = tempnam(sys_get_temp_dir(), 'rr_test_');
+ // 1x1 透明 PNG
+ $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=');
+ file_put_contents($tmpFile, $png);
+
+ $uploaded = new UploadedFile($tmpFile, 'evidence.png', 'image/png', null, true);
+
+ // 入力画面の POST でセッションに格納(一時保存される)
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]),
+ [
+ 'refund_request' => [
+ '_token' => 'dummy',
+ 'quantity' => '1',
+ 'reason' => 'テスト理由です',
+ ],
+ ],
+ [
+ 'refund_request' => ['files' => [$uploaded]],
+ ]
+ );
+ $this->assertTrue($this->client->getResponse()->isRedirection(), 'index POST should redirect to confirm');
+
+ // 確認画面の POST で確定
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('mypage_refund_request_confirm', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]),
+ ['_token' => 'dummy']
+ );
+
+ $this->assertTrue($this->client->getResponse()->isRedirection(), 'confirm POST should redirect to complete');
+
+ $RefundRequest = $this->entityManager->createQueryBuilder()
+ ->select('rr')
+ ->from(RefundRequest::class, 'rr')
+ ->where('rr.Customer = :c')
+ ->setParameter('c', $this->Customer)
+ ->getQuery()->getOneOrNullResult();
+ $this->assertInstanceOf(RefundRequest::class, $RefundRequest);
+ $this->assertCount(1, $RefundRequest->getRefundRequestFiles(), 'エビデンスファイルが永続化されること');
+ }
+
+ public function testComplete(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_complete', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testItemHistory(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_item_history', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testItemHistoryWithRefundRequests(): void
+ {
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+ $this->createRefundRequest($this->Order, $OrderItem, $this->Customer);
+
+ $this->loginTo($this->Customer);
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_item_history', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testHistoryButtonShownOnlyWhenRefundRequestExists(): void
+ {
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+ // 返品許可商品にしておく(履歴/申請ボタンの表示条件).
+ $OrderItem->getProduct()->setRefundAllowed(true);
+ $this->entityManager->flush();
+
+ $itemHistoryUrl = $this->generateUrl('mypage_refund_request_item_history', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]);
+
+ $this->loginTo($this->Customer);
+
+ // 返品申請が無い場合は履歴ボタン(item_history へのリンク)を表示しない.
+ $crawler = $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_history', ['order_no' => $this->Order->getOrderNo()])
+ );
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ $this->assertStringNotContainsString($itemHistoryUrl, $crawler->html());
+
+ // 返品申請を作成すると履歴ボタンが表示される.
+ $this->createRefundRequest($this->Order, $OrderItem, $this->Customer);
+
+ $crawler = $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_history', ['order_no' => $this->Order->getOrderNo()])
+ );
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ $this->assertStringContainsString($itemHistoryUrl, $crawler->html());
+ }
+
+ public function testAccessDeniedForNonDeliveredOrder(): void
+ {
+ $this->setOrderStatus($this->Order, OrderStatus::NEW);
+ $this->entityManager->flush();
+
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testNotFoundForOtherCustomerOrder(): void
+ {
+ $OtherCustomer = $this->createCustomer();
+ $this->loginTo($OtherCustomer);
+
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testNotFoundForInvalidOrderItemId(): void
+ {
+ $this->loginTo($this->Customer);
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => 999999,
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testNotFoundForOtherCustomerHistory(): void
+ {
+ $OtherCustomer = $this->createCustomer();
+ $this->loginTo($OtherCustomer);
+
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_item_history', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testAccessDeniedForRefundNotAllowed(): void
+ {
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+ $Product = $OrderItem->getProduct();
+ $this->assertInstanceOf(Product::class, $Product);
+ $Product->setRefundAllowed(false);
+ $this->entityManager->flush();
+
+ $this->loginTo($this->Customer);
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testValidationQuantityZero(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]),
+ [
+ 'refund_request' => [
+ '_token' => 'dummy',
+ 'quantity' => '0',
+ 'reason' => 'テスト理由です',
+ ],
+ ]
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ // 入力エラー時は確認画面に遷移せず、入力画面に留まることを検証
+ $this->assertFalse($this->client->getResponse()->isRedirection());
+ $content = (string) $this->client->getResponse()->getContent();
+ $this->assertStringNotContainsString('返品申請内容確認', $content);
+ }
+
+ public function testValidationReasonEmpty(): void
+ {
+ $this->loginTo($this->Customer);
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+
+ $this->client->request(
+ Request::METHOD_POST,
+ $this->generateUrl('mypage_refund_request', [
+ 'order_no' => $this->Order->getOrderNo(),
+ 'order_item_id' => $OrderItem->getId(),
+ ]),
+ [
+ 'refund_request' => [
+ '_token' => 'dummy',
+ 'quantity' => '1',
+ 'reason' => '',
+ ],
+ ]
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ // 入力エラー時は確認画面に遷移せず、入力画面に留まることを検証
+ $this->assertFalse($this->client->getResponse()->isRedirection());
+ $content = (string) $this->client->getResponse()->getContent();
+ $this->assertStringNotContainsString('返品申請内容確認', $content);
+ }
+
+ public function testDownloadFile(): void
+ {
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+ $RefundRequest = $this->createRefundRequest($this->Order, $OrderItem, $this->Customer);
+ $file = $this->createTestFile($RefundRequest);
+
+ $this->loginTo($this->Customer);
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_file', [
+ 'refund_request_id' => $RefundRequest->getId(),
+ 'file_id' => $file->getId(),
+ ])
+ );
+
+ $this->assertTrue($this->client->getResponse()->isSuccessful());
+ }
+
+ public function testDownloadFileOtherCustomer(): void
+ {
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+ $RefundRequest = $this->createRefundRequest($this->Order, $OrderItem, $this->Customer);
+ $file = $this->createTestFile($RefundRequest);
+
+ $OtherCustomer = $this->createCustomer();
+ $this->loginTo($OtherCustomer);
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_file', [
+ 'refund_request_id' => $RefundRequest->getId(),
+ 'file_id' => $file->getId(),
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testDownloadFileNotFound(): void
+ {
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+ $RefundRequest = $this->createRefundRequest($this->Order, $OrderItem, $this->Customer);
+
+ $this->loginTo($this->Customer);
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_file', [
+ 'refund_request_id' => $RefundRequest->getId(),
+ 'file_id' => 999999,
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ public function testDownloadFilePathTraversal(): void
+ {
+ $OrderItem = $this->Order->getProductOrderItems()[0];
+ $RefundRequest = $this->createRefundRequest($this->Order, $OrderItem, $this->Customer);
+
+ $file = new RefundRequestFile();
+ $file->setFileName('../../etc/passwd');
+ $file->setMimeType('text/plain');
+ $file->setFileSize(100);
+ $file->setSortNo(1);
+ $RefundRequest->addRefundRequestFile($file);
+ $this->entityManager->persist($file);
+ $this->entityManager->flush();
+
+ $this->loginTo($this->Customer);
+
+ $this->client->request(
+ Request::METHOD_GET,
+ $this->generateUrl('mypage_refund_request_file', [
+ 'refund_request_id' => $RefundRequest->getId(),
+ 'file_id' => $file->getId(),
+ ])
+ );
+
+ $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent());
+ }
+
+ private function setOrderStatus(Order $Order, int $statusId): void
+ {
+ $Status = $this->entityManager->find(OrderStatus::class, $statusId);
+ $this->assertInstanceOf(OrderStatus::class, $Status);
+ $Order->setOrderStatus($Status);
+ }
+
+ private function createRefundRequest(Order $Order, mixed $OrderItem, Customer $Customer): RefundRequest
+ {
+ $NewStatus = $this->entityManager->find(RefundRequestStatus::class, RefundRequestStatus::NEW);
+ $this->assertInstanceOf(RefundRequestStatus::class, $NewStatus);
+
+ $RefundRequest = new RefundRequest();
+ $RefundRequest->setOrder($Order);
+ $RefundRequest->setOrderItem($OrderItem);
+ $RefundRequest->setCustomer($Customer);
+ $RefundRequest->setQuantity('1');
+ $RefundRequest->setReason('テスト返品理由');
+ $RefundRequest->setRefundRequestStatus($NewStatus);
+
+ $this->entityManager->persist($RefundRequest);
+ $this->entityManager->flush();
+
+ return $RefundRequest;
+ }
+
+ private function createTestFile(RefundRequest $RefundRequest): RefundRequestFile
+ {
+ $dir = static::getContainer()->get(EccubeConfig::class)['eccube_save_refund_request_file_dir'];
+ if (!is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+
+ $fileName = bin2hex(random_bytes(16)).'.jpg';
+ file_put_contents($dir.'/'.$fileName, str_repeat("\x00", 100));
+
+ $file = new RefundRequestFile();
+ $file->setFileName($fileName);
+ $file->setMimeType('image/jpeg');
+ $file->setFileSize(100);
+ $file->setSortNo(1);
+ $RefundRequest->addRefundRequestFile($file);
+
+ $this->entityManager->persist($file);
+ $this->entityManager->flush();
+
+ return $file;
+ }
+}