diff --git a/app/DoctrineMigrations/Version20260617000000.php b/app/DoctrineMigrations/Version20260617000000.php new file mode 100644 index 00000000000..47a4c16ed3a --- /dev/null +++ b/app/DoctrineMigrations/Version20260617000000.php @@ -0,0 +1,73 @@ +hasTable(self::NAME)) { + return; + } + + // 商品 CSV(csv_type_id = 1)へ Product.order_memo を追加 + $productExists = $this->connection->fetchOne("SELECT COUNT(*) FROM dtb_csv WHERE csv_type_id = 1 AND field_name = 'order_memo'"); + if ($productExists == 0) { + // dtb_csv が空でも NULL にならないよう COALESCE で既定値を確保する + $sortNo = $this->connection->fetchOne('SELECT COALESCE(MAX(sort_no), 0) + 1 FROM dtb_csv WHERE csv_type_id = 1'); + $this->addSql("INSERT INTO dtb_csv ( + csv_type_id, creator_id, entity_name, field_name, disp_name, sort_no, enabled, create_date, update_date, discriminator_type + ) VALUES ( + 1, null, ?, 'order_memo', '受注管理用メモ', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'csv' + )", + ['Eccube\\\\Entity\\\\Product', $sortNo]); + } + + // 配送/出荷 CSV(csv_type_id = 4)へ OrderItem.order_memo を追加 + $orderItemExists = $this->connection->fetchOne("SELECT COUNT(*) FROM dtb_csv WHERE csv_type_id = 4 AND field_name = 'order_memo'"); + if ($orderItemExists == 0) { + // dtb_csv が空でも NULL にならないよう COALESCE で既定値を確保する + $sortNo = $this->connection->fetchOne('SELECT COALESCE(MAX(sort_no), 0) + 1 FROM dtb_csv WHERE csv_type_id = 4'); + $this->addSql("INSERT INTO dtb_csv ( + csv_type_id, creator_id, entity_name, field_name, disp_name, sort_no, enabled, create_date, update_date, discriminator_type + ) VALUES ( + 4, null, ?, 'order_memo', '受注管理用メモ', ?, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'csv' + )", + ['Eccube\\\\Entity\\\\OrderItem', $sortNo]); + } + } + + public function down(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + $this->addSql("DELETE FROM dtb_csv WHERE csv_type_id = 1 AND entity_name = 'Eccube\\\\Entity\\\\Product' AND field_name = 'order_memo'"); + $this->addSql("DELETE FROM dtb_csv WHERE csv_type_id = 4 AND entity_name = 'Eccube\\\\Entity\\\\OrderItem' AND field_name = 'order_memo'"); + } +} diff --git a/app/config/eccube/packages/purchaseflow.yaml b/app/config/eccube/packages/purchaseflow.yaml index 32b96d7c040..2e848c6901b 100644 --- a/app/config/eccube/packages/purchaseflow.yaml +++ b/app/config/eccube/packages/purchaseflow.yaml @@ -108,6 +108,12 @@ services: - { name: eccube.item.holder.preprocessor, flow_type: shopping, priority: 1000 } - { name: eccube.item.holder.preprocessor, flow_type: order, priority: 1000 } + eccube.purchase.flow.item.holder.preprocessor.order.memo.preprocessor: # 商品の受注管理用メモを商品明細へ追記 + class: Eccube\Service\PurchaseFlow\Processor\OrderMemoPreprocessor + tags: + - { name: eccube.item.holder.preprocessor, flow_type: shopping, priority: 100 } + - { name: eccube.item.holder.preprocessor, flow_type: order, priority: 100 } + eccube.purchase.flow.item.holder.preprocessor.order.no.processor: class: Eccube\Service\PurchaseFlow\Processor\OrderNoProcessor arguments: diff --git a/e2e/setup-fixtures.php b/e2e/setup-fixtures.php index a5a4b4ed4af..1f92f1ba09e 100644 --- a/e2e/setup-fixtures.php +++ b/e2e/setup-fixtures.php @@ -212,5 +212,32 @@ echo " Multi-cart test product already exists\n"; } +// --- 受注管理用メモ確認用の受注 (#6821) --- +// 注文確定時に商品メモが受注明細へコピーされた状態を再現し、 +// 受注編集画面のメモアイコン/モーダル表示を E2E で検証できるようにする。 +$orderMemoName01 = '受注メモ確認用'; +$orderMemoText = "梱包時は割れ物注意\n同梱物: 取扱説明書"; +$existingMemoOrder = $entityManager->getRepository(\Eccube\Entity\Order::class) + ->findOneBy(['name01' => $orderMemoName01]); +if (!$existingMemoOrder) { + $memoProduct = $generator->createProduct('受注管理用メモ商品', 0); + $memoProduct->setOrderMemo($orderMemoText); + $memoCustomer = $entityManager->getRepository(Customer::class)->findAll()[0]; + $Delivery = $entityManager->getRepository(\Eccube\Entity\Delivery::class)->findAll()[0]; + $Order = $generator->createOrder($memoCustomer, $memoProduct->getProductClasses()->toArray(), $Delivery); + $Order->setName01($orderMemoName01); + $Order->setName02('太郎'); + $Order->setOrderStatus($entityManager->getRepository(OrderStatus::class)->find(OrderStatus::NEW)); + $Order->setOrderDate(new \DateTime()); + // 注文確定時のコピーを再現: 商品明細にのみメモを設定する + foreach ($Order->getProductOrderItems() as $item) { + $item->setOrderMemo($memoProduct->getOrderMemo()); + } + $entityManager->flush(); + echo " Created order-memo test order\n"; +} else { + echo " Order-memo test order already exists\n"; +} + echo "Fixtures setup complete.\n"; $kernel->shutdown(); diff --git a/e2e/tests/admin-order.spec.ts b/e2e/tests/admin-order.spec.ts index acb3bd5769a..23ff221b46a 100644 --- a/e2e/tests/admin-order.spec.ts +++ b/e2e/tests/admin-order.spec.ts @@ -567,4 +567,33 @@ test.describe('Admin Order (EA04)', () => { await popup.close(); }); + + test('order_受注管理用メモのアイコンとモーダル表示 - #6821', async ({ page }) => { + // 受注明細にメモを持つフィクスチャ受注(注文者: 受注メモ確認用)を開く + await goOrderList(page); + await searchOrder(page, '受注メモ確認用'); + await expect(page.locator(searchResultMsg)).not.toContainText('検索結果:0件が該当しました'); + + await page.locator('#search_result tbody tr:first-child a.action-edit').click(); + await page.waitForLoadState('load'); + await expect(page.locator(pageTitle)).toContainText('受注登録'); + + // メモアイコンは商品明細のみに表示される(送料・手数料・値引きには出ない) + const memoLink = page.locator('a[data-bs-target^="#order_memo_"]'); + await expect(memoLink).toHaveCount(1); + + // ツールチップのオーバーレイによるクリック阻害を避ける + await page.evaluate(() => document.querySelectorAll('.tooltip').forEach((el) => el.remove())); + + // アイコンクリックでモーダルにメモ全文が表示される + await memoLink.click(); + const modal = page.locator('[id^="order_memo_"].modal.show'); + await expect(modal).toBeVisible(); + await expect(modal).toContainText('梱包時は割れ物注意'); + await expect(modal).toContainText('同梱物: 取扱説明書'); + + // 閉じる (フェードアウトの完了まで自動リトライで待つ) + await modal.locator('[data-bs-dismiss="modal"]').first().click(); + await expect(page.locator('[id^="order_memo_"].modal.show')).toHaveCount(0); + }); }); diff --git a/e2e/tests/admin-product.spec.ts b/e2e/tests/admin-product.spec.ts index 2b69b1d934b..421a7bcbfec 100644 --- a/e2e/tests/admin-product.spec.ts +++ b/e2e/tests/admin-product.spec.ts @@ -1360,4 +1360,23 @@ test.describe('Admin Product (EA03)', () => { await page.waitForLoadState('load'); await expect(page.locator('.alert-success')).toContainText('保存しました'); }); + + test('product_受注管理用メモの保存と表示 - #6821', async ({ page }) => { + const memo = `梱包時は割れ物注意 ${Date.now()}\n同梱物: 取扱説明書`; + + // 受注管理用メモを入力して商品を新規登録 + await page.goto(`/${adminRoute}/product/product/new`); + await page.waitForLoadState('load'); + await page.locator('#admin_product_name').fill('受注メモテスト商品'); + await page.locator('#admin_product_class_price02').fill('1000'); + // フリーエリア直後の受注管理用メモカードが存在することを確認 + await expect(page.locator('#admin_product_order_memo')).toBeVisible(); + await page.locator('#admin_product_order_memo').fill(memo); + await page.locator('button.ladda-button[type="submit"]').click(); + await page.waitForLoadState('load'); + await expect(page.locator('.alert-success')).toContainText('保存しました'); + + // 編集画面の再表示でメモが保持されていることを確認 + await expect(page.locator('#admin_product_order_memo')).toHaveValue(memo); + }); }); diff --git a/rector.php b/rector.php index 09ed62892ae..827f53d2f0b 100644 --- a/rector.php +++ b/rector.php @@ -29,6 +29,7 @@ use Rector\Set\ValueObject\SetList; use Rector\Symfony\CodeQuality\Rector\Class_\ControllerMethodInjectionToConstructorRector; use Rector\Symfony\Set\SymfonySetList; +use Rector\Symfony\Symfony34\Rector\Closure\ContainerGetNameToTypeInTestsRector; use Rector\Symfony\Symfony61\Rector\Class_\CommandConfigureToAttributeRector; use Rector\Symfony\Symfony61\Rector\Class_\CommandPropertyToAttributeRector; use Rector\ValueObject\PhpVersion; @@ -66,6 +67,11 @@ __DIR__.'/codeception/_support/Page/Admin/OrderManagePage.php', __DIR__.'/codeception/acceptance/EF06OtherCest.php', ], + // shopping/order の各購入フローは同じ PurchaseFlow 型の別サービスであり、 + // 型解決(get(PurchaseFlow::class))に置き換えると両者の区別が失われテストが無意味化する + ContainerGetNameToTypeInTestsRector::class => [ + __DIR__.'/tests/Eccube/Tests/Service/PurchaseFlow/OrderMemoFlowTest.php', + ], // 8.3以上で対応可能 AddTypeToConstRector::class, // [BC]定数に型を追加する PHP 8.3 以降で有効 RenameMethodRector::class, //addがaddCommandに変換されてしまうため一旦スキップ diff --git a/src/Eccube/Controller/Admin/Product/CsvImportController.php b/src/Eccube/Controller/Admin/Product/CsvImportController.php index dfe92c40e34..0a93b3e422a 100644 --- a/src/Eccube/Controller/Admin/Product/CsvImportController.php +++ b/src/Eccube/Controller/Admin/Product/CsvImportController.php @@ -296,6 +296,14 @@ public function csvProduct(Request $request): array|JsonResponse } } + if (isset($row[$headerByKey['order_memo']])) { + if (StringUtil::isNotBlank($row[$headerByKey['order_memo']])) { + $Product->setOrderMemo(StringUtil::trimAll($row[$headerByKey['order_memo']])); + } else { + $Product->setOrderMemo(); + } + } + // 商品画像登録 $this->createProductImage($row, $Product, $data, $headerByKey); @@ -1672,6 +1680,11 @@ protected function getProductCsvHeader(): array 'description' => 'admin.product.product_csv.free_area_description', 'required' => false, ], + trans('admin.product.product_csv.order_memo_col') => [ + 'id' => 'order_memo', + 'description' => 'admin.product.product_csv.order_memo_description', + 'required' => false, + ], trans('admin.product.product_csv.delete_flag_col') => [ 'id' => 'product_del_flg', 'description' => 'admin.product.product_csv.delete_flag_description', diff --git a/src/Eccube/Entity/OrderItem.php b/src/Eccube/Entity/OrderItem.php index 253c1852e4a..29894bb1d3d 100644 --- a/src/Eccube/Entity/OrderItem.php +++ b/src/Eccube/Entity/OrderItem.php @@ -174,6 +174,9 @@ public function isPoint(): bool #[ORM\Column(name: 'processor_name', type: Types::STRING, nullable: true)] private ?string $processor_name = null; + #[ORM\Column(name: 'order_memo', type: Types::TEXT, nullable: true)] + private ?string $order_memo = null; + #[ORM\ManyToOne(targetEntity: Order::class, inversedBy: 'OrderItems')] #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id')] private ?Order $Order = null; @@ -478,6 +481,26 @@ public function setProcessorName(?string $processorName = null): static return $this; } + /** + * Get orderMemo. + */ + public function getOrderMemo(): ?string + { + return $this->order_memo; + } + + /** + * Set orderMemo. + * + * @return $this + */ + public function setOrderMemo(?string $orderMemo = null): static + { + $this->order_memo = $orderMemo; + + return $this; + } + /** * Set order. */ diff --git a/src/Eccube/Entity/Product.php b/src/Eccube/Entity/Product.php index dfe16074748..10b94333362 100644 --- a/src/Eccube/Entity/Product.php +++ b/src/Eccube/Entity/Product.php @@ -471,6 +471,9 @@ public function hasProductClass(): bool #[ORM\Column(name: 'free_area', type: Types::TEXT, nullable: true)] private ?string $free_area = null; + #[ORM\Column(name: 'order_memo', type: Types::TEXT, nullable: true)] + private ?string $order_memo = null; + /** * @var \DateTime */ @@ -703,6 +706,24 @@ public function getFreeArea(): ?string return $this->free_area; } + /** + * Set orderMemo. + */ + public function setOrderMemo(?string $orderMemo = null): Product + { + $this->order_memo = $orderMemo; + + return $this; + } + + /** + * Get orderMemo. + */ + public function getOrderMemo(): ?string + { + return $this->order_memo; + } + /** * Set createDate. */ diff --git a/src/Eccube/Form/Type/Admin/ProductType.php b/src/Eccube/Form/Type/Admin/ProductType.php index eff4fca082c..449b347f388 100644 --- a/src/Eccube/Form/Type/Admin/ProductType.php +++ b/src/Eccube/Form/Type/Admin/ProductType.php @@ -119,6 +119,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void new Assert\Length(['max' => $this->eccubeConfig['eccube_lltext_len']]), ], ]) + ->add('order_memo', TextareaType::class, [ + 'required' => false, + 'constraints' => [ + new Assert\Length(['max' => $this->eccubeConfig['eccube_lltext_len']]), + ], + ]) // 右ブロック ->add('Status', ProductStatusType::class, [ 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..dd1262d61d0 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_csv.csv +++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_csv.csv @@ -195,6 +195,7 @@ id,csv_type_id,creator_id,entity_name,field_name,reference_field_name,disp_name, 196,4,,Eccube\\Entity\\Shipping,tracking_number,,Tracking No. ,69,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv 197,4,,Eccube\\Entity\\Shipping,note,,Delivery Notes,70,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv 198,4,,Eccube\\Entity\\Shipping,mail_send_date,,Shipping notice sent on,71,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv +216,4,,Eccube\\Entity\\OrderItem,order_memo,,Order Management Memo,72,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv 199,5,,Eccube\\Entity\\Category,id,,Category ID,1,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv 200,5,,Eccube\\Entity\\Category,sort_no,,Display Priority,2,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv 201,5,,Eccube\\Entity\\Category,name,,Category Name,3,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv @@ -203,6 +204,7 @@ id,csv_type_id,creator_id,entity_name,field_name,reference_field_name,disp_name, 204,1,,Eccube\\Entity\\ProductClass,TaxRule,tax_rate,Tax Rate,"31","0","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" 205,2,,Eccube\\Entity\\Customer,point,,Point,32,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv 206,1,,Eccube\\Entity\\ProductClass,visible,,Product options visible flag,32,0,2022-11-24 00:00:00,2022-11-24 00:00:00,csv +215,1,,Eccube\\Entity\\Product,order_memo,,Order Management Memo,33,1,2017-03-07 10:14:00,2017-03-07 10:14:00,csv 207,6,,Eccube\\Entity\\ClassName,id,,Class Name ID,1,1,2021-05-18 01:26:41,2021-05-18 01:26:41,csv 208,6,,Eccube\\Entity\\ClassName,name,,Class Name,2,1,2021-05-18 01:26:41,2021-05-18 01:26:41,csv 209,6,,Eccube\\Entity\\ClassName,backend_name,,Backend Name,3,1,2021-05-18 01:26:41,2021-05-18 01:26:41,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..70b99e10d47 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_csv.csv +++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_csv.csv @@ -195,6 +195,7 @@ "196","4",,"Eccube\\Entity\\Shipping","tracking_number",,"出荷伝票番号","69","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" "197","4",,"Eccube\\Entity\\Shipping","note",,"配達用メモ","70","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" "198","4",,"Eccube\\Entity\\Shipping","mail_send_date",,"出荷メール送信日","71","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" +"216","4",,"Eccube\\Entity\\OrderItem","order_memo",,"受注管理用メモ","72","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" "199","5",,"Eccube\\Entity\\Category","id",,"カテゴリID","1","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" "200","5",,"Eccube\\Entity\\Category","sort_no",,"表示ランク","2","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" "201","5",,"Eccube\\Entity\\Category","name",,"カテゴリ名","3","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" @@ -203,6 +204,7 @@ "204","1",,"Eccube\\Entity\\ProductClass","TaxRule","tax_rate","税率","31","0","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" "205","2",,"Eccube\\Entity\\Customer","point",,"ポイント","33","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" "206","1",,"Eccube\\Entity\\ProductClass","visible",,"商品規格表示フラグ","32","0","2022-11-24 00:00:00","2022-11-24 00:00:00","csv" +"215","1",,"Eccube\\Entity\\Product","order_memo",,"受注管理用メモ","33","1","2017-03-07 10:14:00","2017-03-07 10:14:00","csv" "207","6",,"Eccube\\Entity\\ClassName","id",,"規格ID","1","1","2021-05-18 01:26:41","2021-05-18 01:26:41","csv" "208","6",,"Eccube\\Entity\\ClassName","name",,"規格名","2","1","2021-05-18 01:26:41","2021-05-18 01:26:41","csv" "209","6",,"Eccube\\Entity\\ClassName","backend_name",,"管理名","3","1","2021-05-18 01:26:41","2021-05-18 01:26:41","csv" diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 3807b8b6380..97525f298e3 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -666,6 +666,8 @@ admin.product.delivery_fee: Shipping Charge admin.product.tax_rate: Tax admin.product.free_area: Miscellaneous admin.product.free_area__card_title: Miscellaneous +admin.product.order_memo: Order Management Memo +admin.product.order_memo__card_title: Order Management Memo admin.product.delete_flag: Product Deletion Flag admin.product.search_category: Category Search admin.product.save_tag: Tags @@ -737,6 +739,8 @@ admin.product.product_csv.keyword_col: Search Keywords admin.product.product_csv.keyword_description: "" admin.product.product_csv.free_area_col: Miscellaneous admin.product.product_csv.free_area_description: "" +admin.product.product_csv.order_memo_col: Order Management Memo +admin.product.product_csv.order_memo_description: Management memo that will be copied to order items. Not visible to customers. admin.product.product_csv.delete_flag_col: Product Deletion Flag admin.product.product_csv.delete_flag_description: "Specify 0: Register 1: Delete. If unspecified, it will be set to 0." admin.product.product_csv.product_image_col: Product Images @@ -1696,6 +1700,7 @@ tooltip.product.sale_limit: This will limit the number of products shoppers can tooltip.product.delivery_duration: You can register estimated shipping date per product. tooltip.product.product_class: You can review and manage the option(s) set to this product. tooltip.product.free_area: The entry will be displayed on the product page. The placement varies according to the design templates. +tooltip.product.order_memo: Management memo that will be copied to order items. Not visible to customers. tooltip.product.shop_memo: Notes for store use. This will not be displayed on the Front Screen. tooltip.product.backend_name: "You can register an alias for administrator (e.g. Option Name: Size - Alias: Size (Clothing) or Size (Shoes) etc. It will not be displayed on the Front Screen." tooltip.product.csv_upload: Bulk product registration is available with a CSV template. diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 96445e787c0..8dcbac7f1f0 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -665,6 +665,8 @@ admin.product.delivery_fee: 商品送料 admin.product.tax_rate: 税率 admin.product.free_area: フリーエリア admin.product.free_area__card_title: フリーエリア +admin.product.order_memo: 受注管理用メモ +admin.product.order_memo__card_title: 受注管理用メモ admin.product.delete_flag: 商品削除フラグ admin.product.search_category: カテゴリ検索 admin.product.save_tag: タグ登録 @@ -736,6 +738,8 @@ admin.product.product_csv.keyword_col: 検索ワード admin.product.product_csv.keyword_description: "" admin.product.product_csv.free_area_col: フリーエリア admin.product.product_csv.free_area_description: "" +admin.product.product_csv.order_memo_col: 受注管理用メモ +admin.product.product_csv.order_memo_description: 受注時に注文明細へコピーされる管理用メモです。顧客には表示されません。 admin.product.product_csv.delete_flag_col: 商品削除フラグ admin.product.product_csv.delete_flag_description: 0:登録 1:削除を指定します。未指定の場合、0として扱います。 admin.product.product_csv.product_image_col: 商品画像 @@ -1696,6 +1700,7 @@ tooltip.product.sale_limit: 購入者が一度に購入できる個数を制限 tooltip.product.delivery_duration: 発送日の目安期間を商品ごとに登録できます。 tooltip.product.product_class: この商品に設定された規格を確認・管理できます。 tooltip.product.free_area: 商品詳細ページに入力内容が表示されます。表示位置はデザインテンプレートによって異なります。 +tooltip.product.order_memo: 受注時に注文明細へコピーされる管理用メモです。顧客には表示されません。 tooltip.product.shop_memo: 店舗用のメモ欄です。フロント画面には表示されません。 tooltip.product.backend_name: 管理者用に別名を登録しておくことができます(例:規格名:サイズ 管理名:サイズ(服)、サイズ(靴)等 )。フロント画面には表示されません。 tooltip.product.csv_upload: 所定の型のCSVデータを用いて商品を一括で登録することができます。 diff --git a/src/Eccube/Resource/template/admin/Order/edit.twig b/src/Eccube/Resource/template/admin/Order/edit.twig index 77f77c8b669..9a84ae4541c 100644 --- a/src/Eccube/Resource/template/admin/Order/edit.twig +++ b/src/Eccube/Resource/template/admin/Order/edit.twig @@ -781,6 +781,31 @@ file that was distributed with this source code. {% endif %} {{ form_errors(orderItemForm.product_name) }} + {# 受注管理用メモ(メモがある商品明細のみアイコン表示・表示専用) #} + {% if OrderItem.isProduct and OrderItem.order_memo is not empty %} + + + + + + + {% endif %} diff --git a/src/Eccube/Resource/template/admin/Product/product.twig b/src/Eccube/Resource/template/admin/Product/product.twig index 664cc9b7188..c15c813b0d5 100644 --- a/src/Eccube/Resource/template/admin/Product/product.twig +++ b/src/Eccube/Resource/template/admin/Product/product.twig @@ -680,6 +680,41 @@ file that was distributed with this source code. +
+
+
+
+
+ {{ 'admin.product.order_memo__card_title'|trans }} + +
+
+
+ +
+
+
+
+
+
+
+ {{ 'admin.product.order_memo'|trans }} +
+
+
+ {{ form_widget(form.order_memo, {attr : { rows : "8"} }) }} + {{ form_errors(form.order_memo) }} +
+
+
+
+
+ +
diff --git a/src/Eccube/Service/PurchaseFlow/Processor/OrderMemoPreprocessor.php b/src/Eccube/Service/PurchaseFlow/Processor/OrderMemoPreprocessor.php new file mode 100644 index 00000000000..106df301f3b --- /dev/null +++ b/src/Eccube/Service/PurchaseFlow/Processor/OrderMemoPreprocessor.php @@ -0,0 +1,66 @@ +getOrderItems() as $OrderItem) { + // 商品明細のみ対象(送料・手数料・値引き等は対象外) + if (!$OrderItem->isProduct()) { + continue; + } + + $productMemo = $OrderItem->getProduct()?->getOrderMemo(); + if ($productMemo === null || $productMemo === '') { + continue; + } + + $current = $OrderItem->getOrderMemo(); + // 同一文言が「行」として既に含まれていれば追記しない(冪等). + // 単純な部分一致だと, 既存行が商品メモを偶然部分文字列として含む場合に誤スキップするため, + // 改行で境界を区切って判定する(複数行の商品メモにも対応). + if ($current !== null && $current !== '' + && str_contains("\n".$current."\n", "\n".$productMemo."\n")) { + continue; + } + + $OrderItem->setOrderMemo( + ($current === null || $current === '') ? $productMemo : $current."\n".$productMemo + ); + } + } +} diff --git a/tests/Eccube/Tests/Service/PurchaseFlow/OrderMemoFlowTest.php b/tests/Eccube/Tests/Service/PurchaseFlow/OrderMemoFlowTest.php new file mode 100644 index 00000000000..9d72e6ab54f --- /dev/null +++ b/tests/Eccube/Tests/Service/PurchaseFlow/OrderMemoFlowTest.php @@ -0,0 +1,55 @@ +shoppingFlow = static::getContainer()->get('eccube.purchase.flow.shopping'); + $this->orderFlow = static::getContainer()->get('eccube.purchase.flow.order'); + } + + public function testRegisteredInShoppingFlow(): void + { + // 確定フローには OrderMemoPreprocessor が登録されている + $this->assertStringContainsString(OrderMemoPreprocessor::class, $this->shoppingFlow->dump()); + } + + public function testRegisteredInOrderFlow(): void + { + // 受注フローにも登録されている(管理画面の受注作成・編集時にも追記する) + $this->assertStringContainsString(OrderMemoPreprocessor::class, $this->orderFlow->dump()); + } +} diff --git a/tests/Eccube/Tests/Service/PurchaseFlow/Processor/OrderMemoPreprocessorTest.php b/tests/Eccube/Tests/Service/PurchaseFlow/Processor/OrderMemoPreprocessorTest.php new file mode 100644 index 00000000000..40fa2b33734 --- /dev/null +++ b/tests/Eccube/Tests/Service/PurchaseFlow/Processor/OrderMemoPreprocessorTest.php @@ -0,0 +1,150 @@ +processor = new OrderMemoPreprocessor(); + $Customer = $this->createCustomer(); + $this->Product = $this->createProduct('test', 1); + $this->Order = $this->createOrderWithProductClasses($Customer, $this->Product->getProductClasses()->toArray()); + $this->entityManager->flush(); + } + + public function testCopyMemoToProductItem(): void + { + $this->Product->setOrderMemo('梱包時は割れ物注意'); + + $this->processor->process($this->Order, new PurchaseContext()); + + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $this->assertSame('梱包時は割れ物注意', $OrderItem->getOrderMemo()); + } + } + + public function testCopyNullMemo(): void + { + $this->Product->setOrderMemo(null); + + $this->processor->process($this->Order, new PurchaseContext()); + + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $this->assertNull($OrderItem->getOrderMemo()); + } + } + + public function testSameMemoIsNotAppendedTwice(): void + { + $this->Product->setOrderMemo('梱包時は割れ物注意'); + + // 既に同一文言が入っている明細に対しては追記しない(冪等) + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $OrderItem->setOrderMemo('梱包時は割れ物注意'); + } + + $this->processor->process($this->Order, new PurchaseContext()); + + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $this->assertSame('梱包時は割れ物注意', $OrderItem->getOrderMemo()); + } + } + + public function testDifferentMemoIsAppended(): void + { + $this->Product->setOrderMemo('商品メモ'); + + // 既存メモがある明細には、改行区切りで追記し既存メモは残す + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $OrderItem->setOrderMemo('既存メモ'); + } + + $this->processor->process($this->Order, new PurchaseContext()); + + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $this->assertSame("既存メモ\n商品メモ", $OrderItem->getOrderMemo()); + } + } + + public function testPartialMatchIsStillAppended(): void + { + // 商品メモが既存メモ行の部分文字列に偶然含まれていても, 行として一致しなければ追記する. + $this->Product->setOrderMemo('注意'); + + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $OrderItem->setOrderMemo('取扱注意事項あり'); + } + + $this->processor->process($this->Order, new PurchaseContext()); + + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $this->assertSame("取扱注意事項あり\n注意", $OrderItem->getOrderMemo()); + } + } + + public function testExactLineMatchInMultilineMemoIsNotAppended(): void + { + // 複数行メモのうち1行が商品メモと完全一致する場合は追記しない(冪等). + $this->Product->setOrderMemo('商品メモ'); + + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $OrderItem->setOrderMemo("他メモ\n商品メモ"); + } + + $this->processor->process($this->Order, new PurchaseContext()); + + foreach ($this->Order->getProductOrderItems() as $OrderItem) { + $this->assertSame("他メモ\n商品メモ", $OrderItem->getOrderMemo()); + } + } + + public function testNonProductItemIsNotCopied(): void + { + $this->Product->setOrderMemo('商品メモ'); + + // 送料明細(商品メモを持つ Product を紐づけても、商品明細でなければコピーされない) + $DeliveryFeeType = $this->entityManager->find(OrderItemType::class, OrderItemType::DELIVERY_FEE); + $FeeItem = new OrderItem(); + $this->assertInstanceOf(OrderItemType::class, $DeliveryFeeType); + $FeeItem->setOrderItemType($DeliveryFeeType) + ->setProduct($this->Product) + ->setProductName('送料') + ->setPrice('500') + ->setQuantity('1'); + $this->Order->addOrderItem($FeeItem); + + $this->processor->process($this->Order, new PurchaseContext()); + + $this->assertFalse($FeeItem->isProduct()); + $this->assertNull($FeeItem->getOrderMemo()); + } +} diff --git a/tests/Eccube/Tests/Web/Admin/Product/CsvImportControllerTest.php b/tests/Eccube/Tests/Web/Admin/Product/CsvImportControllerTest.php index 2bf65314cbb..80ecfeb4d57 100644 --- a/tests/Eccube/Tests/Web/Admin/Product/CsvImportControllerTest.php +++ b/tests/Eccube/Tests/Web/Admin/Product/CsvImportControllerTest.php @@ -887,6 +887,29 @@ public static function dataDescriptionDetailProvider(): \Iterator yield [3001, 'div.text-danger', '/2行目の商品説明\(詳細\)は3000文字以下の文字列を指定してください。/u']; } + /** + * 商品CSVインポートで受注管理用メモ列が取り込まれることを確認する. + * + * @see https://github.com/EC-CUBE/ec-cube/issues/6821 + */ + public function testImportProductWithOrderMemo(): void + { + $csv = []; + $csv[] = ['公開ステータス(ID)', '商品名', '販売種別(ID)', '在庫数無制限フラグ', '販売価格', '受注管理用メモ']; + $csv[] = [1, '受注メモCSVテスト', 1, 1, 1000, '梱包時は割れ物注意']; + $this->filepath = $this->createCsvFromArray($csv); + + $crawler = $this->scenario(); + $this->assertMatchesRegularExpression( + '/CSVファイルをアップロードしました/u', + $crawler->filter('div.alert-success')->text() + ); + + $Product = $this->productRepo->findOneBy(['name' => '受注メモCSVテスト']); + $this->assertInstanceOf(Product::class, $Product); + $this->assertSame('梱包時は割れ物注意', $Product->getOrderMemo()); + } + /** * @see https://github.com/EC-CUBE/ec-cube/pull/4281 * diff --git a/tests/Eccube/Tests/Web/Admin/Product/ProductControllerTest.php b/tests/Eccube/Tests/Web/Admin/Product/ProductControllerTest.php index 6c62695cf98..7b898d2b878 100644 --- a/tests/Eccube/Tests/Web/Admin/Product/ProductControllerTest.php +++ b/tests/Eccube/Tests/Web/Admin/Product/ProductControllerTest.php @@ -114,6 +114,7 @@ public function createFormData() 'Tag' => [1], 'search_word' => $faker->word(), 'free_area' => $faker->realText, + 'order_memo' => $faker->realText, 'Status' => 1, 'note' => $faker->realText, 'tags' => [], @@ -1268,4 +1269,44 @@ public static function purifyTarget(): array ['free_area', 'getFreeArea'], ]; } + + /** + * 受注管理用メモが保存できることを確認する. + */ + public function testEditWithOrderMemo(): void + { + $Product = $this->createProduct(null, 0); + $formData = $this->createFormData(); + $formData['order_memo'] = '梱包時は割れ物注意'; + + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('admin_product_product_edit', ['id' => $Product->getId()]), + ['admin_product' => $formData] + ); + + $this->assertTrue($this->client->getResponse()->isRedirection()); + // 保存後の永続化状態を確認するため DB から再読込する + $this->entityManager->refresh($Product); + $this->assertSame('梱包時は割れ物注意', $Product->getOrderMemo()); + } + + /** + * 受注管理用メモが文字数上限を超えるとバリデーションエラーになることを確認する. + */ + public function testEditWithOrderMemoOverMaxLength(): void + { + $Product = $this->createProduct(null, 0); + $formData = $this->createFormData(); + $formData['order_memo'] = str_repeat('a', $this->eccubeConfig['eccube_lltext_len'] + 1); + + $this->client->request( + Request::METHOD_POST, + $this->generateUrl('admin_product_product_edit', ['id' => $Product->getId()]), + ['admin_product' => $formData] + ); + + // バリデーションエラーのため再描画され、リダイレクトしない + $this->assertFalse($this->client->getResponse()->isRedirection()); + } }