From d4d66d20ef09d041b2167582589142dc09f12e4b Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Wed, 3 Jun 2026 14:22:55 +0900 Subject: [PATCH 01/28] =?UTF-8?q?feat(agent-commerce):=20=E3=82=A8?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88=E3=82=B3=E3=83=9E?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E5=85=B1=E9=80=9A=E5=9F=BA=E7=9B=A4=20Phase?= =?UTF-8?q?=201a=20=E3=82=92=E8=BF=BD=E5=8A=A0=20(#6777)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACP/UCP 対応の共通基盤のうち CheckoutSession 非依存の先行スライスを実装。 トラック A (Product Feed / Discovery) を解放する最小集合。 - MinorUnitConverter: 通貨 minor unit 変換 (bcmath / ゼロデシマル / 負数) - AddressMappingService: 住所マッピング・国コード numeric→alpha-2 (全249件網羅) - AgentCommerceScopeRegistry: : scope 照合 - KeyStoreInterface / FilesystemKeyStore: 鍵保管 (env パス上書き→既定ファイル、#6797 雛形) - AgentCommerceMessageSignerInterface / UcpMessageSigner: RFC 9421 EC P-256 / JWK 公開鍵 / 鍵ローテーション grace - BaseInfo にフラグ5 (acp/ucp 有効化等、default false) + google_pay_merchant_id + migration - 秘密鍵は dtb_baseinfo でなく app/keystore/ に保管 (#6797、.htaccess/.gitignore 多重防御) プライバシー/利用規約 URL はカラム化せず標準ページ (help_privacy/help_agreement) から自動生成する方針。 検証: PHPUnit 53 tests / 611 assertions / 0 失敗、PHPStan level6 No errors、php-cs-fixer 0件。 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + .htaccess | 2 +- .../Version20260602120000.php | 80 ++++ app/config/eccube/services.yaml | 13 + app/keystore/.gitkeep | 0 app/keystore/.htaccess | 2 + src/Eccube/Entity/BaseInfo.php | 126 ++++++ .../doctrine/import_csv/en/dtb_base_info.csv | 2 +- .../doctrine/import_csv/ja/dtb_base_info.csv | 2 +- .../AgentCommerce/AddressMappingService.php | 394 ++++++++++++++++++ .../AgentCommerce/MinorUnitConverter.php | 107 +++++ .../AgentCommerceMessageSignerInterface.php | 58 +++ .../Security/AgentCommerceScopeRegistry.php | 99 +++++ .../Security/FilesystemKeyStore.php | 83 ++++ .../Security/KeyStoreInterface.php | 40 ++ .../Security/UcpMessageSigner.php | 283 +++++++++++++ .../AddressMappingServiceTest.php | 181 ++++++++ .../BaseInfoAgentCommerceFlagsTest.php | 87 ++++ .../AgentCommerceBaseConformanceTest.php | 94 +++++ .../AgentCommerce/MinorUnitConverterTest.php | 110 +++++ .../AgentCommerceScopeRegistryTest.php | 128 ++++++ .../Security/UcpMessageSignerTest.php | 157 +++++++ 22 files changed, 2048 insertions(+), 3 deletions(-) create mode 100644 app/DoctrineMigrations/Version20260602120000.php create mode 100644 app/keystore/.gitkeep create mode 100644 app/keystore/.htaccess create mode 100644 src/Eccube/Service/AgentCommerce/AddressMappingService.php create mode 100644 src/Eccube/Service/AgentCommerce/MinorUnitConverter.php create mode 100644 src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php create mode 100644 src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php create mode 100644 src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php create mode 100644 src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php create mode 100644 src/Eccube/Service/AgentCommerce/Security/UcpMessageSigner.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php diff --git a/.gitignore b/.gitignore index 52dcccb2915..8338e814354 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ node_modules !/app/Plugin/.gitkeep /app/PluginData/* !/app/PluginData/.gitkeep +/app/keystore/* +!/app/keystore/.gitkeep +!/app/keystore/.htaccess /app/template/* !/app/template/admin !/app/template/default diff --git a/.htaccess b/.htaccess index 4439047aca8..015024cf20f 100644 --- a/.htaccess +++ b/.htaccess @@ -12,7 +12,7 @@ DirectoryIndex index.php index.html .ht Allow from all - + order allow,deny deny from all diff --git a/app/DoctrineMigrations/Version20260602120000.php b/app/DoctrineMigrations/Version20260602120000.php new file mode 100644 index 00000000000..bd10e0b8ebf --- /dev/null +++ b/app/DoctrineMigrations/Version20260602120000.php @@ -0,0 +1,80 @@ +hasTable(self::NAME)) { + return; + } + + $table = $schema->getTable(self::NAME); + + if (!$table->hasColumn('acp_enabled')) { + $this->addSql('ALTER TABLE dtb_base_info ADD acp_enabled BOOLEAN NOT NULL DEFAULT false'); + } + if (!$table->hasColumn('ucp_enabled')) { + $this->addSql('ALTER TABLE dtb_base_info ADD ucp_enabled BOOLEAN NOT NULL DEFAULT false'); + } + if (!$table->hasColumn('acp_feed_enabled')) { + $this->addSql('ALTER TABLE dtb_base_info ADD acp_feed_enabled BOOLEAN NOT NULL DEFAULT false'); + } + if (!$table->hasColumn('ucp_catalog_api_enabled')) { + $this->addSql('ALTER TABLE dtb_base_info ADD ucp_catalog_api_enabled BOOLEAN NOT NULL DEFAULT false'); + } + if (!$table->hasColumn('ucp_catalog_requires_auth')) { + $this->addSql('ALTER TABLE dtb_base_info ADD ucp_catalog_requires_auth BOOLEAN NOT NULL DEFAULT false'); + } + if (!$table->hasColumn('google_pay_merchant_id')) { + $this->addSql('ALTER TABLE dtb_base_info ADD google_pay_merchant_id VARCHAR(255) DEFAULT NULL'); + } + } + + public function down(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + $table = $schema->getTable(self::NAME); + + if ($table->hasColumn('acp_enabled')) { + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN acp_enabled'); + } + if ($table->hasColumn('ucp_enabled')) { + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN ucp_enabled'); + } + if ($table->hasColumn('acp_feed_enabled')) { + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN acp_feed_enabled'); + } + if ($table->hasColumn('ucp_catalog_api_enabled')) { + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN ucp_catalog_api_enabled'); + } + if ($table->hasColumn('ucp_catalog_requires_auth')) { + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN ucp_catalog_requires_auth'); + } + if ($table->hasColumn('google_pay_merchant_id')) { + $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN google_pay_merchant_id'); + } + } +} diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index ebdebaa9853..c6c790ff9e7 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -237,3 +237,16 @@ services: eccube.asset.user_data_version_strategy: alias: Eccube\Asset\FilemtimeVersionStrategy public: true + + # Agent Commerce (ACP / UCP) 共通基盤 + Eccube\Service\AgentCommerce\Security\FilesystemKeyStore: + arguments: + $projectDir: '%kernel.project_dir%' + $envPathOverrides: + ucp_signing: '%env(default::string:ECCUBE_AGENT_COMMERCE_UCP_SIGNING_KEY)%' + + Eccube\Service\AgentCommerce\Security\KeyStoreInterface: + alias: Eccube\Service\AgentCommerce\Security\FilesystemKeyStore + + Eccube\Service\AgentCommerce\Security\AgentCommerceMessageSignerInterface: + alias: Eccube\Service\AgentCommerce\Security\UcpMessageSigner diff --git a/app/keystore/.gitkeep b/app/keystore/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/keystore/.htaccess b/app/keystore/.htaccess new file mode 100644 index 00000000000..baa56e5a369 --- /dev/null +++ b/app/keystore/.htaccess @@ -0,0 +1,2 @@ +order allow,deny +deny from all \ No newline at end of file diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 042253f5157..68d76acffe8 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -154,6 +154,24 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'ga_id', type: Types::STRING, length: 255, nullable: true)] private ?string $gaId = null; + #[ORM\Column(name: 'acp_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $acp_enabled = false; + + #[ORM\Column(name: 'ucp_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_enabled = false; + + #[ORM\Column(name: 'acp_feed_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $acp_feed_enabled = false; + + #[ORM\Column(name: 'ucp_catalog_api_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_catalog_api_enabled = false; + + #[ORM\Column(name: 'ucp_catalog_requires_auth', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_catalog_requires_auth = false; + + #[ORM\Column(name: 'google_pay_merchant_id', type: Types::STRING, length: 255, nullable: true)] + private ?string $google_pay_merchant_id = null; + /** * Get id. * @@ -831,5 +849,113 @@ public function getGaId(): ?string { return $this->gaId; } + + /** + * Set acpEnabled. + */ + public function setAcpEnabled(bool $acpEnabled): BaseInfo + { + $this->acp_enabled = $acpEnabled; + + return $this; + } + + /** + * Get acpEnabled. + */ + public function isAcpEnabled(): bool + { + return $this->acp_enabled; + } + + /** + * Set ucpEnabled. + */ + public function setUcpEnabled(bool $ucpEnabled): BaseInfo + { + $this->ucp_enabled = $ucpEnabled; + + return $this; + } + + /** + * Get ucpEnabled. + */ + public function isUcpEnabled(): bool + { + return $this->ucp_enabled; + } + + /** + * Set acpFeedEnabled. + */ + public function setAcpFeedEnabled(bool $acpFeedEnabled): BaseInfo + { + $this->acp_feed_enabled = $acpFeedEnabled; + + return $this; + } + + /** + * Get acpFeedEnabled. + */ + public function isAcpFeedEnabled(): bool + { + return $this->acp_feed_enabled; + } + + /** + * Set ucpCatalogApiEnabled. + */ + public function setUcpCatalogApiEnabled(bool $ucpCatalogApiEnabled): BaseInfo + { + $this->ucp_catalog_api_enabled = $ucpCatalogApiEnabled; + + return $this; + } + + /** + * Get ucpCatalogApiEnabled. + */ + public function isUcpCatalogApiEnabled(): bool + { + return $this->ucp_catalog_api_enabled; + } + + /** + * Set ucpCatalogRequiresAuth. + */ + public function setUcpCatalogRequiresAuth(bool $ucpCatalogRequiresAuth): BaseInfo + { + $this->ucp_catalog_requires_auth = $ucpCatalogRequiresAuth; + + return $this; + } + + /** + * Get ucpCatalogRequiresAuth. + */ + public function isUcpCatalogRequiresAuth(): bool + { + return $this->ucp_catalog_requires_auth; + } + + /** + * Set googlePayMerchantId. + */ + public function setGooglePayMerchantId(?string $googlePayMerchantId = null): BaseInfo + { + $this->google_pay_merchant_id = $googlePayMerchantId; + + return $this; + } + + /** + * Get googlePayMerchantId. + */ + public function getGooglePayMerchantId(): ?string + { + return $this->google_pay_merchant_id; + } } } diff --git a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv index 95a7ffd2662..98c784c61a2 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_enabled,ucp_enabled,acp_feed_enabled,ucp_catalog_api_enabled,ucp_catalog_requires_auth,google_pay_merchant_id diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv index 95a7ffd2662..98c784c61a2 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_enabled,ucp_enabled,acp_feed_enabled,ucp_catalog_api_enabled,ucp_catalog_requires_auth,google_pay_merchant_id diff --git a/src/Eccube/Service/AgentCommerce/AddressMappingService.php b/src/Eccube/Service/AgentCommerce/AddressMappingService.php new file mode 100644 index 00000000000..128844974d9 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/AddressMappingService.php @@ -0,0 +1,394 @@ + alpha-2。 + * mtb_country.csv に出現する全 id を網羅する。未知 id は null を返す設計。 + * + * @var array + */ + private const NUMERIC_TO_ALPHA2 = [ + 352 => 'IS', // アイスランド + 372 => 'IE', // アイルランド + 31 => 'AZ', // アゼルバイジャン + 4 => 'AF', // アフガニスタン + 840 => 'US', // アメリカ合衆国 + 850 => 'VI', // アメリカ領ヴァージン諸島 + 16 => 'AS', // アメリカ領サモア + 784 => 'AE', // アラブ首長国連邦 + 12 => 'DZ', // アルジェリア + 32 => 'AR', // アルゼンチン + 533 => 'AW', // アルバ + 8 => 'AL', // アルバニア + 51 => 'AM', // アルメニア + 660 => 'AI', // アンギラ + 24 => 'AO', // アンゴラ + 28 => 'AG', // アンティグア・バーブーダ + 20 => 'AD', // アンドラ + 887 => 'YE', // イエメン + 826 => 'GB', // イギリス + 86 => 'IO', // イギリス領インド洋地域 + 92 => 'VG', // イギリス領ヴァージン諸島 + 376 => 'IL', // イスラエル + 380 => 'IT', // イタリア + 368 => 'IQ', // イラク + 364 => 'IR', // イラン + 356 => 'IN', // インド + 360 => 'ID', // インドネシア + 876 => 'WF', // ウォリス・フツナ + 800 => 'UG', // ウガンダ + 804 => 'UA', // ウクライナ + 860 => 'UZ', // ウズベキスタン + 858 => 'UY', // ウルグアイ + 218 => 'EC', // エクアドル + 818 => 'EG', // エジプト + 233 => 'EE', // エストニア + 231 => 'ET', // エチオピア + 232 => 'ER', // エリトリア + 222 => 'SV', // エルサルバドル + 36 => 'AU', // オーストラリア + 40 => 'AT', // オーストリア + 248 => 'AX', // オーランド諸島 + 512 => 'OM', // オマーン + 528 => 'NL', // オランダ + 288 => 'GH', // ガーナ + 132 => 'CV', // カーボベルデ + 831 => 'GG', // ガーンジー + 328 => 'GY', // ガイアナ + 398 => 'KZ', // カザフスタン + 634 => 'QA', // カタール + 581 => 'UM', // 合衆国領有小離島 + 124 => 'CA', // カナダ + 266 => 'GA', // ガボン + 120 => 'CM', // カメルーン + 270 => 'GM', // ガンビア + 116 => 'KH', // カンボジア + 580 => 'MP', // 北マリアナ諸島 + 324 => 'GN', // ギニア + 624 => 'GW', // ギニアビサウ + 196 => 'CY', // キプロス + 192 => 'CU', // キューバ + 531 => 'CW', // キュラソー島 + 300 => 'GR', // ギリシャ + 296 => 'KI', // キリバス + 417 => 'KG', // キルギス + 320 => 'GT', // グアテマラ + 312 => 'GP', // グアドループ + 316 => 'GU', // グアム + 414 => 'KW', // クウェート + 184 => 'CK', // クック諸島 + 304 => 'GL', // グリーンランド + 162 => 'CX', // クリスマス島 + 268 => 'GE', // グルジア + 308 => 'GD', // グレナダ + 191 => 'HR', // クロアチア + 136 => 'KY', // ケイマン諸島 + 404 => 'KE', // ケニア + 384 => 'CI', // コートジボワール + 166 => 'CC', // ココス諸島 + 188 => 'CR', // コスタリカ + 174 => 'KM', // コモロ + 170 => 'CO', // コロンビア + 178 => 'CG', // コンゴ共和国 + 180 => 'CD', // コンゴ民主共和国 + 682 => 'SA', // サウジアラビア + 239 => 'GS', // サウスジョージア・サウスサンドウィッチ諸島 + 882 => 'WS', // サモア + 678 => 'ST', // サントメ・プリンシペ + 652 => 'BL', // サン・バルテルミー島 + 894 => 'ZM', // ザンビア + 666 => 'PM', // サンピエール島・ミクロン島 + 674 => 'SM', // サンマリノ + 663 => 'MF', // サン・マルタン (フランス領) + 694 => 'SL', // シエラレオネ + 262 => 'DJ', // ジブチ + 292 => 'GI', // ジブラルタル + 832 => 'JE', // ジャージー + 388 => 'JM', // ジャマイカ + 760 => 'SY', // シリア + 702 => 'SG', // シンガポール + 534 => 'SX', // シント・マールテン + 716 => 'ZW', // ジンバブエ + 756 => 'CH', // スイス + 752 => 'SE', // スウェーデン + 729 => 'SD', // スーダン + 744 => 'SJ', // スヴァールバル諸島およびヤンマイエン島 + 724 => 'ES', // スペイン + 740 => 'SR', // スリナム + 144 => 'LK', // スリランカ + 703 => 'SK', // スロバキア + 705 => 'SI', // スロベニア + 748 => 'SZ', // スワジランド + 690 => 'SC', // セーシェル + 226 => 'GQ', // 赤道ギニア + 686 => 'SN', // セネガル + 688 => 'RS', // セルビア + 659 => 'KN', // セントクリストファー・ネイビス + 670 => 'VC', // セントビンセント・グレナディーン + 426 => 'LS', // レソト + 654 => 'SH', // セントヘレナ・アセンションおよびトリスタンダクーニャ + 662 => 'LC', // セントルシア + 706 => 'SO', // ソマリア + 90 => 'SB', // ソロモン諸島 + 796 => 'TC', // タークス・カイコス諸島 + 764 => 'TH', // タイ王国 + 410 => 'KR', // 大韓民国 + 158 => 'TW', // 台湾 + 762 => 'TJ', // タジキスタン + 834 => 'TZ', // タンザニア + 203 => 'CZ', // チェコ + 148 => 'TD', // チャド + 140 => 'CF', // 中央アフリカ共和国 + 156 => 'CN', // 中華人民共和国 + 788 => 'TN', // チュニジア + 408 => 'KP', // 朝鮮民主主義人民共和国 + 152 => 'CL', // チリ + 798 => 'TV', // ツバル + 208 => 'DK', // デンマーク + 276 => 'DE', // ドイツ + 768 => 'TG', // トーゴ + 772 => 'TK', // トケラウ + 214 => 'DO', // ドミニカ共和国 + 212 => 'DM', // ドミニカ国 + 780 => 'TT', // トリニダード・トバゴ + 795 => 'TM', // トルクメニスタン + 792 => 'TR', // トルコ + 776 => 'TO', // トンガ + 566 => 'NG', // ナイジェリア + 520 => 'NR', // ナウル + 516 => 'NA', // ナミビア + 10 => 'AQ', // 南極 + 570 => 'NU', // ニウエ + 558 => 'NI', // ニカラグア + 562 => 'NE', // ニジェール + 392 => 'JP', // 日本 + 732 => 'EH', // 西サハラ + 540 => 'NC', // ニューカレドニア + 554 => 'NZ', // ニュージーランド + 524 => 'NP', // ネパール + 574 => 'NF', // ノーフォーク島 + 578 => 'NO', // ノルウェー + 334 => 'HM', // ハード島とマクドナルド諸島 + 48 => 'BH', // バーレーン + 332 => 'HT', // ハイチ + 586 => 'PK', // パキスタン + 336 => 'VA', // バチカン + 591 => 'PA', // パナマ + 548 => 'VU', // バヌアツ + 44 => 'BS', // バハマ + 598 => 'PG', // パプアニューギニア + 60 => 'BM', // バミューダ諸島 + 585 => 'PW', // パラオ + 600 => 'PY', // パラグアイ + 52 => 'BB', // バルバドス + 275 => 'PS', // パレスチナ + 348 => 'HU', // ハンガリー + 50 => 'BD', // バングラデシュ + 626 => 'TL', // 東ティモール + 612 => 'PN', // ピトケアン諸島 + 242 => 'FJ', // フィジー + 608 => 'PH', // フィリピン + 246 => 'FI', // フィンランド + 64 => 'BT', // ブータン + 74 => 'BV', // ブーベ島 + 630 => 'PR', // プエルトリコ + 234 => 'FO', // フェロー諸島 + 238 => 'FK', // フォークランド諸島 + 76 => 'BR', // ブラジル + 250 => 'FR', // フランス + 254 => 'GF', // フランス領ギアナ + 258 => 'PF', // フランス領ポリネシア + 260 => 'TF', // フランス領南方・南極地域 + 100 => 'BG', // ブルガリア + 854 => 'BF', // ブルキナファソ + 96 => 'BN', // ブルネイ + 108 => 'BI', // ブルンジ + 704 => 'VN', // ベトナム + 204 => 'BJ', // ベナン + 862 => 'VE', // ベネズエラ + 112 => 'BY', // ベラルーシ + 84 => 'BZ', // ベリーズ + 604 => 'PE', // ペルー + 56 => 'BE', // ベルギー + 616 => 'PL', // ポーランド + 70 => 'BA', // ボスニア・ヘルツェゴビナ + 72 => 'BW', // ボツワナ + 535 => 'BQ', // BES諸島 + 68 => 'BO', // ボリビア + 620 => 'PT', // ポルトガル + 344 => 'HK', // 香港 + 340 => 'HN', // ホンジュラス + 584 => 'MH', // マーシャル諸島 + 446 => 'MO', // マカオ + 807 => 'MK', // マケドニア共和国 + 450 => 'MG', // マダガスカル + 175 => 'YT', // マヨット + 454 => 'MW', // マラウイ + 466 => 'ML', // マリ共和国 + 470 => 'MT', // マルタ + 474 => 'MQ', // マルティニーク + 458 => 'MY', // マレーシア + 833 => 'IM', // マン島 + 583 => 'FM', // ミクロネシア連邦 + 710 => 'ZA', // 南アフリカ共和国 + 728 => 'SS', // 南スーダン + 104 => 'MM', // ミャンマー + 484 => 'MX', // メキシコ + 480 => 'MU', // モーリシャス + 478 => 'MR', // モーリタニア + 508 => 'MZ', // モザンビーク + 492 => 'MC', // モナコ + 462 => 'MV', // モルディブ + 498 => 'MD', // モルドバ + 504 => 'MA', // モロッコ + 496 => 'MN', // モンゴル国 + 499 => 'ME', // モンテネグロ + 500 => 'MS', // モントセラト + 400 => 'JO', // ヨルダン + 418 => 'LA', // ラオス + 428 => 'LV', // ラトビア + 440 => 'LT', // リトアニア + 434 => 'LY', // リビア + 438 => 'LI', // リヒテンシュタイン + 430 => 'LR', // リベリア + 642 => 'RO', // ルーマニア + 442 => 'LU', // ルクセンブルク + 646 => 'RW', // ルワンダ + 422 => 'LB', // レバノン + 638 => 'RE', // レユニオン + 643 => 'RU', // ロシア + ]; + + /** + * ISO 3166-1 numeric (Country.id) を alpha-2 へ変換する。 + * 未知の id / null は null を返す。 + */ + public function getAlpha2FromCountryId(?int $numericCountryId): ?string + { + if ($numericCountryId === null) { + return null; + } + + return self::NUMERIC_TO_ALPHA2[$numericCountryId] ?? null; + } + + /** + * Pref から region 文字列 (都道府県名) を返す。null は null を返す。 + */ + public function getRegionFromPref(?Pref $pref): ?string + { + if ($pref === null) { + return null; + } + + return $pref->getName(); + } + + /** + * 住所系エンティティを ACP / UCP の住所 DTO 相当の配列へ写す。 + * + * @return array{ + * family_name: ?string, + * given_name: ?string, + * family_name_kana: ?string, + * given_name_kana: ?string, + * company: ?string, + * postal_code: ?string, + * region: ?string, + * address1: ?string, + * address2: ?string, + * country: ?string, + * phone: ?string + * } + */ + public function toAddressArray(Customer|CustomerAddress|Shipping $source): array + { + $country = $this->extractCountry($source); + $countryId = $country !== null ? $country->getId() : null; + + return [ + 'family_name' => $this->callIfExists($source, 'getName01'), + 'given_name' => $this->callIfExists($source, 'getName02'), + 'family_name_kana' => $this->callIfExists($source, 'getKana01'), + 'given_name_kana' => $this->callIfExists($source, 'getKana02'), + 'company' => $this->callIfExists($source, 'getCompanyName'), + 'postal_code' => $this->callIfExists($source, 'getPostalCode'), + 'region' => $this->getRegionFromPref($this->extractPref($source)), + 'address1' => $this->callIfExists($source, 'getAddr01'), + 'address2' => $this->callIfExists($source, 'getAddr02'), + 'country' => $this->getAlpha2FromCountryId($countryId !== null ? (int) $countryId : null), + 'phone' => $this->extractPhoneNumber($source), + ]; + } + + private function extractCountry(Customer|CustomerAddress|Shipping $source): ?Country + { + // Customer / CustomerAddress / Shipping いずれも getCountry() を持つ。 + return $source->getCountry(); + } + + private function extractPref(Customer|CustomerAddress|Shipping $source): ?Pref + { + // Customer / CustomerAddress / Shipping いずれも getPref() を持つ。 + return $source->getPref(); + } + + /** + * 電話番号を取得する。 + * Customer / CustomerAddress / Shipping いずれも getPhoneNumber() を持つ。 + */ + private function extractPhoneNumber(Customer|CustomerAddress|Shipping $source): ?string + { + return $source->getPhoneNumber(); + } + + /** + * 指定 getter が存在すれば呼び出して文字列(or null)を返す。存在しなければ null。 + */ + private function callIfExists(Customer|CustomerAddress|Shipping $source, string $method): ?string + { + if (!method_exists($source, $method)) { + return null; + } + + try { + $value = $source->$method(); + } catch (\TypeError) { + // 一部エンティティ (Shipping の getName01/getKana01 等) は getter の戻り型が + // 非 null の string だが、値が未設定だと TypeError になる。null 扱いにする。 + return null; + } + + return $value === null ? null : (string) $value; + } +} diff --git a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php new file mode 100644 index 00000000000..e85f82fd15d --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php @@ -0,0 +1,107 @@ + 1234, ("1000", "JPY") => 1000, ("-1.5", "BHD") => -1500 + * + * @param string $amount major-unit の金額 (decimal 文字列). 負数可 + * @param string $currency ISO 4217 通貨コード (例: USD, JPY, BHD) + */ + public function toMinorUnits(string $amount, string $currency): int + { + $amount = trim($amount); + $digits = $this->getFractionDigits($currency); + + // 符号を分離して非負の値として丸め、最後に符号を戻す (round-half-up を絶対値で行うため)。 + $negative = false; + if (str_starts_with($amount, '-')) { + $negative = true; + $amount = substr($amount, 1); + } elseif (str_starts_with($amount, '+')) { + $amount = substr($amount, 1); + } + + // 空や不正な表記は 0 とみなす。 + if ($amount === '' || $amount === '.') { + return 0; + } + + // scale を桁数 + 1 にして、丸め用の補正を加える。 + $scale = $digits + 1; + $factor = bcpow('10', (string) $digits, 0); + + // amount * 10^digits を計算 (まだ小数を含みうる)。 + $scaled = bcmul($amount, $factor, $scale); + + // round-half-up: 正の値に 0.5 を足してから切り捨て (bcmath は truncate)。 + $rounded = bcadd($scaled, '0.5', 0); + + $result = (int) $rounded; + + return $negative ? -$result : $result; + } + + /** + * minor-unit の整数を major-unit の decimal 文字列へ変換する. + * + * 例: (1234, "USD") => "12.34", (1000, "JPY") => "1000", (-1500, "BHD") => "-1.500" + * + * @param int $minorUnits minor-unit の整数. 負数可 + * @param string $currency ISO 4217 通貨コード + */ + public function toAmountString(int $minorUnits, string $currency): string + { + $digits = $this->getFractionDigits($currency); + + if ($digits === 0) { + return (string) $minorUnits; + } + + $factor = bcpow('10', (string) $digits, 0); + + // bcdiv は scale 桁で丸めずに切り捨てるが、minor->major は割り切れるため scale=digits で正確。 + return bcdiv((string) $minorUnits, $factor, $digits); + } + + /** + * 通貨の小数桁数を ISO 4217 権威データから取得する. + * + * 未知の通貨コードの場合は安全側に倒して 2 桁を返す。 + */ + private function getFractionDigits(string $currency): int + { + $currency = strtoupper(trim($currency)); + + if ($currency !== '' && Currencies::exists($currency)) { + return Currencies::getFractionDigits($currency); + } + + return 2; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php new file mode 100644 index 00000000000..e97450e20ef --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php @@ -0,0 +1,58 @@ +> JWK の配列 + */ + public function getPublicJwks(): array; + + /** + * 現用鍵の Key ID (kid) を返す. + */ + public function getCurrentKid(): string; +} diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php new file mode 100644 index 00000000000..bfe7e27b4bf --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php @@ -0,0 +1,99 @@ +:" 形式で統一する (例: "ucp:checkout")。 + * "agent:catalog:read" のような旧形式は無効とする。protocol 越境 + * (例: acp トークンで ucp capability を要求) は許可しない。 + */ +class AgentCommerceScopeRegistry +{ + /** + * 正準 scope レジストリ. protocol => 許可する capability の配列. + * + * app/Customize で差し替えやすいようメソッド経由で参照する。 + * + * @var array> + */ + private const CANONICAL_SCOPES = [ + 'acp' => ['checkout', 'catalog'], + 'ucp' => ['checkout', 'cart', 'catalog', 'identity'], + ]; + + /** + * scope 文字列が正準レジストリに存在するか判定する. + * + * "ucp:checkout" などレジストリに定義された ":" のみ true。 + */ + public function isValidScope(string $scope): bool + { + $parts = explode(':', $scope); + + // 正確に protocol:capability の 2 要素でなければ無効 (例: "agent:catalog:read" は 3 要素)。 + if (count($parts) !== 2) { + return false; + } + + [$protocol, $capability] = $parts; + + if ($protocol === '' || $capability === '') { + return false; + } + + return isset(self::CANONICAL_SCOPES[$protocol]) + && in_array($capability, self::CANONICAL_SCOPES[$protocol], true); + } + + /** + * 指定 protocol が持つ全 scope 文字列を返す. + * + * 例: "ucp" => ["ucp:checkout", "ucp:cart", "ucp:catalog", "ucp:identity"] + * + * @return list + */ + public function scopesForProtocol(string $protocol): array + { + if (!isset(self::CANONICAL_SCOPES[$protocol])) { + return []; + } + + return array_map( + static fn (string $capability): string => $protocol.':'.$capability, + self::CANONICAL_SCOPES[$protocol] + ); + } + + /** + * 付与済み scope 群が、指定 protocol/capability の操作を許可しているか判定する. + * + * grantedScopes に ":" が含まれ、かつその scope が + * 正準であり protocol が一致するときのみ true。protocol 越境は false。 + * + * @param list $grantedScopes トークンに付与された scope 文字列の配列 + */ + public function supports(string $protocol, string $capability, array $grantedScopes): bool + { + $required = $protocol.':'.$capability; + + // 要求 scope 自体が正準でなければ許可しない (越境・未定義の capability を排除)。 + if (!$this->isValidScope($required)) { + return false; + } + + return in_array($required, $grantedScopes, true); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php new file mode 100644 index 00000000000..bb94f601745 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php @@ -0,0 +1,83 @@ + $envPathOverrides purpose => 絶対パスの上書きマップ + */ + public function __construct( + private readonly string $projectDir, + private readonly array $envPathOverrides = [], + ) { + } + + /** + * {@inheritdoc} + */ + public function read(string $purpose): ?string + { + $path = $this->resolvePath($purpose); + + if (!is_file($path)) { + return null; + } + + $contents = file_get_contents($path); + + return $contents === false ? null : $contents; + } + + /** + * {@inheritdoc} + */ + public function write(string $purpose, string $pem): void + { + $path = $this->resolvePath($purpose); + $dir = \dirname($path); + + if (!is_dir($dir)) { + mkdir($dir, 0700, true); + } + + file_put_contents($path, $pem); + chmod($path, 0600); + } + + /** + * purpose から鍵ファイルの絶対パスを解決する。 + * + * $envPathOverrides[$purpose] が非空文字ならそのパスを優先し、 + * それ以外は既定パスを使用する。 + */ + private function resolvePath(string $purpose): string + { + $override = $this->envPathOverrides[$purpose] ?? ''; + + if ($override !== '') { + return $override; + } + + return $this->projectDir.'/app/keystore/agent-commerce/'.$purpose.'.key'; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php b/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php new file mode 100644 index 00000000000..f6180661371 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php @@ -0,0 +1,40 @@ + grace period 中の旧公開鍵 PEM 群 + */ + private readonly array $gracePublicKeyPems; + + private ?PrivateKey $privateKey = null; + + /** + * @param KeyStoreInterface $keyStore 秘密鍵 PEM の読み書きストア + * @param string $purpose 鍵の用途識別子 (KeyStore のパス解決に使用) + * @param array $gracePublicKeyPems 旧公開鍵 PEM 群 (verify/JWK に含める) + */ + public function __construct( + KeyStoreInterface $keyStore, + string $purpose = 'ucp_signing', + array $gracePublicKeyPems = [], + ) { + $this->keyStore = $keyStore; + $this->purpose = $purpose; + $this->gracePublicKeyPems = array_values($gracePublicKeyPems); + } + + /** + * {@inheritdoc} + */ + public function sign(string $signatureBase): string + { + $raw = $this->getPrivateKey() + ->withSignatureFormat('IEEE') + ->withHash('sha256') + ->sign($signatureBase); + + return $this->base64urlEncode($raw); + } + + /** + * {@inheritdoc} + */ + public function verify(string $signatureBase, string $signature): bool + { + $raw = $this->base64urlDecode($signature); + if ($raw === '') { + return false; + } + + // 現用鍵 + grace period の旧公開鍵すべてに対して検証を試みる. + foreach ($this->collectPublicKeys() as $publicKey) { + try { + $verified = $publicKey + ->withSignatureFormat('IEEE') + ->withHash('sha256') + ->verify($signatureBase, $raw); + } catch (\Throwable) { + // 不正な署名長などで例外が出る実装差異を吸収し, 次の鍵へ. + continue; + } + + if ($verified) { + return true; + } + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function getPublicJwks(): array + { + $jwks = []; + foreach ($this->collectPublicKeys() as $publicKey) { + $jwks[] = $this->toPublicJwk($publicKey); + } + + return $jwks; + } + + /** + * {@inheritdoc} + */ + public function getCurrentKid(): string + { + return $this->thumbprint($this->getCurrentPublicKey()); + } + + /** + * 現用秘密鍵を取得する. 鍵ストアに無ければ EC P-256 を生成して永続化する. + */ + private function getPrivateKey(): PrivateKey + { + if ($this->privateKey instanceof PrivateKey) { + return $this->privateKey; + } + + $pem = $this->keyStore->read($this->purpose); + if ($pem === null || trim($pem) === '') { + /** @var PrivateKey $generated */ + $generated = EC::createKey('secp256r1'); + $pem = $generated->toString('PKCS8'); + $this->keyStore->write($this->purpose, $pem); + $this->privateKey = $generated; + + return $this->privateKey; + } + + $loaded = PublicKeyLoader::load($pem); + if (!$loaded instanceof PrivateKey) { + throw new \RuntimeException(sprintf('鍵ストアの "%s" は EC 秘密鍵ではありません.', $this->purpose)); + } + + $this->privateKey = $loaded; + + return $this->privateKey; + } + + private function getCurrentPublicKey(): PublicKey + { + return $this->getPrivateKey()->getPublicKey(); + } + + /** + * 現用 + grace の公開鍵を返す. + * + * @return array + */ + private function collectPublicKeys(): array + { + $keys = [$this->getCurrentPublicKey()]; + + foreach ($this->gracePublicKeyPems as $pem) { + if (trim($pem) === '') { + continue; + } + $loaded = PublicKeyLoader::load($pem); + if ($loaded instanceof PrivateKey) { + $keys[] = $loaded->getPublicKey(); + } elseif ($loaded instanceof PublicKey) { + $keys[] = $loaded; + } + } + + return $keys; + } + + /** + * 公開鍵を EC JWK (連想配列) に変換する. 秘密鍵パラメータ d は含めない. + * + * @return array + */ + private function toPublicJwk(PublicKey $publicKey): array + { + $coords = $this->extractCoordinates($publicKey); + + $jwk = [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => $coords['x'], + 'y' => $coords['y'], + 'use' => 'sig', + 'alg' => 'ES256', + ]; + $jwk['kid'] = $this->thumbprintFromCoordinates($coords['x'], $coords['y']); + + return $jwk; + } + + /** + * 公開鍵から JWK の座標 (x, y; base64url) を抽出する. + * + * 主: phpseclib の toString('JWK') を利用. + * フォールバック (コメント): phpseclib のバージョン差で 'JWK' 出力のキー名が + * 異なる / 取得できない場合は, getEncodedCoordinates() 等で得た + * uncompressed point (0x04 || X(32) || Y(32)) を 32byte ずつに分割し, + * それぞれ base64url する実装に切り替えること. ここではまず JWK 出力を解析し, + * 取得不能な場合に uncompressed point から座標を復元する. + * + * @return array{x: string, y: string} + */ + private function extractCoordinates(PublicKey $publicKey): array + { + $x = null; + $y = null; + + try { + $jwkJson = $publicKey->toString('JWK'); + /** @var array|null $decoded */ + $decoded = json_decode($jwkJson, true); + if (is_array($decoded)) { + // JWK は {keys:[{...}]} か単体 {x,y} のいずれもあり得るため両対応. + if (isset($decoded['keys'][0]) && is_array($decoded['keys'][0])) { + $decoded = $decoded['keys'][0]; + } + if (isset($decoded['x']) && is_string($decoded['x'])) { + $x = $decoded['x']; + } + if (isset($decoded['y']) && is_string($decoded['y'])) { + $y = $decoded['y']; + } + } + } catch (\Throwable) { + // 下のフォールバックで座標を復元する. + } + + if ($x === null || $y === null) { + // phpseclib3 の EC 公開鍵は toString('JWK') で必ず x/y を返すため通常到達しない。 + throw new \RuntimeException('EC 公開鍵から JWK 座標を取得できませんでした.'); + } + + return ['x' => $x, 'y' => $y]; + } + + /** + * RFC 7638 JWK Thumbprint を kid として算出する. + */ + private function thumbprint(PublicKey $publicKey): string + { + $coords = $this->extractCoordinates($publicKey); + + return $this->thumbprintFromCoordinates($coords['x'], $coords['y']); + } + + /** + * RFC 7638: 必須メンバ (crv, kty, x, y) を辞書順・余白なし JSON にして SHA-256 する. + */ + private function thumbprintFromCoordinates(string $x, string $y): string + { + // キー昇順 (crv, kty, x, y), 余白なし. + $canonical = json_encode([ + 'crv' => 'P-256', + 'kty' => 'EC', + 'x' => $x, + 'y' => $y, + ], JSON_UNESCAPED_SLASHES); + + return $this->base64urlEncode(hash('sha256', (string) $canonical, true)); + } + + private function base64urlEncode(string $binary): string + { + return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); + } + + private function base64urlDecode(string $value): string + { + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + + return $decoded === false ? '' : $decoded; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php new file mode 100644 index 00000000000..8d0a0a4ee0b --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php @@ -0,0 +1,181 @@ + alpha-2 mapping, that every + * id shipped in mtb_country.csv resolves to a non-null alpha-2, name/region + * splitting, and DTO array shaping. Entities are constructed in-memory (no DB). + */ +class AddressMappingServiceTest extends TestCase +{ + private AddressMappingService $service; + + protected function setUp(): void + { + parent::setUp(); + $this->service = new AddressMappingService(); + } + + public function testGetAlpha2FromCountryIdKnownIds(): void + { + self::assertSame('JP', $this->service->getAlpha2FromCountryId(392), 'ISO 3166-1 numeric 392 is Japan -> JP'); + self::assertSame('US', $this->service->getAlpha2FromCountryId(840), 'ISO 3166-1 numeric 840 is United States -> US'); + self::assertSame('GB', $this->service->getAlpha2FromCountryId(826), 'ISO 3166-1 numeric 826 is United Kingdom -> GB'); + self::assertSame('CN', $this->service->getAlpha2FromCountryId(156), 'ISO 3166-1 numeric 156 is China -> CN'); + self::assertSame('KR', $this->service->getAlpha2FromCountryId(410), 'ISO 3166-1 numeric 410 is Republic of Korea -> KR'); + self::assertSame('RU', $this->service->getAlpha2FromCountryId(643), 'ISO 3166-1 numeric 643 is Russia -> RU'); + } + + public function testGetAlpha2FromCountryIdNullAndUnknown(): void + { + self::assertNull($this->service->getAlpha2FromCountryId(null), 'Null country id must yield null alpha-2'); + self::assertNull($this->service->getAlpha2FromCountryId(999999), 'Unknown numeric id must yield null alpha-2 (no exception)'); + } + + /** + * Every numeric id present in the shipped mtb_country.csv must map to a + * non-null alpha-2, otherwise an address built from that country breaks. + */ + public function testAllCsvCountryIdsResolve(): void + { + $ids = $this->loadCountryIdsFromCsv(); + self::assertNotEmpty($ids, 'mtb_country.csv must contain country rows for this assertion to be meaningful'); + + foreach ($ids as $id) { + $alpha2 = $this->service->getAlpha2FromCountryId($id); + self::assertNotNull($alpha2, sprintf('mtb_country.csv id %d must map to a non-null ISO 3166-1 alpha-2', $id)); + self::assertMatchesRegularExpression('/^[A-Z]{2}$/', $alpha2, sprintf('mtb_country.csv id %d must map to a two-letter uppercase alpha-2 code', $id)); + } + } + + public function testGetRegionFromPref(): void + { + $pref = new Pref(); + $pref->setId(13); + $pref->setName('東京都'); + + self::assertSame('東京都', $this->service->getRegionFromPref($pref), 'Region must be the prefecture name'); + self::assertNull($this->service->getRegionFromPref(null), 'Null prefecture must yield null region'); + } + + public function testToAddressArrayFromCustomerSplitsNameAndMapsCountry(): void + { + $country = new Country(); + $country->setId(392); + + $pref = new Pref(); + $pref->setId(13); + $pref->setName('東京都'); + + $customer = new Customer(); + $customer->setName01('山田'); + $customer->setName02('太郎'); + $customer->setKana01('ヤマダ'); + $customer->setKana02('タロウ'); + $customer->setCompanyName('株式会社テスト'); + $customer->setPostalCode('1000001'); + $customer->setPref($pref); + $customer->setAddr01('千代田区千代田'); + $customer->setAddr02('1-1'); + $customer->setCountry($country); + $customer->setPhoneNumber('0312345678'); + + $address = $this->service->toAddressArray($customer); + + self::assertSame('山田', $address['family_name'], 'family_name must come from name01'); + self::assertSame('太郎', $address['given_name'], 'given_name must come from name02'); + self::assertSame('ヤマダ', $address['family_name_kana'], 'family_name_kana must come from kana01'); + self::assertSame('タロウ', $address['given_name_kana'], 'given_name_kana must come from kana02'); + self::assertSame('株式会社テスト', $address['company'], 'company must come from company_name'); + self::assertSame('1000001', $address['postal_code'], 'postal_code must be mapped'); + self::assertSame('東京都', $address['region'], 'region must be the prefecture name'); + self::assertSame('千代田区千代田', $address['address1'], 'address1 must come from addr01'); + self::assertSame('1-1', $address['address2'], 'address2 must come from addr02'); + self::assertSame('JP', $address['country'], 'country must be alpha-2 derived from Country.id (392 -> JP)'); + self::assertSame('0312345678', $address['phone'], 'phone must come from phone_number'); + } + + public function testToAddressArrayFromShipping(): void + { + $country = new Country(); + $country->setId(840); + + $pref = new Pref(); + $pref->setId(1); + $pref->setName('北海道'); + + $shipping = new Shipping(); + $shipping->setName01('佐藤'); + $shipping->setName02('花子'); + $shipping->setPostalCode('0600000'); + $shipping->setPref($pref); + $shipping->setAddr01('札幌市中央区'); + $shipping->setCountry($country); + $shipping->setPhoneNumber('0111234567'); + + $address = $this->service->toAddressArray($shipping); + + self::assertSame('佐藤', $address['family_name'], 'Shipping family_name must come from name01'); + self::assertSame('花子', $address['given_name'], 'Shipping given_name must come from name02'); + self::assertSame('北海道', $address['region'], 'Shipping region must be the prefecture name'); + self::assertSame('US', $address['country'], 'Shipping country must be alpha-2 derived from Country.id (840 -> US)'); + self::assertSame('0111234567', $address['phone'], 'Shipping phone must come from phone_number'); + } + + public function testToAddressArrayWithNullCountryAndPref(): void + { + $customer = new Customer(); + $customer->setName01('田中'); + $customer->setName02('一郎'); + + $address = $this->service->toAddressArray($customer); + + self::assertSame('田中', $address['family_name'], 'family_name must be mapped even when country/pref are null'); + self::assertNull($address['country'], 'Null Country must yield null country alpha-2'); + self::assertNull($address['region'], 'Null Pref must yield null region'); + } + + /** + * @return int[] + */ + private function loadCountryIdsFromCsv(): array + { + $csvPath = __DIR__.'/../../../../../src/Eccube/Resource/doctrine/import_csv/ja/mtb_country.csv'; + self::assertFileExists($csvPath, 'mtb_country.csv must exist at the expected resource path'); + + $ids = []; + $handle = fopen($csvPath, 'r'); + $header = fgetcsv($handle, null, ',', '"', ''); // skip header row + self::assertIsArray($header, 'mtb_country.csv must have a header row'); + while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { + if (!isset($row[0]) || $row[0] === '') { + continue; + } + $ids[] = (int) $row[0]; + } + fclose($handle); + + return $ids; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php new file mode 100644 index 00000000000..23e502b76c0 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php @@ -0,0 +1,87 @@ +get(BaseInfoRepository::class)->get(); + + foreach (self::FLAG_PROPERTIES as $property) { + $value = $this->readBooleanFlag($BaseInfo, $property); + self::assertFalse($value, sprintf('BaseInfo flag "%s" must default to false (off by default)', $property)); + } + } + + public function testPersistedBaseInfoGooglePayMerchantIdDefaultsToNull(): void + { + $BaseInfo = static::getContainer()->get(BaseInfoRepository::class)->get(); + + self::assertNull($this->readGooglePayMerchantId($BaseInfo), 'BaseInfo google_pay_merchant_id must default to null'); + } + + public function testNewBaseInfoFlagsDefaultToFalse(): void + { + $BaseInfo = new BaseInfo(); + + foreach (self::FLAG_PROPERTIES as $property) { + $value = $this->readBooleanFlag($BaseInfo, $property); + self::assertFalse((bool) $value, sprintf('A freshly constructed BaseInfo must report "%s" as false', $property)); + } + + self::assertNull($this->readGooglePayMerchantId($BaseInfo), 'A freshly constructed BaseInfo must report google_pay_merchant_id as null'); + } + + private function readBooleanFlag(BaseInfo $BaseInfo, string $property): bool + { + $studly = str_replace('_', '', ucwords($property, '_')); + foreach (['is'.$studly, 'get'.$studly] as $getter) { + if (method_exists($BaseInfo, $getter)) { + return (bool) $BaseInfo->{$getter}(); + } + } + + self::fail(sprintf('BaseInfo must expose a getter (is%1$s or get%1$s) for the "%2$s" flag', $studly, $property)); + } + + private function readGooglePayMerchantId(BaseInfo $BaseInfo): ?string + { + if (method_exists($BaseInfo, 'getGooglePayMerchantId')) { + return $BaseInfo->getGooglePayMerchantId(); + } + + self::fail('BaseInfo must expose getGooglePayMerchantId() for the google_pay_merchant_id column'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php new file mode 100644 index 00000000000..88436593cc7 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php @@ -0,0 +1,94 @@ +toMinorUnits('1000', 'JPY'); + self::assertIsInt($jpy, 'MUST: monetary amounts are integer minor units (JPY, zero-decimal)'); + self::assertSame(1000, $jpy, 'MUST: a JPY amount of 1000 is 1000 minor units'); + + $usd = $converter->toMinorUnits('10.99', 'USD'); + self::assertIsInt($usd, 'MUST: monetary amounts are integer minor units (USD, two-decimal)'); + self::assertSame(1099, $usd, 'MUST: a USD amount of 10.99 is 1099 minor units (cents)'); + } + + /** + * MUST: published signing keys advertise public key material only. The + * private key parameter "d" MUST NOT appear in any discovery JWK. + * + * @see UCP /.well-known/ucp signing_keys[] — EC public-key JWKs only + */ + public function testPublishedSigningKeysContainPublicMaterialOnly(): void + { + $store = new class implements KeyStoreInterface { + /** @var array */ + private array $store = []; + + public function read(string $purpose): ?string + { + return $this->store[$purpose] ?? null; + } + + public function write(string $purpose, string $pem): void + { + $this->store[$purpose] = $pem; + } + }; + + $signer = new UcpMessageSigner($store, 'ucp_signing'); + $jwks = $signer->getPublicJwks(); + + self::assertNotEmpty($jwks, 'MUST: at least one signing key is advertised for discovery'); + foreach ($jwks as $jwk) { + self::assertSame('EC', $jwk['kty'] ?? null, 'MUST: UCP signing keys are EC keys'); + self::assertArrayNotHasKey('d', $jwk, 'MUST NOT: discovery JWKs must not contain the private parameter d'); + } + } + + /** + * Cross-protocol two-tier error model (protocol errors as HTTP 4xx/5xx vs + * business errors as HTTP 200 + messages[]) is enforced at the controller + * layer, which is out of scope for the common base. + */ + public function testTwoTierErrorModelIsDeferredToControllerLayer(): void + { + self::markTestIncomplete('Two-tier error model (HTTP errors vs messages[]) is verified in the ACP/UCP checkout controller tracks, not in the common base.'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php new file mode 100644 index 00000000000..102b8fb7464 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php @@ -0,0 +1,110 @@ +toAmountString round trips. + */ +class MinorUnitConverterTest extends TestCase +{ + private MinorUnitConverter $converter; + + protected function setUp(): void + { + parent::setUp(); + $this->converter = new MinorUnitConverter(); + } + + public function testToMinorUnitsZeroDecimalJpy(): void + { + self::assertSame(1000, $this->converter->toMinorUnits('1000', 'JPY'), 'JPY has 0 fraction digits so the minor unit equals the major amount'); + self::assertSame(1000, $this->converter->toMinorUnits('1000.00', 'JPY'), 'JPY trailing zeros must not introduce extra minor units'); + } + + public function testToMinorUnitsTwoDecimalUsd(): void + { + self::assertSame(1000, $this->converter->toMinorUnits('10.00', 'USD'), 'USD 10.00 dollars equals 1000 cents'); + self::assertSame(1099, $this->converter->toMinorUnits('10.99', 'USD'), 'USD 10.99 dollars equals 1099 cents'); + self::assertSame(5, $this->converter->toMinorUnits('0.05', 'USD'), 'USD 0.05 dollars equals 5 cents'); + } + + public function testToMinorUnitsThreeDecimalBhd(): void + { + self::assertSame(1500, $this->converter->toMinorUnits('1.500', 'BHD'), 'BHD has 3 fraction digits so 1.5 dinar equals 1500 fils'); + self::assertSame(1234, $this->converter->toMinorUnits('1.234', 'BHD'), 'BHD 1.234 dinar equals 1234 fils'); + } + + public function testToMinorUnitsNegativeAmount(): void + { + self::assertSame(-500, $this->converter->toMinorUnits('-5.00', 'USD'), 'Negative amounts (UCP discounts) must convert to negative minor units'); + self::assertSame(-1000, $this->converter->toMinorUnits('-1000', 'JPY'), 'Negative zero-decimal amount must remain negative'); + } + + public function testToMinorUnitsRoundHalfUp(): void + { + self::assertSame(1010, $this->converter->toMinorUnits('10.095', 'USD'), 'Round-half-up: 10.095 USD rounds up to 1010 cents'); + self::assertSame(1009, $this->converter->toMinorUnits('10.094', 'USD'), 'Round down: 10.094 USD rounds to 1009 cents'); + self::assertSame(-1010, $this->converter->toMinorUnits('-10.095', 'USD'), 'Round-half-up on negative magnitude: -10.095 USD rounds to -1010 cents'); + } + + public function testToAmountStringZeroDecimalJpy(): void + { + self::assertSame('1000', $this->converter->toAmountString(1000, 'JPY'), 'JPY minor units map back to the same integer string with no decimal point'); + } + + public function testToAmountStringTwoDecimalUsd(): void + { + self::assertSame('10.00', $this->converter->toAmountString(1000, 'USD'), 'USD 1000 cents map back to 10.00 dollars with 2 decimals'); + self::assertSame('10.99', $this->converter->toAmountString(1099, 'USD'), 'USD 1099 cents map back to 10.99 dollars'); + self::assertSame('0.05', $this->converter->toAmountString(5, 'USD'), 'USD 5 cents map back to 0.05 dollars'); + } + + public function testToAmountStringThreeDecimalBhd(): void + { + self::assertSame('1.500', $this->converter->toAmountString(1500, 'BHD'), 'BHD 1500 fils map back to 1.500 dinar with 3 decimals'); + } + + public function testToAmountStringNegative(): void + { + self::assertSame('-5.00', $this->converter->toAmountString(-500, 'USD'), 'Negative minor units must map back to a negative decimal string'); + } + + #[DataProvider('roundTripProvider')] + public function testRoundTrip(string $amount, string $currency): void + { + $minor = $this->converter->toMinorUnits($amount, $currency); + $back = $this->converter->toAmountString($minor, $currency); + $reMinor = $this->converter->toMinorUnits($back, $currency); + self::assertSame($minor, $reMinor, 'toAmountString output must convert back to the identical minor-unit integer'); + } + + public static function roundTripProvider(): array + { + return [ + 'JPY positive' => ['1000', 'JPY'], + 'JPY negative' => ['-2500', 'JPY'], + 'USD positive' => ['10.99', 'USD'], + 'USD negative' => ['-3.50', 'USD'], + 'BHD positive' => ['1.234', 'BHD'], + ]; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php new file mode 100644 index 00000000000..8c188f79b90 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php @@ -0,0 +1,128 @@ +:" scope vocabulary + * (acp:checkout/acp:catalog, ucp:checkout/ucp:cart/ucp:catalog/ucp:identity), + * rejection of malformed scopes, and that protocol crossover is denied. + */ +class AgentCommerceScopeRegistryTest extends TestCase +{ + private AgentCommerceScopeRegistry $registry; + + protected function setUp(): void + { + parent::setUp(); + $this->registry = new AgentCommerceScopeRegistry(); + } + + #[DataProvider('validScopeProvider')] + public function testIsValidScopeAcceptsCanonicalScopes(string $scope): void + { + self::assertTrue($this->registry->isValidScope($scope), sprintf('"%s" is a canonical : scope and must be valid', $scope)); + } + + public static function validScopeProvider(): array + { + return [ + ['acp:checkout'], + ['acp:catalog'], + ['ucp:checkout'], + ['ucp:cart'], + ['ucp:catalog'], + ['ucp:identity'], + ]; + } + + #[DataProvider('invalidScopeProvider')] + public function testIsValidScopeRejectsMalformedOrUnknownScopes(string $scope): void + { + self::assertFalse($this->registry->isValidScope($scope), sprintf('"%s" is not a canonical scope and must be rejected', $scope)); + } + + public static function invalidScopeProvider(): array + { + return [ + 'three segments legacy form' => ['agent:catalog:read'], + 'unknown protocol' => ['foo:checkout'], + 'unknown capability for acp' => ['acp:identity'], + 'unknown capability for ucp' => ['ucp:feed'], + 'cart not valid for acp' => ['acp:cart'], + 'no colon' => ['checkout'], + 'empty string' => [''], + 'colon only' => [':'], + ]; + } + + public function testScopesForProtocol(): void + { + self::assertEqualsCanonicalizing( + ['acp:checkout', 'acp:catalog'], + $this->registry->scopesForProtocol('acp'), + 'scopesForProtocol("acp") must list every acp scope in : form' + ); + self::assertEqualsCanonicalizing( + ['ucp:checkout', 'ucp:cart', 'ucp:catalog', 'ucp:identity'], + $this->registry->scopesForProtocol('ucp'), + 'scopesForProtocol("ucp") must list every ucp scope in : form' + ); + } + + public function testScopesForUnknownProtocolIsEmpty(): void + { + self::assertSame([], $this->registry->scopesForProtocol('unknown'), 'An unknown protocol must yield no scopes'); + } + + public function testSupportsGrantsWhenScopePresentForMatchingProtocol(): void + { + $granted = ['ucp:checkout', 'ucp:cart']; + self::assertTrue( + $this->registry->supports('ucp', 'checkout', $granted), + 'supports must be true when grantedScopes contains the exact : and the protocol matches' + ); + } + + public function testSupportsDeniesWhenCapabilityNotGranted(): void + { + $granted = ['ucp:cart']; + self::assertFalse( + $this->registry->supports('ucp', 'checkout', $granted), + 'supports must be false when the required capability scope was not granted' + ); + } + + public function testSupportsDeniesProtocolCrossover(): void + { + $granted = ['acp:checkout']; + self::assertFalse( + $this->registry->supports('ucp', 'checkout', $granted), + 'Protocol crossover must be denied: an acp:checkout grant must not satisfy a ucp checkout request' + ); + } + + public function testSupportsDeniesEmptyGrants(): void + { + self::assertFalse( + $this->registry->supports('acp', 'catalog', []), + 'supports must be false when no scopes were granted' + ); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php new file mode 100644 index 00000000000..30a35b043cc --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php @@ -0,0 +1,157 @@ +createKeyStore(), self::PURPOSE); + $base = '"@method": POST'."\n".'"@path": /checkout-sessions'; + + $signature = $signer->sign($base); + + self::assertNotSame('', $signature, 'sign must return a non-empty base64url signature'); + self::assertTrue($signer->verify($base, $signature), 'A signature produced by sign must verify against the same signature base'); + } + + public function testVerifyFailsOnTamperedSignatureBase(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + $base = 'original-signature-base'; + + $signature = $signer->sign($base); + + self::assertFalse($signer->verify('tampered-signature-base', $signature), 'A signature must not verify against a tampered signature base'); + } + + public function testVerifyFailsOnTamperedSignature(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + $base = 'signature-base'; + + $signature = $signer->sign($base); + // Flip the first character to corrupt the signature while keeping it base64url-ish. + $corrupted = ($signature[0] === 'A' ? 'B' : 'A').substr($signature, 1); + + self::assertFalse($signer->verify($base, $corrupted), 'A corrupted signature must not verify'); + } + + public function testGetPublicJwksExposesEcPublicKeyOnly(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + + $jwks = $signer->getPublicJwks(); + + self::assertNotEmpty($jwks, 'getPublicJwks must return at least the current key'); + foreach ($jwks as $jwk) { + self::assertSame('EC', $jwk['kty'], 'UCP signing keys must be EC keys (kty=EC)'); + self::assertSame('P-256', $jwk['crv'], 'UCP signing keys must use the P-256 curve'); + self::assertArrayHasKey('x', $jwk, 'EC public JWK must expose the x coordinate'); + self::assertArrayHasKey('y', $jwk, 'EC public JWK must expose the y coordinate'); + self::assertArrayNotHasKey('d', $jwk, 'Published JWK must never contain the private key parameter d'); + } + } + + public function testGetCurrentKidIsStableAndPresentInJwks(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + + $kid = $signer->getCurrentKid(); + self::assertNotSame('', $kid, 'getCurrentKid must return a non-empty key id'); + + $kids = array_map(static fn (array $jwk) => $jwk['kid'] ?? null, $signer->getPublicJwks()); + self::assertContains($kid, $kids, 'The current kid must appear among the published JWKs'); + } + + /** + * Rotation: a signature created with the previous key must still verify on + * a signer whose current key is the new key and whose grace set contains + * the old public key. + */ + public function testKeyRotationVerifiesWithGracePublicKey(): void + { + // Old signer with its own (auto-generated) private key. + $oldStore = $this->createKeyStore(); + $oldSigner = new UcpMessageSigner($oldStore, self::PURPOSE); + $base = 'rotation-signature-base'; + $oldSignature = $oldSigner->sign($base); + + $oldPrivatePem = $oldStore->read(self::PURPOSE); + self::assertNotNull($oldPrivatePem, 'The old key store must hold the generated private key'); + $oldPublicPem = $this->derivePublicPem($oldPrivatePem); + + // New signer with a fresh key, carrying the old public key in its grace set. + $newStore = $this->createKeyStore(); + $newSigner = new UcpMessageSigner($newStore, self::PURPOSE, [$oldPublicPem]); + + self::assertTrue( + $newSigner->verify($base, $oldSignature), + 'A signature made with the rotated-out key must still verify while its public key is in the grace set' + ); + + // And the grace public key must be advertised in the JWKs without leaking d. + $jwks = $newSigner->getPublicJwks(); + self::assertGreaterThanOrEqual(2, count($jwks), 'During grace, both the current and the grace public key must be published'); + foreach ($jwks as $jwk) { + self::assertArrayNotHasKey('d', $jwk, 'No published JWK (current or grace) may contain the private parameter d'); + } + } + + private function derivePublicPem(string $privatePem): string + { + $key = PublicKeyLoader::load($privatePem); + + return $key->getPublicKey()->toString('PKCS8'); + } + + /** + * In-memory KeyStore stub: persists PEMs in an associative array keyed by + * purpose. No filesystem or DB access. + */ + private function createKeyStore(): KeyStoreInterface + { + return new class implements KeyStoreInterface { + /** @var array */ + private array $store = []; + + public function read(string $purpose): ?string + { + return $this->store[$purpose] ?? null; + } + + public function write(string $purpose, string $pem): void + { + $this->store[$purpose] = $pem; + } + }; + } +} From 292b8b03dd10365c1d4e71ee36d4ba31b9fe68a9 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 4 Jun 2026 15:15:56 +0900 Subject: [PATCH 02/28] =?UTF-8?q?docs(agent-commerce):=20MinorUnitConverte?= =?UTF-8?q?r=20=E3=81=AE=E3=82=B3=E3=83=A1=E3=83=B3=E3=83=88=E3=82=92?= =?UTF-8?q?=E9=80=9A=E8=B2=A8=E6=A1=81=E6=95=B0=E3=81=8C=E5=88=86=E3=81=8B?= =?UTF-8?q?=E3=82=8B=E4=BE=8B=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit minor-unit は通貨の小数桁数だけ桁が増える (一律 ×100 ではない) ことが 一目で分かるよう、docblock の例を JPY (×1) / USD (×100) の 2 桁までに統一。 3 桁通貨 (BHD 等) の 4 桁例は紛らわしいため削除。 Co-Authored-By: Claude Opus 4.8 --- .../AgentCommerce/MinorUnitConverter.php | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php index e85f82fd15d..a513f80dc40 100644 --- a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php +++ b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php @@ -19,8 +19,10 @@ * 通貨の major-unit (人間が扱う表記) と minor-unit (整数表現) を相互変換するサービス. * * ACP / UCP の金額はいずれも minor-unit (最小通貨単位) の整数で表現される。 - * 小数桁数は ISO 4217 の権威データ (symfony/intl) から取得するため、 - * JPY/KRW/TWD のようなゼロデシマル通貨や BHD のような 3 桁通貨も正しく扱える。 + * minor-unit は major-unit を「通貨の小数桁数 (ISO 4217) だけ 10 倍した整数」で、 + * 同じ "1000" でも通貨ごとに桁数が変わる (JPY は ×1、USD は ×100)。一律 ×100 ではない点に注意。 + * 桁数は symfony/intl の権威データから取得するため、ゼロデシマル通貨 (JPY/KRW/TWD) や + * 3 桁通貨 (BHD 等) も自動で正しく扱える (EC-CUBE 本体の金額カラムは 2 桁まで)。 * 割引・返金などの負数にも対応する。 */ class MinorUnitConverter @@ -28,10 +30,15 @@ class MinorUnitConverter /** * major-unit の金額文字列を minor-unit の整数へ変換する. * - * 例: ("12.34", "USD") => 1234, ("1000", "JPY") => 1000, ("-1.5", "BHD") => -1500 + * minor-unit は「通貨の小数桁数 (ISO 4217) だけ 0 が増えた整数」。 + * 同じ金額でも通貨によって桁数が変わる (一律 ×100 ではない): + * ("1000", "JPY") => 1000 // 0 桁通貨: ×10^0 → 桁は増えない + * ("1000.00", "USD") => 100000 // 2 桁通貨: ×10^2 → 0 が 2 つ増える + * ("12.34", "USD") => 1234 + * ("-15.00", "USD") => -1500 // 割引・返金などの負数も可 * * @param string $amount major-unit の金額 (decimal 文字列). 負数可 - * @param string $currency ISO 4217 通貨コード (例: USD, JPY, BHD) + * @param string $currency ISO 4217 通貨コード (例: JPY, USD) */ public function toMinorUnits(string $amount, string $currency): int { @@ -68,9 +75,12 @@ public function toMinorUnits(string $amount, string $currency): int } /** - * minor-unit の整数を major-unit の decimal 文字列へ変換する. + * minor-unit の整数を major-unit の decimal 文字列へ変換する (toMinorUnits の逆変換). * - * 例: (1234, "USD") => "12.34", (1000, "JPY") => "1000", (-1500, "BHD") => "-1.500" + * (1000, "JPY") => "1000" // 0 桁通貨: そのまま + * (100000, "USD") => "1000.00" // 2 桁通貨: 下 2 桁が小数部 + * (1234, "USD") => "12.34" + * (-1500, "USD") => "-15.00" * * @param int $minorUnits minor-unit の整数. 負数可 * @param string $currency ISO 4217 通貨コード From 914884cd3f0921f38813057b3e1c08a226027713 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 4 Jun 2026 15:39:14 +0900 Subject: [PATCH 03/28] =?UTF-8?q?fix(agent-commerce):=20=E5=86=97=E9=95=B7?= =?UTF-8?q?=E3=81=AA=20ALTER=20TABLE=20=E3=83=9E=E3=82=A4=E3=82=B0?= =?UTF-8?q?=E3=83=AC=E3=83=BC=E3=82=B7=E3=83=A7=E3=83=B3=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACP/UCP 用の 6 カラムは BaseInfo エンティティに #[ORM\Column] で定義済みのため、 公式アップデート手順 (doctrine:schema:update --force) で自動反映される。 カラム追加に ALTER TABLE マイグレーションを書かないのが EC-CUBE の慣例 (前例 PR #4912: カラム追加に ALTER マイグレーション無し、INSERT のみ)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Version20260602120000.php | 80 ------------------- 1 file changed, 80 deletions(-) delete mode 100644 app/DoctrineMigrations/Version20260602120000.php diff --git a/app/DoctrineMigrations/Version20260602120000.php b/app/DoctrineMigrations/Version20260602120000.php deleted file mode 100644 index bd10e0b8ebf..00000000000 --- a/app/DoctrineMigrations/Version20260602120000.php +++ /dev/null @@ -1,80 +0,0 @@ -hasTable(self::NAME)) { - return; - } - - $table = $schema->getTable(self::NAME); - - if (!$table->hasColumn('acp_enabled')) { - $this->addSql('ALTER TABLE dtb_base_info ADD acp_enabled BOOLEAN NOT NULL DEFAULT false'); - } - if (!$table->hasColumn('ucp_enabled')) { - $this->addSql('ALTER TABLE dtb_base_info ADD ucp_enabled BOOLEAN NOT NULL DEFAULT false'); - } - if (!$table->hasColumn('acp_feed_enabled')) { - $this->addSql('ALTER TABLE dtb_base_info ADD acp_feed_enabled BOOLEAN NOT NULL DEFAULT false'); - } - if (!$table->hasColumn('ucp_catalog_api_enabled')) { - $this->addSql('ALTER TABLE dtb_base_info ADD ucp_catalog_api_enabled BOOLEAN NOT NULL DEFAULT false'); - } - if (!$table->hasColumn('ucp_catalog_requires_auth')) { - $this->addSql('ALTER TABLE dtb_base_info ADD ucp_catalog_requires_auth BOOLEAN NOT NULL DEFAULT false'); - } - if (!$table->hasColumn('google_pay_merchant_id')) { - $this->addSql('ALTER TABLE dtb_base_info ADD google_pay_merchant_id VARCHAR(255) DEFAULT NULL'); - } - } - - public function down(Schema $schema): void - { - if (!$schema->hasTable(self::NAME)) { - return; - } - - $table = $schema->getTable(self::NAME); - - if ($table->hasColumn('acp_enabled')) { - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN acp_enabled'); - } - if ($table->hasColumn('ucp_enabled')) { - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN ucp_enabled'); - } - if ($table->hasColumn('acp_feed_enabled')) { - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN acp_feed_enabled'); - } - if ($table->hasColumn('ucp_catalog_api_enabled')) { - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN ucp_catalog_api_enabled'); - } - if ($table->hasColumn('ucp_catalog_requires_auth')) { - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN ucp_catalog_requires_auth'); - } - if ($table->hasColumn('google_pay_merchant_id')) { - $this->addSql('ALTER TABLE dtb_base_info DROP COLUMN google_pay_merchant_id'); - } - } -} From 118fb46e325468708e4936c6d0342d7da8b2b3d0 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 4 Jun 2026 15:46:09 +0900 Subject: [PATCH 04/28] =?UTF-8?q?style(agent-commerce):=20Rector=20dry-run?= =?UTF-8?q?=20=E6=8C=87=E6=91=98=E3=82=92=E9=81=A9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI の rector ジョブ (PR #6802) で検出された 8 ファイルの指摘を vendor/bin/rector で適用。 - AddressMappingService: 冗長な (int) キャストと三項を null 合体演算子に簡約 - UcpMessageSigner: コンストラクタプロパティ昇格 - テスト各種: self:: → $this->、final class 化、strict_types 宣言等 AgentCommerce テスト 53 件すべて成功を確認。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentCommerce/AddressMappingService.php | 2 +- .../Security/UcpMessageSigner.php | 10 +-- .../AddressMappingServiceTest.php | 73 ++++++++-------- .../BaseInfoAgentCommerceFlagsTest.php | 13 +-- .../AgentCommerceBaseConformanceTest.php | 19 +++-- .../AgentCommerce/MinorUnitConverterTest.php | 59 +++++++------ .../AgentCommerceScopeRegistryTest.php | 83 +++++++------------ .../Security/UcpMessageSignerTest.php | 40 +++++---- 8 files changed, 135 insertions(+), 164 deletions(-) diff --git a/src/Eccube/Service/AgentCommerce/AddressMappingService.php b/src/Eccube/Service/AgentCommerce/AddressMappingService.php index 128844974d9..261d93d0de3 100644 --- a/src/Eccube/Service/AgentCommerce/AddressMappingService.php +++ b/src/Eccube/Service/AgentCommerce/AddressMappingService.php @@ -346,7 +346,7 @@ public function toAddressArray(Customer|CustomerAddress|Shipping $source): array 'region' => $this->getRegionFromPref($this->extractPref($source)), 'address1' => $this->callIfExists($source, 'getAddr01'), 'address2' => $this->callIfExists($source, 'getAddr02'), - 'country' => $this->getAlpha2FromCountryId($countryId !== null ? (int) $countryId : null), + 'country' => $this->getAlpha2FromCountryId($countryId ?? null), 'phone' => $this->extractPhoneNumber($source), ]; } diff --git a/src/Eccube/Service/AgentCommerce/Security/UcpMessageSigner.php b/src/Eccube/Service/AgentCommerce/Security/UcpMessageSigner.php index cac6c8f52ae..7410483c7fc 100644 --- a/src/Eccube/Service/AgentCommerce/Security/UcpMessageSigner.php +++ b/src/Eccube/Service/AgentCommerce/Security/UcpMessageSigner.php @@ -27,10 +27,6 @@ */ class UcpMessageSigner implements AgentCommerceMessageSignerInterface { - private readonly KeyStoreInterface $keyStore; - - private readonly string $purpose; - /** * @var array grace period 中の旧公開鍵 PEM 群 */ @@ -44,12 +40,10 @@ class UcpMessageSigner implements AgentCommerceMessageSignerInterface * @param array $gracePublicKeyPems 旧公開鍵 PEM 群 (verify/JWK に含める) */ public function __construct( - KeyStoreInterface $keyStore, - string $purpose = 'ucp_signing', + private readonly KeyStoreInterface $keyStore, + private readonly string $purpose = 'ucp_signing', array $gracePublicKeyPems = [], ) { - $this->keyStore = $keyStore; - $this->purpose = $purpose; $this->gracePublicKeyPems = array_values($gracePublicKeyPems); } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php index 8d0a0a4ee0b..6d834863687 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php @@ -1,5 +1,7 @@ service->getAlpha2FromCountryId(392), 'ISO 3166-1 numeric 392 is Japan -> JP'); - self::assertSame('US', $this->service->getAlpha2FromCountryId(840), 'ISO 3166-1 numeric 840 is United States -> US'); - self::assertSame('GB', $this->service->getAlpha2FromCountryId(826), 'ISO 3166-1 numeric 826 is United Kingdom -> GB'); - self::assertSame('CN', $this->service->getAlpha2FromCountryId(156), 'ISO 3166-1 numeric 156 is China -> CN'); - self::assertSame('KR', $this->service->getAlpha2FromCountryId(410), 'ISO 3166-1 numeric 410 is Republic of Korea -> KR'); - self::assertSame('RU', $this->service->getAlpha2FromCountryId(643), 'ISO 3166-1 numeric 643 is Russia -> RU'); + $this->assertSame('JP', $this->service->getAlpha2FromCountryId(392), 'ISO 3166-1 numeric 392 is Japan -> JP'); + $this->assertSame('US', $this->service->getAlpha2FromCountryId(840), 'ISO 3166-1 numeric 840 is United States -> US'); + $this->assertSame('GB', $this->service->getAlpha2FromCountryId(826), 'ISO 3166-1 numeric 826 is United Kingdom -> GB'); + $this->assertSame('CN', $this->service->getAlpha2FromCountryId(156), 'ISO 3166-1 numeric 156 is China -> CN'); + $this->assertSame('KR', $this->service->getAlpha2FromCountryId(410), 'ISO 3166-1 numeric 410 is Republic of Korea -> KR'); + $this->assertSame('RU', $this->service->getAlpha2FromCountryId(643), 'ISO 3166-1 numeric 643 is Russia -> RU'); } public function testGetAlpha2FromCountryIdNullAndUnknown(): void { - self::assertNull($this->service->getAlpha2FromCountryId(null), 'Null country id must yield null alpha-2'); - self::assertNull($this->service->getAlpha2FromCountryId(999999), 'Unknown numeric id must yield null alpha-2 (no exception)'); + $this->assertNull($this->service->getAlpha2FromCountryId(null), 'Null country id must yield null alpha-2'); + $this->assertNull($this->service->getAlpha2FromCountryId(999999), 'Unknown numeric id must yield null alpha-2 (no exception)'); } /** @@ -60,12 +61,12 @@ public function testGetAlpha2FromCountryIdNullAndUnknown(): void public function testAllCsvCountryIdsResolve(): void { $ids = $this->loadCountryIdsFromCsv(); - self::assertNotEmpty($ids, 'mtb_country.csv must contain country rows for this assertion to be meaningful'); + $this->assertNotEmpty($ids, 'mtb_country.csv must contain country rows for this assertion to be meaningful'); foreach ($ids as $id) { $alpha2 = $this->service->getAlpha2FromCountryId($id); - self::assertNotNull($alpha2, sprintf('mtb_country.csv id %d must map to a non-null ISO 3166-1 alpha-2', $id)); - self::assertMatchesRegularExpression('/^[A-Z]{2}$/', $alpha2, sprintf('mtb_country.csv id %d must map to a two-letter uppercase alpha-2 code', $id)); + $this->assertNotNull($alpha2, sprintf('mtb_country.csv id %d must map to a non-null ISO 3166-1 alpha-2', $id)); + $this->assertMatchesRegularExpression('/^[A-Z]{2}$/', $alpha2, sprintf('mtb_country.csv id %d must map to a two-letter uppercase alpha-2 code', $id)); } } @@ -75,8 +76,8 @@ public function testGetRegionFromPref(): void $pref->setId(13); $pref->setName('東京都'); - self::assertSame('東京都', $this->service->getRegionFromPref($pref), 'Region must be the prefecture name'); - self::assertNull($this->service->getRegionFromPref(null), 'Null prefecture must yield null region'); + $this->assertSame('東京都', $this->service->getRegionFromPref($pref), 'Region must be the prefecture name'); + $this->assertNull($this->service->getRegionFromPref(null), 'Null prefecture must yield null region'); } public function testToAddressArrayFromCustomerSplitsNameAndMapsCountry(): void @@ -103,17 +104,17 @@ public function testToAddressArrayFromCustomerSplitsNameAndMapsCountry(): void $address = $this->service->toAddressArray($customer); - self::assertSame('山田', $address['family_name'], 'family_name must come from name01'); - self::assertSame('太郎', $address['given_name'], 'given_name must come from name02'); - self::assertSame('ヤマダ', $address['family_name_kana'], 'family_name_kana must come from kana01'); - self::assertSame('タロウ', $address['given_name_kana'], 'given_name_kana must come from kana02'); - self::assertSame('株式会社テスト', $address['company'], 'company must come from company_name'); - self::assertSame('1000001', $address['postal_code'], 'postal_code must be mapped'); - self::assertSame('東京都', $address['region'], 'region must be the prefecture name'); - self::assertSame('千代田区千代田', $address['address1'], 'address1 must come from addr01'); - self::assertSame('1-1', $address['address2'], 'address2 must come from addr02'); - self::assertSame('JP', $address['country'], 'country must be alpha-2 derived from Country.id (392 -> JP)'); - self::assertSame('0312345678', $address['phone'], 'phone must come from phone_number'); + $this->assertSame('山田', $address['family_name'], 'family_name must come from name01'); + $this->assertSame('太郎', $address['given_name'], 'given_name must come from name02'); + $this->assertSame('ヤマダ', $address['family_name_kana'], 'family_name_kana must come from kana01'); + $this->assertSame('タロウ', $address['given_name_kana'], 'given_name_kana must come from kana02'); + $this->assertSame('株式会社テスト', $address['company'], 'company must come from company_name'); + $this->assertSame('1000001', $address['postal_code'], 'postal_code must be mapped'); + $this->assertSame('東京都', $address['region'], 'region must be the prefecture name'); + $this->assertSame('千代田区千代田', $address['address1'], 'address1 must come from addr01'); + $this->assertSame('1-1', $address['address2'], 'address2 must come from addr02'); + $this->assertSame('JP', $address['country'], 'country must be alpha-2 derived from Country.id (392 -> JP)'); + $this->assertSame('0312345678', $address['phone'], 'phone must come from phone_number'); } public function testToAddressArrayFromShipping(): void @@ -136,11 +137,11 @@ public function testToAddressArrayFromShipping(): void $address = $this->service->toAddressArray($shipping); - self::assertSame('佐藤', $address['family_name'], 'Shipping family_name must come from name01'); - self::assertSame('花子', $address['given_name'], 'Shipping given_name must come from name02'); - self::assertSame('北海道', $address['region'], 'Shipping region must be the prefecture name'); - self::assertSame('US', $address['country'], 'Shipping country must be alpha-2 derived from Country.id (840 -> US)'); - self::assertSame('0111234567', $address['phone'], 'Shipping phone must come from phone_number'); + $this->assertSame('佐藤', $address['family_name'], 'Shipping family_name must come from name01'); + $this->assertSame('花子', $address['given_name'], 'Shipping given_name must come from name02'); + $this->assertSame('北海道', $address['region'], 'Shipping region must be the prefecture name'); + $this->assertSame('US', $address['country'], 'Shipping country must be alpha-2 derived from Country.id (840 -> US)'); + $this->assertSame('0111234567', $address['phone'], 'Shipping phone must come from phone_number'); } public function testToAddressArrayWithNullCountryAndPref(): void @@ -151,9 +152,9 @@ public function testToAddressArrayWithNullCountryAndPref(): void $address = $this->service->toAddressArray($customer); - self::assertSame('田中', $address['family_name'], 'family_name must be mapped even when country/pref are null'); - self::assertNull($address['country'], 'Null Country must yield null country alpha-2'); - self::assertNull($address['region'], 'Null Pref must yield null region'); + $this->assertSame('田中', $address['family_name'], 'family_name must be mapped even when country/pref are null'); + $this->assertNull($address['country'], 'Null Country must yield null country alpha-2'); + $this->assertNull($address['region'], 'Null Pref must yield null region'); } /** @@ -162,12 +163,12 @@ public function testToAddressArrayWithNullCountryAndPref(): void private function loadCountryIdsFromCsv(): array { $csvPath = __DIR__.'/../../../../../src/Eccube/Resource/doctrine/import_csv/ja/mtb_country.csv'; - self::assertFileExists($csvPath, 'mtb_country.csv must exist at the expected resource path'); + $this->assertFileExists($csvPath, 'mtb_country.csv must exist at the expected resource path'); $ids = []; $handle = fopen($csvPath, 'r'); $header = fgetcsv($handle, null, ',', '"', ''); // skip header row - self::assertIsArray($header, 'mtb_country.csv must have a header row'); + $this->assertIsArray($header, 'mtb_country.csv must have a header row'); while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { if (!isset($row[0]) || $row[0] === '') { continue; diff --git a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php index 23e502b76c0..c1baa07cf89 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php @@ -1,5 +1,7 @@ readBooleanFlag($BaseInfo, $property); - self::assertFalse($value, sprintf('BaseInfo flag "%s" must default to false (off by default)', $property)); + $this->assertFalse($value, sprintf('BaseInfo flag "%s" must default to false (off by default)', $property)); } } @@ -49,7 +50,7 @@ public function testPersistedBaseInfoGooglePayMerchantIdDefaultsToNull(): void { $BaseInfo = static::getContainer()->get(BaseInfoRepository::class)->get(); - self::assertNull($this->readGooglePayMerchantId($BaseInfo), 'BaseInfo google_pay_merchant_id must default to null'); + $this->assertNull($this->readGooglePayMerchantId($BaseInfo), 'BaseInfo google_pay_merchant_id must default to null'); } public function testNewBaseInfoFlagsDefaultToFalse(): void @@ -58,10 +59,10 @@ public function testNewBaseInfoFlagsDefaultToFalse(): void foreach (self::FLAG_PROPERTIES as $property) { $value = $this->readBooleanFlag($BaseInfo, $property); - self::assertFalse((bool) $value, sprintf('A freshly constructed BaseInfo must report "%s" as false', $property)); + $this->assertFalse($value, sprintf('A freshly constructed BaseInfo must report "%s" as false', $property)); } - self::assertNull($this->readGooglePayMerchantId($BaseInfo), 'A freshly constructed BaseInfo must report google_pay_merchant_id as null'); + $this->assertNull($this->readGooglePayMerchantId($BaseInfo), 'A freshly constructed BaseInfo must report google_pay_merchant_id as null'); } private function readBooleanFlag(BaseInfo $BaseInfo, string $property): bool diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php index 88436593cc7..8f976943381 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php @@ -1,5 +1,7 @@ toMinorUnits('1000', 'JPY'); - self::assertIsInt($jpy, 'MUST: monetary amounts are integer minor units (JPY, zero-decimal)'); - self::assertSame(1000, $jpy, 'MUST: a JPY amount of 1000 is 1000 minor units'); + $this->assertIsInt($jpy, 'MUST: monetary amounts are integer minor units (JPY, zero-decimal)'); + $this->assertSame(1000, $jpy, 'MUST: a JPY amount of 1000 is 1000 minor units'); $usd = $converter->toMinorUnits('10.99', 'USD'); - self::assertIsInt($usd, 'MUST: monetary amounts are integer minor units (USD, two-decimal)'); - self::assertSame(1099, $usd, 'MUST: a USD amount of 10.99 is 1099 minor units (cents)'); + $this->assertIsInt($usd, 'MUST: monetary amounts are integer minor units (USD, two-decimal)'); + $this->assertSame(1099, $usd, 'MUST: a USD amount of 10.99 is 1099 minor units (cents)'); } /** @@ -75,10 +76,10 @@ public function write(string $purpose, string $pem): void $signer = new UcpMessageSigner($store, 'ucp_signing'); $jwks = $signer->getPublicJwks(); - self::assertNotEmpty($jwks, 'MUST: at least one signing key is advertised for discovery'); + $this->assertNotEmpty($jwks, 'MUST: at least one signing key is advertised for discovery'); foreach ($jwks as $jwk) { - self::assertSame('EC', $jwk['kty'] ?? null, 'MUST: UCP signing keys are EC keys'); - self::assertArrayNotHasKey('d', $jwk, 'MUST NOT: discovery JWKs must not contain the private parameter d'); + $this->assertSame('EC', $jwk['kty'] ?? null, 'MUST: UCP signing keys are EC keys'); + $this->assertArrayNotHasKey('d', $jwk, 'MUST NOT: discovery JWKs must not contain the private parameter d'); } } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php index 102b8fb7464..5b9e1075db8 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php @@ -1,5 +1,7 @@ toAmountString round trips. */ -class MinorUnitConverterTest extends TestCase +final class MinorUnitConverterTest extends TestCase { private MinorUnitConverter $converter; @@ -36,75 +37,73 @@ protected function setUp(): void public function testToMinorUnitsZeroDecimalJpy(): void { - self::assertSame(1000, $this->converter->toMinorUnits('1000', 'JPY'), 'JPY has 0 fraction digits so the minor unit equals the major amount'); - self::assertSame(1000, $this->converter->toMinorUnits('1000.00', 'JPY'), 'JPY trailing zeros must not introduce extra minor units'); + $this->assertSame(1000, $this->converter->toMinorUnits('1000', 'JPY'), 'JPY has 0 fraction digits so the minor unit equals the major amount'); + $this->assertSame(1000, $this->converter->toMinorUnits('1000.00', 'JPY'), 'JPY trailing zeros must not introduce extra minor units'); } public function testToMinorUnitsTwoDecimalUsd(): void { - self::assertSame(1000, $this->converter->toMinorUnits('10.00', 'USD'), 'USD 10.00 dollars equals 1000 cents'); - self::assertSame(1099, $this->converter->toMinorUnits('10.99', 'USD'), 'USD 10.99 dollars equals 1099 cents'); - self::assertSame(5, $this->converter->toMinorUnits('0.05', 'USD'), 'USD 0.05 dollars equals 5 cents'); + $this->assertSame(1000, $this->converter->toMinorUnits('10.00', 'USD'), 'USD 10.00 dollars equals 1000 cents'); + $this->assertSame(1099, $this->converter->toMinorUnits('10.99', 'USD'), 'USD 10.99 dollars equals 1099 cents'); + $this->assertSame(5, $this->converter->toMinorUnits('0.05', 'USD'), 'USD 0.05 dollars equals 5 cents'); } public function testToMinorUnitsThreeDecimalBhd(): void { - self::assertSame(1500, $this->converter->toMinorUnits('1.500', 'BHD'), 'BHD has 3 fraction digits so 1.5 dinar equals 1500 fils'); - self::assertSame(1234, $this->converter->toMinorUnits('1.234', 'BHD'), 'BHD 1.234 dinar equals 1234 fils'); + $this->assertSame(1500, $this->converter->toMinorUnits('1.500', 'BHD'), 'BHD has 3 fraction digits so 1.5 dinar equals 1500 fils'); + $this->assertSame(1234, $this->converter->toMinorUnits('1.234', 'BHD'), 'BHD 1.234 dinar equals 1234 fils'); } public function testToMinorUnitsNegativeAmount(): void { - self::assertSame(-500, $this->converter->toMinorUnits('-5.00', 'USD'), 'Negative amounts (UCP discounts) must convert to negative minor units'); - self::assertSame(-1000, $this->converter->toMinorUnits('-1000', 'JPY'), 'Negative zero-decimal amount must remain negative'); + $this->assertSame(-500, $this->converter->toMinorUnits('-5.00', 'USD'), 'Negative amounts (UCP discounts) must convert to negative minor units'); + $this->assertSame(-1000, $this->converter->toMinorUnits('-1000', 'JPY'), 'Negative zero-decimal amount must remain negative'); } public function testToMinorUnitsRoundHalfUp(): void { - self::assertSame(1010, $this->converter->toMinorUnits('10.095', 'USD'), 'Round-half-up: 10.095 USD rounds up to 1010 cents'); - self::assertSame(1009, $this->converter->toMinorUnits('10.094', 'USD'), 'Round down: 10.094 USD rounds to 1009 cents'); - self::assertSame(-1010, $this->converter->toMinorUnits('-10.095', 'USD'), 'Round-half-up on negative magnitude: -10.095 USD rounds to -1010 cents'); + $this->assertSame(1010, $this->converter->toMinorUnits('10.095', 'USD'), 'Round-half-up: 10.095 USD rounds up to 1010 cents'); + $this->assertSame(1009, $this->converter->toMinorUnits('10.094', 'USD'), 'Round down: 10.094 USD rounds to 1009 cents'); + $this->assertSame(-1010, $this->converter->toMinorUnits('-10.095', 'USD'), 'Round-half-up on negative magnitude: -10.095 USD rounds to -1010 cents'); } public function testToAmountStringZeroDecimalJpy(): void { - self::assertSame('1000', $this->converter->toAmountString(1000, 'JPY'), 'JPY minor units map back to the same integer string with no decimal point'); + $this->assertSame('1000', $this->converter->toAmountString(1000, 'JPY'), 'JPY minor units map back to the same integer string with no decimal point'); } public function testToAmountStringTwoDecimalUsd(): void { - self::assertSame('10.00', $this->converter->toAmountString(1000, 'USD'), 'USD 1000 cents map back to 10.00 dollars with 2 decimals'); - self::assertSame('10.99', $this->converter->toAmountString(1099, 'USD'), 'USD 1099 cents map back to 10.99 dollars'); - self::assertSame('0.05', $this->converter->toAmountString(5, 'USD'), 'USD 5 cents map back to 0.05 dollars'); + $this->assertSame('10.00', $this->converter->toAmountString(1000, 'USD'), 'USD 1000 cents map back to 10.00 dollars with 2 decimals'); + $this->assertSame('10.99', $this->converter->toAmountString(1099, 'USD'), 'USD 1099 cents map back to 10.99 dollars'); + $this->assertSame('0.05', $this->converter->toAmountString(5, 'USD'), 'USD 5 cents map back to 0.05 dollars'); } public function testToAmountStringThreeDecimalBhd(): void { - self::assertSame('1.500', $this->converter->toAmountString(1500, 'BHD'), 'BHD 1500 fils map back to 1.500 dinar with 3 decimals'); + $this->assertSame('1.500', $this->converter->toAmountString(1500, 'BHD'), 'BHD 1500 fils map back to 1.500 dinar with 3 decimals'); } public function testToAmountStringNegative(): void { - self::assertSame('-5.00', $this->converter->toAmountString(-500, 'USD'), 'Negative minor units must map back to a negative decimal string'); + $this->assertSame('-5.00', $this->converter->toAmountString(-500, 'USD'), 'Negative minor units must map back to a negative decimal string'); } - #[DataProvider('roundTripProvider')] + #[DataProvider(methodName: 'roundTripProvider')] public function testRoundTrip(string $amount, string $currency): void { $minor = $this->converter->toMinorUnits($amount, $currency); $back = $this->converter->toAmountString($minor, $currency); $reMinor = $this->converter->toMinorUnits($back, $currency); - self::assertSame($minor, $reMinor, 'toAmountString output must convert back to the identical minor-unit integer'); + $this->assertSame($minor, $reMinor, 'toAmountString output must convert back to the identical minor-unit integer'); } - public static function roundTripProvider(): array + public static function roundTripProvider(): \Iterator { - return [ - 'JPY positive' => ['1000', 'JPY'], - 'JPY negative' => ['-2500', 'JPY'], - 'USD positive' => ['10.99', 'USD'], - 'USD negative' => ['-3.50', 'USD'], - 'BHD positive' => ['1.234', 'BHD'], - ]; + yield 'JPY positive' => ['1000', 'JPY']; + yield 'JPY negative' => ['-2500', 'JPY']; + yield 'USD positive' => ['10.99', 'USD']; + yield 'USD negative' => ['-3.50', 'USD']; + yield 'BHD positive' => ['1.234', 'BHD']; } } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php index 8c188f79b90..b5a6dcd5fb9 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php @@ -1,5 +1,7 @@ registry = new AgentCommerceScopeRegistry(); } - #[DataProvider('validScopeProvider')] + #[DataProvider(methodName: 'validScopeProvider')] public function testIsValidScopeAcceptsCanonicalScopes(string $scope): void { - self::assertTrue($this->registry->isValidScope($scope), sprintf('"%s" is a canonical : scope and must be valid', $scope)); + $this->assertTrue($this->registry->isValidScope($scope), sprintf('"%s" is a canonical : scope and must be valid', $scope)); } - public static function validScopeProvider(): array + public static function validScopeProvider(): \Iterator { - return [ - ['acp:checkout'], - ['acp:catalog'], - ['ucp:checkout'], - ['ucp:cart'], - ['ucp:catalog'], - ['ucp:identity'], - ]; + yield ['acp:checkout']; + yield ['acp:catalog']; + yield ['ucp:checkout']; + yield ['ucp:cart']; + yield ['ucp:catalog']; + yield ['ucp:identity']; } - #[DataProvider('invalidScopeProvider')] + #[DataProvider(methodName: 'invalidScopeProvider')] public function testIsValidScopeRejectsMalformedOrUnknownScopes(string $scope): void { - self::assertFalse($this->registry->isValidScope($scope), sprintf('"%s" is not a canonical scope and must be rejected', $scope)); + $this->assertFalse($this->registry->isValidScope($scope), sprintf('"%s" is not a canonical scope and must be rejected', $scope)); } - public static function invalidScopeProvider(): array + public static function invalidScopeProvider(): \Iterator { - return [ - 'three segments legacy form' => ['agent:catalog:read'], - 'unknown protocol' => ['foo:checkout'], - 'unknown capability for acp' => ['acp:identity'], - 'unknown capability for ucp' => ['ucp:feed'], - 'cart not valid for acp' => ['acp:cart'], - 'no colon' => ['checkout'], - 'empty string' => [''], - 'colon only' => [':'], - ]; + yield 'three segments legacy form' => ['agent:catalog:read']; + yield 'unknown protocol' => ['foo:checkout']; + yield 'unknown capability for acp' => ['acp:identity']; + yield 'unknown capability for ucp' => ['ucp:feed']; + yield 'cart not valid for acp' => ['acp:cart']; + yield 'no colon' => ['checkout']; + yield 'empty string' => ['']; + yield 'colon only' => [':']; } public function testScopesForProtocol(): void { - self::assertEqualsCanonicalizing( - ['acp:checkout', 'acp:catalog'], - $this->registry->scopesForProtocol('acp'), - 'scopesForProtocol("acp") must list every acp scope in : form' - ); - self::assertEqualsCanonicalizing( - ['ucp:checkout', 'ucp:cart', 'ucp:catalog', 'ucp:identity'], - $this->registry->scopesForProtocol('ucp'), - 'scopesForProtocol("ucp") must list every ucp scope in : form' - ); + $this->assertEqualsCanonicalizing(['acp:checkout', 'acp:catalog'], $this->registry->scopesForProtocol('acp'), 'scopesForProtocol("acp") must list every acp scope in : form'); + $this->assertEqualsCanonicalizing(['ucp:checkout', 'ucp:cart', 'ucp:catalog', 'ucp:identity'], $this->registry->scopesForProtocol('ucp'), 'scopesForProtocol("ucp") must list every ucp scope in : form'); } public function testScopesForUnknownProtocolIsEmpty(): void { - self::assertSame([], $this->registry->scopesForProtocol('unknown'), 'An unknown protocol must yield no scopes'); + $this->assertSame([], $this->registry->scopesForProtocol('unknown'), 'An unknown protocol must yield no scopes'); } public function testSupportsGrantsWhenScopePresentForMatchingProtocol(): void { $granted = ['ucp:checkout', 'ucp:cart']; - self::assertTrue( - $this->registry->supports('ucp', 'checkout', $granted), - 'supports must be true when grantedScopes contains the exact : and the protocol matches' - ); + $this->assertTrue($this->registry->supports('ucp', 'checkout', $granted), 'supports must be true when grantedScopes contains the exact : and the protocol matches'); } public function testSupportsDeniesWhenCapabilityNotGranted(): void { $granted = ['ucp:cart']; - self::assertFalse( - $this->registry->supports('ucp', 'checkout', $granted), - 'supports must be false when the required capability scope was not granted' - ); + $this->assertFalse($this->registry->supports('ucp', 'checkout', $granted), 'supports must be false when the required capability scope was not granted'); } public function testSupportsDeniesProtocolCrossover(): void { $granted = ['acp:checkout']; - self::assertFalse( - $this->registry->supports('ucp', 'checkout', $granted), - 'Protocol crossover must be denied: an acp:checkout grant must not satisfy a ucp checkout request' - ); + $this->assertFalse($this->registry->supports('ucp', 'checkout', $granted), 'Protocol crossover must be denied: an acp:checkout grant must not satisfy a ucp checkout request'); } public function testSupportsDeniesEmptyGrants(): void { - self::assertFalse( - $this->registry->supports('acp', 'catalog', []), - 'supports must be false when no scopes were granted' - ); + $this->assertFalse($this->registry->supports('acp', 'catalog', []), 'supports must be false when no scopes were granted'); } } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php index 30a35b043cc..0322f3b2521 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php @@ -1,5 +1,7 @@ sign($base); - self::assertNotSame('', $signature, 'sign must return a non-empty base64url signature'); - self::assertTrue($signer->verify($base, $signature), 'A signature produced by sign must verify against the same signature base'); + $this->assertNotSame('', $signature, 'sign must return a non-empty base64url signature'); + $this->assertTrue($signer->verify($base, $signature), 'A signature produced by sign must verify against the same signature base'); } public function testVerifyFailsOnTamperedSignatureBase(): void @@ -50,7 +51,7 @@ public function testVerifyFailsOnTamperedSignatureBase(): void $signature = $signer->sign($base); - self::assertFalse($signer->verify('tampered-signature-base', $signature), 'A signature must not verify against a tampered signature base'); + $this->assertFalse($signer->verify('tampered-signature-base', $signature), 'A signature must not verify against a tampered signature base'); } public function testVerifyFailsOnTamperedSignature(): void @@ -62,7 +63,7 @@ public function testVerifyFailsOnTamperedSignature(): void // Flip the first character to corrupt the signature while keeping it base64url-ish. $corrupted = ($signature[0] === 'A' ? 'B' : 'A').substr($signature, 1); - self::assertFalse($signer->verify($base, $corrupted), 'A corrupted signature must not verify'); + $this->assertFalse($signer->verify($base, $corrupted), 'A corrupted signature must not verify'); } public function testGetPublicJwksExposesEcPublicKeyOnly(): void @@ -71,13 +72,13 @@ public function testGetPublicJwksExposesEcPublicKeyOnly(): void $jwks = $signer->getPublicJwks(); - self::assertNotEmpty($jwks, 'getPublicJwks must return at least the current key'); + $this->assertNotEmpty($jwks, 'getPublicJwks must return at least the current key'); foreach ($jwks as $jwk) { - self::assertSame('EC', $jwk['kty'], 'UCP signing keys must be EC keys (kty=EC)'); - self::assertSame('P-256', $jwk['crv'], 'UCP signing keys must use the P-256 curve'); - self::assertArrayHasKey('x', $jwk, 'EC public JWK must expose the x coordinate'); - self::assertArrayHasKey('y', $jwk, 'EC public JWK must expose the y coordinate'); - self::assertArrayNotHasKey('d', $jwk, 'Published JWK must never contain the private key parameter d'); + $this->assertSame('EC', $jwk['kty'], 'UCP signing keys must be EC keys (kty=EC)'); + $this->assertSame('P-256', $jwk['crv'], 'UCP signing keys must use the P-256 curve'); + $this->assertArrayHasKey('x', $jwk, 'EC public JWK must expose the x coordinate'); + $this->assertArrayHasKey('y', $jwk, 'EC public JWK must expose the y coordinate'); + $this->assertArrayNotHasKey('d', $jwk, 'Published JWK must never contain the private key parameter d'); } } @@ -86,10 +87,10 @@ public function testGetCurrentKidIsStableAndPresentInJwks(): void $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); $kid = $signer->getCurrentKid(); - self::assertNotSame('', $kid, 'getCurrentKid must return a non-empty key id'); + $this->assertNotSame('', $kid, 'getCurrentKid must return a non-empty key id'); $kids = array_map(static fn (array $jwk) => $jwk['kid'] ?? null, $signer->getPublicJwks()); - self::assertContains($kid, $kids, 'The current kid must appear among the published JWKs'); + $this->assertContains($kid, $kids, 'The current kid must appear among the published JWKs'); } /** @@ -106,23 +107,20 @@ public function testKeyRotationVerifiesWithGracePublicKey(): void $oldSignature = $oldSigner->sign($base); $oldPrivatePem = $oldStore->read(self::PURPOSE); - self::assertNotNull($oldPrivatePem, 'The old key store must hold the generated private key'); + $this->assertNotNull($oldPrivatePem, 'The old key store must hold the generated private key'); $oldPublicPem = $this->derivePublicPem($oldPrivatePem); // New signer with a fresh key, carrying the old public key in its grace set. $newStore = $this->createKeyStore(); $newSigner = new UcpMessageSigner($newStore, self::PURPOSE, [$oldPublicPem]); - self::assertTrue( - $newSigner->verify($base, $oldSignature), - 'A signature made with the rotated-out key must still verify while its public key is in the grace set' - ); + $this->assertTrue($newSigner->verify($base, $oldSignature), 'A signature made with the rotated-out key must still verify while its public key is in the grace set'); // And the grace public key must be advertised in the JWKs without leaking d. $jwks = $newSigner->getPublicJwks(); - self::assertGreaterThanOrEqual(2, count($jwks), 'During grace, both the current and the grace public key must be published'); + $this->assertGreaterThanOrEqual(2, count($jwks), 'During grace, both the current and the grace public key must be published'); foreach ($jwks as $jwk) { - self::assertArrayNotHasKey('d', $jwk, 'No published JWK (current or grace) may contain the private parameter d'); + $this->assertArrayNotHasKey('d', $jwk, 'No published JWK (current or grace) may contain the private parameter d'); } } From 74c74afa39ac1056ae8cab343f73c588cff52011 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 4 Jun 2026 16:06:34 +0900 Subject: [PATCH 05/28] =?UTF-8?q?refactor(agent-commerce):=20google=5Fpay?= =?UTF-8?q?=5Fmerchant=5Fid=20=E3=82=92=20BaseInfo=20=E3=81=8B=E3=82=89?= =?UTF-8?q?=E9=99=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 決済はプラグイン化方針 (Stripe 同様) のため、Google Pay の merchant_id は core BaseInfo に持たせない。UCP discovery の payment_handlers は決済ハンドラ プラグインが寄与する設計とする。あわせて rector 適用後の php-cs-fixer 整形 (ライセンスヘッダ直後の空行) をテストへ反映。 Co-Authored-By: Claude Opus 4.8 --- src/Eccube/Entity/BaseInfo.php | 21 --------------- .../doctrine/import_csv/en/dtb_base_info.csv | 2 +- .../doctrine/import_csv/ja/dtb_base_info.csv | 2 +- .../AddressMappingServiceTest.php | 1 + .../BaseInfoAgentCommerceFlagsTest.php | 27 +++++-------------- .../AgentCommerceBaseConformanceTest.php | 1 + .../AgentCommerce/MinorUnitConverterTest.php | 1 + .../AgentCommerceScopeRegistryTest.php | 1 + .../Security/UcpMessageSignerTest.php | 1 + 9 files changed, 13 insertions(+), 44 deletions(-) diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 68d76acffe8..e31a26c4c5a 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -169,9 +169,6 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'ucp_catalog_requires_auth', type: Types::BOOLEAN, options: ['default' => false])] private bool $ucp_catalog_requires_auth = false; - #[ORM\Column(name: 'google_pay_merchant_id', type: Types::STRING, length: 255, nullable: true)] - private ?string $google_pay_merchant_id = null; - /** * Get id. * @@ -939,23 +936,5 @@ public function isUcpCatalogRequiresAuth(): bool { return $this->ucp_catalog_requires_auth; } - - /** - * Set googlePayMerchantId. - */ - public function setGooglePayMerchantId(?string $googlePayMerchantId = null): BaseInfo - { - $this->google_pay_merchant_id = $googlePayMerchantId; - - return $this; - } - - /** - * Get googlePayMerchantId. - */ - public function getGooglePayMerchantId(): ?string - { - return $this->google_pay_merchant_id; - } } } diff --git a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv index 98c784c61a2..588eed4edf7 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_enabled,ucp_enabled,acp_feed_enabled,ucp_catalog_api_enabled,ucp_catalog_requires_auth,google_pay_merchant_id +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_enabled,ucp_enabled,acp_feed_enabled,ucp_catalog_api_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv index 98c784c61a2..588eed4edf7 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_enabled,ucp_enabled,acp_feed_enabled,ucp_catalog_api_enabled,ucp_catalog_requires_auth,google_pay_merchant_id +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_enabled,ucp_enabled,acp_feed_enabled,ucp_catalog_api_enabled,ucp_catalog_requires_auth diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php index 6d834863687..add67b72910 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php @@ -12,6 +12,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace Eccube\Tests\Service\AgentCommerce; use Eccube\Entity\Customer; diff --git a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php index c1baa07cf89..cc0757bf93f 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php @@ -12,6 +12,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace Eccube\Tests\Service\AgentCommerce; use Eccube\Entity\BaseInfo; @@ -21,9 +22,11 @@ /** * Layer 2 (Doctrine) tests for the agent-commerce BaseInfo flags. * - * Verifies that the five enable flags default to false and that - * google_pay_merchant_id defaults to null for the persisted BaseInfo (id=1), - * matching the "default false / off by default" contract from the base plan. + * Verifies that the five enable flags (acp_enabled / ucp_enabled / + * acp_feed_enabled / ucp_catalog_api_enabled / ucp_catalog_requires_auth) + * default to false for both the persisted BaseInfo (id=1) and a freshly + * constructed instance, matching the "default false / off by default" + * contract from the base plan. */ final class BaseInfoAgentCommerceFlagsTest extends EccubeTestCase { @@ -46,13 +49,6 @@ public function testPersistedBaseInfoFlagsDefaultToFalse(): void } } - public function testPersistedBaseInfoGooglePayMerchantIdDefaultsToNull(): void - { - $BaseInfo = static::getContainer()->get(BaseInfoRepository::class)->get(); - - $this->assertNull($this->readGooglePayMerchantId($BaseInfo), 'BaseInfo google_pay_merchant_id must default to null'); - } - public function testNewBaseInfoFlagsDefaultToFalse(): void { $BaseInfo = new BaseInfo(); @@ -61,8 +57,6 @@ public function testNewBaseInfoFlagsDefaultToFalse(): void $value = $this->readBooleanFlag($BaseInfo, $property); $this->assertFalse($value, sprintf('A freshly constructed BaseInfo must report "%s" as false', $property)); } - - $this->assertNull($this->readGooglePayMerchantId($BaseInfo), 'A freshly constructed BaseInfo must report google_pay_merchant_id as null'); } private function readBooleanFlag(BaseInfo $BaseInfo, string $property): bool @@ -76,13 +70,4 @@ private function readBooleanFlag(BaseInfo $BaseInfo, string $property): bool self::fail(sprintf('BaseInfo must expose a getter (is%1$s or get%1$s) for the "%2$s" flag', $studly, $property)); } - - private function readGooglePayMerchantId(BaseInfo $BaseInfo): ?string - { - if (method_exists($BaseInfo, 'getGooglePayMerchantId')) { - return $BaseInfo->getGooglePayMerchantId(); - } - - self::fail('BaseInfo must expose getGooglePayMerchantId() for the google_pay_merchant_id column'); - } } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php index 8f976943381..e3fe24f461f 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php @@ -12,6 +12,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace Eccube\Tests\Service\AgentCommerce\Conformance; use Eccube\Service\AgentCommerce\MinorUnitConverter; diff --git a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php index 5b9e1075db8..0ab55645e20 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php @@ -12,6 +12,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace Eccube\Tests\Service\AgentCommerce; use Eccube\Service\AgentCommerce\MinorUnitConverter; diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php index b5a6dcd5fb9..d57738de9be 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php @@ -12,6 +12,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace Eccube\Tests\Service\AgentCommerce\Security; use Eccube\Service\AgentCommerce\Security\AgentCommerceScopeRegistry; diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php index 0322f3b2521..69248b02a65 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php @@ -12,6 +12,7 @@ * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ + namespace Eccube\Tests\Service\AgentCommerce\Security; use Eccube\Service\AgentCommerce\Security\KeyStoreInterface; From 28e8deb3d84e31c5e91176c0001dcf04a026f060 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 4 Jun 2026 17:15:27 +0900 Subject: [PATCH 06/28] =?UTF-8?q?refactor(agent-commerce):=20=E5=9B=BD?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E5=A4=89=E6=8F=9B=E3=82=92=E3=83=9E?= =?UTF-8?q?=E3=82=B9=E3=82=BF=20mtb=5Fcountry=5Fiso=5Fcode=20=E3=81=B8?= =?UTF-8?q?=E7=A7=BB=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AddressMappingService の numeric->alpha-2 ハードコード const (249件) を廃止し、 新規マスタ mtb_country_iso_code で管理する。PR #6802 レビュー指摘 (マスタテーブル化) に対応。 - 新規マスタ CountryIsoCode / CountryIsoCodeRepository を追加 - mtb_* の固定スキーマ (id/name/sort_no/discriminator_type) に準拠し、id=ISO numeric / name=alpha-2 を格納 (discriminator=countryisocode) - 新規インストールは import_csv (definition.yml 登録)、既存環境は INSERT データ migration で backfill (mtb_country は改変しない) - AddressMappingService はリポジトリ経由解決へ変更、テストを Layer 2 化 Co-Authored-By: Claude Opus 4.8 --- .../Version20260604120000.php | 56 ++++ src/Eccube/Entity/Master/CountryIsoCode.php | 36 +++ .../Master/CountryIsoCodeRepository.php | 31 ++ .../doctrine/import_csv/en/definition.yml | 1 + .../import_csv/en/mtb_country_iso_code.csv | 250 ++++++++++++++++ .../doctrine/import_csv/ja/definition.yml | 1 + .../import_csv/ja/mtb_country_iso_code.csv | 250 ++++++++++++++++ .../AgentCommerce/AddressMappingService.php | 277 +----------------- .../AddressMappingServiceTest.php | 41 +-- 9 files changed, 660 insertions(+), 283 deletions(-) create mode 100644 app/DoctrineMigrations/Version20260604120000.php create mode 100644 src/Eccube/Entity/Master/CountryIsoCode.php create mode 100644 src/Eccube/Repository/Master/CountryIsoCodeRepository.php create mode 100644 src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv create mode 100644 src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv diff --git a/app/DoctrineMigrations/Version20260604120000.php b/app/DoctrineMigrations/Version20260604120000.php new file mode 100644 index 00000000000..58a0f48dabe --- /dev/null +++ b/app/DoctrineMigrations/Version20260604120000.php @@ -0,0 +1,56 @@ + alpha-2) の初期データを投入する. + * + * テーブル自体は doctrine:schema:update でエンティティから生成される。 + * 新規インストールは import_csv (definition.yml) で投入されるため、 + * 本マイグレーションは既存インストールのアップグレード時の backfill を担う + * (空テーブルのときのみ INSERT する冪等実装)。 + */ +final class Version20260604120000 extends AbstractMigration +{ + public const NAME = 'mtb_country_iso_code'; + + public function up(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + // 既にデータがある場合 (新規インストールの fixtures 投入済み等) は二重投入しない. + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM '.self::NAME); + if ($count > 0) { + return; + } + + $this->addSql("INSERT INTO mtb_country_iso_code (id, name, sort_no, discriminator_type) VALUES (352, 'IS', 1, 'countryisocode'), (372, 'IE', 2, 'countryisocode'), (31, 'AZ', 3, 'countryisocode'), (4, 'AF', 4, 'countryisocode'), (840, 'US', 5, 'countryisocode'), (850, 'VI', 6, 'countryisocode'), (16, 'AS', 7, 'countryisocode'), (784, 'AE', 8, 'countryisocode'), (12, 'DZ', 9, 'countryisocode'), (32, 'AR', 10, 'countryisocode'), (533, 'AW', 11, 'countryisocode'), (8, 'AL', 12, 'countryisocode'), (51, 'AM', 13, 'countryisocode'), (660, 'AI', 14, 'countryisocode'), (24, 'AO', 15, 'countryisocode'), (28, 'AG', 16, 'countryisocode'), (20, 'AD', 17, 'countryisocode'), (887, 'YE', 18, 'countryisocode'), (826, 'GB', 19, 'countryisocode'), (86, 'IO', 20, 'countryisocode'), (92, 'VG', 21, 'countryisocode'), (376, 'IL', 22, 'countryisocode'), (380, 'IT', 23, 'countryisocode'), (368, 'IQ', 24, 'countryisocode'), (364, 'IR', 25, 'countryisocode'), (356, 'IN', 26, 'countryisocode'), (360, 'ID', 27, 'countryisocode'), (876, 'WF', 28, 'countryisocode'), (800, 'UG', 29, 'countryisocode'), (804, 'UA', 30, 'countryisocode'), (860, 'UZ', 31, 'countryisocode'), (858, 'UY', 32, 'countryisocode'), (218, 'EC', 33, 'countryisocode'), (818, 'EG', 34, 'countryisocode'), (233, 'EE', 35, 'countryisocode'), (231, 'ET', 36, 'countryisocode'), (232, 'ER', 37, 'countryisocode'), (222, 'SV', 38, 'countryisocode'), (36, 'AU', 39, 'countryisocode'), (40, 'AT', 40, 'countryisocode'), (248, 'AX', 41, 'countryisocode'), (512, 'OM', 42, 'countryisocode'), (528, 'NL', 43, 'countryisocode'), (288, 'GH', 44, 'countryisocode'), (132, 'CV', 45, 'countryisocode'), (831, 'GG', 46, 'countryisocode'), (328, 'GY', 47, 'countryisocode'), (398, 'KZ', 48, 'countryisocode'), (634, 'QA', 49, 'countryisocode'), (581, 'UM', 50, 'countryisocode'), (124, 'CA', 51, 'countryisocode'), (266, 'GA', 52, 'countryisocode'), (120, 'CM', 53, 'countryisocode'), (270, 'GM', 54, 'countryisocode'), (116, 'KH', 55, 'countryisocode'), (580, 'MP', 56, 'countryisocode'), (324, 'GN', 57, 'countryisocode'), (624, 'GW', 58, 'countryisocode'), (196, 'CY', 59, 'countryisocode'), (192, 'CU', 60, 'countryisocode'), (531, 'CW', 61, 'countryisocode'), (300, 'GR', 62, 'countryisocode'), (296, 'KI', 63, 'countryisocode'), (417, 'KG', 64, 'countryisocode'), (320, 'GT', 65, 'countryisocode'), (312, 'GP', 66, 'countryisocode'), (316, 'GU', 67, 'countryisocode'), (414, 'KW', 68, 'countryisocode'), (184, 'CK', 69, 'countryisocode'), (304, 'GL', 70, 'countryisocode'), (162, 'CX', 71, 'countryisocode'), (268, 'GE', 72, 'countryisocode'), (308, 'GD', 73, 'countryisocode'), (191, 'HR', 74, 'countryisocode'), (136, 'KY', 75, 'countryisocode'), (404, 'KE', 76, 'countryisocode'), (384, 'CI', 77, 'countryisocode'), (166, 'CC', 78, 'countryisocode'), (188, 'CR', 79, 'countryisocode'), (174, 'KM', 80, 'countryisocode'), (170, 'CO', 81, 'countryisocode'), (178, 'CG', 82, 'countryisocode'), (180, 'CD', 83, 'countryisocode'), (682, 'SA', 84, 'countryisocode'), (239, 'GS', 85, 'countryisocode'), (882, 'WS', 86, 'countryisocode'), (678, 'ST', 87, 'countryisocode'), (652, 'BL', 88, 'countryisocode'), (894, 'ZM', 89, 'countryisocode'), (666, 'PM', 90, 'countryisocode'), (674, 'SM', 91, 'countryisocode'), (663, 'MF', 92, 'countryisocode'), (694, 'SL', 93, 'countryisocode'), (262, 'DJ', 94, 'countryisocode'), (292, 'GI', 95, 'countryisocode'), (832, 'JE', 96, 'countryisocode'), (388, 'JM', 97, 'countryisocode'), (760, 'SY', 98, 'countryisocode'), (702, 'SG', 99, 'countryisocode'), (534, 'SX', 100, 'countryisocode'), (716, 'ZW', 101, 'countryisocode'), (756, 'CH', 102, 'countryisocode'), (752, 'SE', 103, 'countryisocode'), (729, 'SD', 104, 'countryisocode'), (744, 'SJ', 105, 'countryisocode'), (724, 'ES', 106, 'countryisocode'), (740, 'SR', 107, 'countryisocode'), (144, 'LK', 108, 'countryisocode'), (703, 'SK', 109, 'countryisocode'), (705, 'SI', 110, 'countryisocode'), (748, 'SZ', 111, 'countryisocode'), (690, 'SC', 112, 'countryisocode'), (226, 'GQ', 113, 'countryisocode'), (686, 'SN', 114, 'countryisocode'), (688, 'RS', 115, 'countryisocode'), (659, 'KN', 116, 'countryisocode'), (670, 'VC', 117, 'countryisocode'), (426, 'LS', 118, 'countryisocode'), (654, 'SH', 119, 'countryisocode'), (662, 'LC', 120, 'countryisocode'), (706, 'SO', 121, 'countryisocode'), (90, 'SB', 122, 'countryisocode'), (796, 'TC', 123, 'countryisocode'), (764, 'TH', 124, 'countryisocode'), (410, 'KR', 125, 'countryisocode'), (158, 'TW', 126, 'countryisocode'), (762, 'TJ', 127, 'countryisocode'), (834, 'TZ', 128, 'countryisocode'), (203, 'CZ', 129, 'countryisocode'), (148, 'TD', 130, 'countryisocode'), (140, 'CF', 131, 'countryisocode'), (156, 'CN', 132, 'countryisocode'), (788, 'TN', 133, 'countryisocode'), (408, 'KP', 134, 'countryisocode'), (152, 'CL', 135, 'countryisocode'), (798, 'TV', 136, 'countryisocode'), (208, 'DK', 137, 'countryisocode'), (276, 'DE', 138, 'countryisocode'), (768, 'TG', 139, 'countryisocode'), (772, 'TK', 140, 'countryisocode'), (214, 'DO', 141, 'countryisocode'), (212, 'DM', 142, 'countryisocode'), (780, 'TT', 143, 'countryisocode'), (795, 'TM', 144, 'countryisocode'), (792, 'TR', 145, 'countryisocode'), (776, 'TO', 146, 'countryisocode'), (566, 'NG', 147, 'countryisocode'), (520, 'NR', 148, 'countryisocode'), (516, 'NA', 149, 'countryisocode'), (10, 'AQ', 150, 'countryisocode'), (570, 'NU', 151, 'countryisocode'), (558, 'NI', 152, 'countryisocode'), (562, 'NE', 153, 'countryisocode'), (392, 'JP', 154, 'countryisocode'), (732, 'EH', 155, 'countryisocode'), (540, 'NC', 156, 'countryisocode'), (554, 'NZ', 157, 'countryisocode'), (524, 'NP', 158, 'countryisocode'), (574, 'NF', 159, 'countryisocode'), (578, 'NO', 160, 'countryisocode'), (334, 'HM', 161, 'countryisocode'), (48, 'BH', 162, 'countryisocode'), (332, 'HT', 163, 'countryisocode'), (586, 'PK', 164, 'countryisocode'), (336, 'VA', 165, 'countryisocode'), (591, 'PA', 166, 'countryisocode'), (548, 'VU', 167, 'countryisocode'), (44, 'BS', 168, 'countryisocode'), (598, 'PG', 169, 'countryisocode'), (60, 'BM', 170, 'countryisocode'), (585, 'PW', 171, 'countryisocode'), (600, 'PY', 172, 'countryisocode'), (52, 'BB', 173, 'countryisocode'), (275, 'PS', 174, 'countryisocode'), (348, 'HU', 175, 'countryisocode'), (50, 'BD', 176, 'countryisocode'), (626, 'TL', 177, 'countryisocode'), (612, 'PN', 178, 'countryisocode'), (242, 'FJ', 179, 'countryisocode'), (608, 'PH', 180, 'countryisocode'), (246, 'FI', 181, 'countryisocode'), (64, 'BT', 182, 'countryisocode'), (74, 'BV', 183, 'countryisocode'), (630, 'PR', 184, 'countryisocode'), (234, 'FO', 185, 'countryisocode'), (238, 'FK', 186, 'countryisocode'), (76, 'BR', 187, 'countryisocode'), (250, 'FR', 188, 'countryisocode'), (254, 'GF', 189, 'countryisocode'), (258, 'PF', 190, 'countryisocode'), (260, 'TF', 191, 'countryisocode'), (100, 'BG', 192, 'countryisocode'), (854, 'BF', 193, 'countryisocode'), (96, 'BN', 194, 'countryisocode'), (108, 'BI', 195, 'countryisocode'), (704, 'VN', 196, 'countryisocode'), (204, 'BJ', 197, 'countryisocode'), (862, 'VE', 198, 'countryisocode'), (112, 'BY', 199, 'countryisocode'), (84, 'BZ', 200, 'countryisocode'), (604, 'PE', 201, 'countryisocode'), (56, 'BE', 202, 'countryisocode'), (616, 'PL', 203, 'countryisocode'), (70, 'BA', 204, 'countryisocode'), (72, 'BW', 205, 'countryisocode'), (535, 'BQ', 206, 'countryisocode'), (68, 'BO', 207, 'countryisocode'), (620, 'PT', 208, 'countryisocode'), (344, 'HK', 209, 'countryisocode'), (340, 'HN', 210, 'countryisocode'), (584, 'MH', 211, 'countryisocode'), (446, 'MO', 212, 'countryisocode'), (807, 'MK', 213, 'countryisocode'), (450, 'MG', 214, 'countryisocode'), (175, 'YT', 215, 'countryisocode'), (454, 'MW', 216, 'countryisocode'), (466, 'ML', 217, 'countryisocode'), (470, 'MT', 218, 'countryisocode'), (474, 'MQ', 219, 'countryisocode'), (458, 'MY', 220, 'countryisocode'), (833, 'IM', 221, 'countryisocode'), (583, 'FM', 222, 'countryisocode'), (710, 'ZA', 223, 'countryisocode'), (728, 'SS', 224, 'countryisocode'), (104, 'MM', 225, 'countryisocode'), (484, 'MX', 226, 'countryisocode'), (480, 'MU', 227, 'countryisocode'), (478, 'MR', 228, 'countryisocode'), (508, 'MZ', 229, 'countryisocode'), (492, 'MC', 230, 'countryisocode'), (462, 'MV', 231, 'countryisocode'), (498, 'MD', 232, 'countryisocode'), (504, 'MA', 233, 'countryisocode'), (496, 'MN', 234, 'countryisocode'), (499, 'ME', 235, 'countryisocode'), (500, 'MS', 236, 'countryisocode'), (400, 'JO', 237, 'countryisocode'), (418, 'LA', 238, 'countryisocode'), (428, 'LV', 239, 'countryisocode'), (440, 'LT', 240, 'countryisocode'), (434, 'LY', 241, 'countryisocode'), (438, 'LI', 242, 'countryisocode'), (430, 'LR', 243, 'countryisocode'), (642, 'RO', 244, 'countryisocode'), (442, 'LU', 245, 'countryisocode'), (646, 'RW', 246, 'countryisocode'), (422, 'LB', 247, 'countryisocode'), (638, 'RE', 248, 'countryisocode'), (643, 'RU', 249, 'countryisocode')"); + } + + public function down(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + $this->addSql('DELETE FROM mtb_country_iso_code'); + } +} diff --git a/src/Eccube/Entity/Master/CountryIsoCode.php b/src/Eccube/Entity/Master/CountryIsoCode.php new file mode 100644 index 00000000000..345fd81ffda --- /dev/null +++ b/src/Eccube/Entity/Master/CountryIsoCode.php @@ -0,0 +1,36 @@ + + */ +class CountryIsoCodeRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CountryIsoCode::class); + } +} diff --git a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml index bd8383c3485..6d81b1d63e9 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml @@ -1,5 +1,6 @@ - mtb_authority.csv - mtb_country.csv +- mtb_country_iso_code.csv - mtb_csv_type.csv - mtb_customer_order_status.csv - mtb_customer_status.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv new file mode 100644 index 00000000000..941bd5c983f --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv @@ -0,0 +1,250 @@ +id,name,sort_no,discriminator_type +"352","IS","1","countryisocode" +"372","IE","2","countryisocode" +"31","AZ","3","countryisocode" +"4","AF","4","countryisocode" +"840","US","5","countryisocode" +"850","VI","6","countryisocode" +"16","AS","7","countryisocode" +"784","AE","8","countryisocode" +"12","DZ","9","countryisocode" +"32","AR","10","countryisocode" +"533","AW","11","countryisocode" +"8","AL","12","countryisocode" +"51","AM","13","countryisocode" +"660","AI","14","countryisocode" +"24","AO","15","countryisocode" +"28","AG","16","countryisocode" +"20","AD","17","countryisocode" +"887","YE","18","countryisocode" +"826","GB","19","countryisocode" +"86","IO","20","countryisocode" +"92","VG","21","countryisocode" +"376","IL","22","countryisocode" +"380","IT","23","countryisocode" +"368","IQ","24","countryisocode" +"364","IR","25","countryisocode" +"356","IN","26","countryisocode" +"360","ID","27","countryisocode" +"876","WF","28","countryisocode" +"800","UG","29","countryisocode" +"804","UA","30","countryisocode" +"860","UZ","31","countryisocode" +"858","UY","32","countryisocode" +"218","EC","33","countryisocode" +"818","EG","34","countryisocode" +"233","EE","35","countryisocode" +"231","ET","36","countryisocode" +"232","ER","37","countryisocode" +"222","SV","38","countryisocode" +"36","AU","39","countryisocode" +"40","AT","40","countryisocode" +"248","AX","41","countryisocode" +"512","OM","42","countryisocode" +"528","NL","43","countryisocode" +"288","GH","44","countryisocode" +"132","CV","45","countryisocode" +"831","GG","46","countryisocode" +"328","GY","47","countryisocode" +"398","KZ","48","countryisocode" +"634","QA","49","countryisocode" +"581","UM","50","countryisocode" +"124","CA","51","countryisocode" +"266","GA","52","countryisocode" +"120","CM","53","countryisocode" +"270","GM","54","countryisocode" +"116","KH","55","countryisocode" +"580","MP","56","countryisocode" +"324","GN","57","countryisocode" +"624","GW","58","countryisocode" +"196","CY","59","countryisocode" +"192","CU","60","countryisocode" +"531","CW","61","countryisocode" +"300","GR","62","countryisocode" +"296","KI","63","countryisocode" +"417","KG","64","countryisocode" +"320","GT","65","countryisocode" +"312","GP","66","countryisocode" +"316","GU","67","countryisocode" +"414","KW","68","countryisocode" +"184","CK","69","countryisocode" +"304","GL","70","countryisocode" +"162","CX","71","countryisocode" +"268","GE","72","countryisocode" +"308","GD","73","countryisocode" +"191","HR","74","countryisocode" +"136","KY","75","countryisocode" +"404","KE","76","countryisocode" +"384","CI","77","countryisocode" +"166","CC","78","countryisocode" +"188","CR","79","countryisocode" +"174","KM","80","countryisocode" +"170","CO","81","countryisocode" +"178","CG","82","countryisocode" +"180","CD","83","countryisocode" +"682","SA","84","countryisocode" +"239","GS","85","countryisocode" +"882","WS","86","countryisocode" +"678","ST","87","countryisocode" +"652","BL","88","countryisocode" +"894","ZM","89","countryisocode" +"666","PM","90","countryisocode" +"674","SM","91","countryisocode" +"663","MF","92","countryisocode" +"694","SL","93","countryisocode" +"262","DJ","94","countryisocode" +"292","GI","95","countryisocode" +"832","JE","96","countryisocode" +"388","JM","97","countryisocode" +"760","SY","98","countryisocode" +"702","SG","99","countryisocode" +"534","SX","100","countryisocode" +"716","ZW","101","countryisocode" +"756","CH","102","countryisocode" +"752","SE","103","countryisocode" +"729","SD","104","countryisocode" +"744","SJ","105","countryisocode" +"724","ES","106","countryisocode" +"740","SR","107","countryisocode" +"144","LK","108","countryisocode" +"703","SK","109","countryisocode" +"705","SI","110","countryisocode" +"748","SZ","111","countryisocode" +"690","SC","112","countryisocode" +"226","GQ","113","countryisocode" +"686","SN","114","countryisocode" +"688","RS","115","countryisocode" +"659","KN","116","countryisocode" +"670","VC","117","countryisocode" +"426","LS","118","countryisocode" +"654","SH","119","countryisocode" +"662","LC","120","countryisocode" +"706","SO","121","countryisocode" +"90","SB","122","countryisocode" +"796","TC","123","countryisocode" +"764","TH","124","countryisocode" +"410","KR","125","countryisocode" +"158","TW","126","countryisocode" +"762","TJ","127","countryisocode" +"834","TZ","128","countryisocode" +"203","CZ","129","countryisocode" +"148","TD","130","countryisocode" +"140","CF","131","countryisocode" +"156","CN","132","countryisocode" +"788","TN","133","countryisocode" +"408","KP","134","countryisocode" +"152","CL","135","countryisocode" +"798","TV","136","countryisocode" +"208","DK","137","countryisocode" +"276","DE","138","countryisocode" +"768","TG","139","countryisocode" +"772","TK","140","countryisocode" +"214","DO","141","countryisocode" +"212","DM","142","countryisocode" +"780","TT","143","countryisocode" +"795","TM","144","countryisocode" +"792","TR","145","countryisocode" +"776","TO","146","countryisocode" +"566","NG","147","countryisocode" +"520","NR","148","countryisocode" +"516","NA","149","countryisocode" +"10","AQ","150","countryisocode" +"570","NU","151","countryisocode" +"558","NI","152","countryisocode" +"562","NE","153","countryisocode" +"392","JP","154","countryisocode" +"732","EH","155","countryisocode" +"540","NC","156","countryisocode" +"554","NZ","157","countryisocode" +"524","NP","158","countryisocode" +"574","NF","159","countryisocode" +"578","NO","160","countryisocode" +"334","HM","161","countryisocode" +"48","BH","162","countryisocode" +"332","HT","163","countryisocode" +"586","PK","164","countryisocode" +"336","VA","165","countryisocode" +"591","PA","166","countryisocode" +"548","VU","167","countryisocode" +"44","BS","168","countryisocode" +"598","PG","169","countryisocode" +"60","BM","170","countryisocode" +"585","PW","171","countryisocode" +"600","PY","172","countryisocode" +"52","BB","173","countryisocode" +"275","PS","174","countryisocode" +"348","HU","175","countryisocode" +"50","BD","176","countryisocode" +"626","TL","177","countryisocode" +"612","PN","178","countryisocode" +"242","FJ","179","countryisocode" +"608","PH","180","countryisocode" +"246","FI","181","countryisocode" +"64","BT","182","countryisocode" +"74","BV","183","countryisocode" +"630","PR","184","countryisocode" +"234","FO","185","countryisocode" +"238","FK","186","countryisocode" +"76","BR","187","countryisocode" +"250","FR","188","countryisocode" +"254","GF","189","countryisocode" +"258","PF","190","countryisocode" +"260","TF","191","countryisocode" +"100","BG","192","countryisocode" +"854","BF","193","countryisocode" +"96","BN","194","countryisocode" +"108","BI","195","countryisocode" +"704","VN","196","countryisocode" +"204","BJ","197","countryisocode" +"862","VE","198","countryisocode" +"112","BY","199","countryisocode" +"84","BZ","200","countryisocode" +"604","PE","201","countryisocode" +"56","BE","202","countryisocode" +"616","PL","203","countryisocode" +"70","BA","204","countryisocode" +"72","BW","205","countryisocode" +"535","BQ","206","countryisocode" +"68","BO","207","countryisocode" +"620","PT","208","countryisocode" +"344","HK","209","countryisocode" +"340","HN","210","countryisocode" +"584","MH","211","countryisocode" +"446","MO","212","countryisocode" +"807","MK","213","countryisocode" +"450","MG","214","countryisocode" +"175","YT","215","countryisocode" +"454","MW","216","countryisocode" +"466","ML","217","countryisocode" +"470","MT","218","countryisocode" +"474","MQ","219","countryisocode" +"458","MY","220","countryisocode" +"833","IM","221","countryisocode" +"583","FM","222","countryisocode" +"710","ZA","223","countryisocode" +"728","SS","224","countryisocode" +"104","MM","225","countryisocode" +"484","MX","226","countryisocode" +"480","MU","227","countryisocode" +"478","MR","228","countryisocode" +"508","MZ","229","countryisocode" +"492","MC","230","countryisocode" +"462","MV","231","countryisocode" +"498","MD","232","countryisocode" +"504","MA","233","countryisocode" +"496","MN","234","countryisocode" +"499","ME","235","countryisocode" +"500","MS","236","countryisocode" +"400","JO","237","countryisocode" +"418","LA","238","countryisocode" +"428","LV","239","countryisocode" +"440","LT","240","countryisocode" +"434","LY","241","countryisocode" +"438","LI","242","countryisocode" +"430","LR","243","countryisocode" +"642","RO","244","countryisocode" +"442","LU","245","countryisocode" +"646","RW","246","countryisocode" +"422","LB","247","countryisocode" +"638","RE","248","countryisocode" +"643","RU","249","countryisocode" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml index bd8383c3485..6d81b1d63e9 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml @@ -1,5 +1,6 @@ - mtb_authority.csv - mtb_country.csv +- mtb_country_iso_code.csv - mtb_csv_type.csv - mtb_customer_order_status.csv - mtb_customer_status.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv new file mode 100644 index 00000000000..941bd5c983f --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv @@ -0,0 +1,250 @@ +id,name,sort_no,discriminator_type +"352","IS","1","countryisocode" +"372","IE","2","countryisocode" +"31","AZ","3","countryisocode" +"4","AF","4","countryisocode" +"840","US","5","countryisocode" +"850","VI","6","countryisocode" +"16","AS","7","countryisocode" +"784","AE","8","countryisocode" +"12","DZ","9","countryisocode" +"32","AR","10","countryisocode" +"533","AW","11","countryisocode" +"8","AL","12","countryisocode" +"51","AM","13","countryisocode" +"660","AI","14","countryisocode" +"24","AO","15","countryisocode" +"28","AG","16","countryisocode" +"20","AD","17","countryisocode" +"887","YE","18","countryisocode" +"826","GB","19","countryisocode" +"86","IO","20","countryisocode" +"92","VG","21","countryisocode" +"376","IL","22","countryisocode" +"380","IT","23","countryisocode" +"368","IQ","24","countryisocode" +"364","IR","25","countryisocode" +"356","IN","26","countryisocode" +"360","ID","27","countryisocode" +"876","WF","28","countryisocode" +"800","UG","29","countryisocode" +"804","UA","30","countryisocode" +"860","UZ","31","countryisocode" +"858","UY","32","countryisocode" +"218","EC","33","countryisocode" +"818","EG","34","countryisocode" +"233","EE","35","countryisocode" +"231","ET","36","countryisocode" +"232","ER","37","countryisocode" +"222","SV","38","countryisocode" +"36","AU","39","countryisocode" +"40","AT","40","countryisocode" +"248","AX","41","countryisocode" +"512","OM","42","countryisocode" +"528","NL","43","countryisocode" +"288","GH","44","countryisocode" +"132","CV","45","countryisocode" +"831","GG","46","countryisocode" +"328","GY","47","countryisocode" +"398","KZ","48","countryisocode" +"634","QA","49","countryisocode" +"581","UM","50","countryisocode" +"124","CA","51","countryisocode" +"266","GA","52","countryisocode" +"120","CM","53","countryisocode" +"270","GM","54","countryisocode" +"116","KH","55","countryisocode" +"580","MP","56","countryisocode" +"324","GN","57","countryisocode" +"624","GW","58","countryisocode" +"196","CY","59","countryisocode" +"192","CU","60","countryisocode" +"531","CW","61","countryisocode" +"300","GR","62","countryisocode" +"296","KI","63","countryisocode" +"417","KG","64","countryisocode" +"320","GT","65","countryisocode" +"312","GP","66","countryisocode" +"316","GU","67","countryisocode" +"414","KW","68","countryisocode" +"184","CK","69","countryisocode" +"304","GL","70","countryisocode" +"162","CX","71","countryisocode" +"268","GE","72","countryisocode" +"308","GD","73","countryisocode" +"191","HR","74","countryisocode" +"136","KY","75","countryisocode" +"404","KE","76","countryisocode" +"384","CI","77","countryisocode" +"166","CC","78","countryisocode" +"188","CR","79","countryisocode" +"174","KM","80","countryisocode" +"170","CO","81","countryisocode" +"178","CG","82","countryisocode" +"180","CD","83","countryisocode" +"682","SA","84","countryisocode" +"239","GS","85","countryisocode" +"882","WS","86","countryisocode" +"678","ST","87","countryisocode" +"652","BL","88","countryisocode" +"894","ZM","89","countryisocode" +"666","PM","90","countryisocode" +"674","SM","91","countryisocode" +"663","MF","92","countryisocode" +"694","SL","93","countryisocode" +"262","DJ","94","countryisocode" +"292","GI","95","countryisocode" +"832","JE","96","countryisocode" +"388","JM","97","countryisocode" +"760","SY","98","countryisocode" +"702","SG","99","countryisocode" +"534","SX","100","countryisocode" +"716","ZW","101","countryisocode" +"756","CH","102","countryisocode" +"752","SE","103","countryisocode" +"729","SD","104","countryisocode" +"744","SJ","105","countryisocode" +"724","ES","106","countryisocode" +"740","SR","107","countryisocode" +"144","LK","108","countryisocode" +"703","SK","109","countryisocode" +"705","SI","110","countryisocode" +"748","SZ","111","countryisocode" +"690","SC","112","countryisocode" +"226","GQ","113","countryisocode" +"686","SN","114","countryisocode" +"688","RS","115","countryisocode" +"659","KN","116","countryisocode" +"670","VC","117","countryisocode" +"426","LS","118","countryisocode" +"654","SH","119","countryisocode" +"662","LC","120","countryisocode" +"706","SO","121","countryisocode" +"90","SB","122","countryisocode" +"796","TC","123","countryisocode" +"764","TH","124","countryisocode" +"410","KR","125","countryisocode" +"158","TW","126","countryisocode" +"762","TJ","127","countryisocode" +"834","TZ","128","countryisocode" +"203","CZ","129","countryisocode" +"148","TD","130","countryisocode" +"140","CF","131","countryisocode" +"156","CN","132","countryisocode" +"788","TN","133","countryisocode" +"408","KP","134","countryisocode" +"152","CL","135","countryisocode" +"798","TV","136","countryisocode" +"208","DK","137","countryisocode" +"276","DE","138","countryisocode" +"768","TG","139","countryisocode" +"772","TK","140","countryisocode" +"214","DO","141","countryisocode" +"212","DM","142","countryisocode" +"780","TT","143","countryisocode" +"795","TM","144","countryisocode" +"792","TR","145","countryisocode" +"776","TO","146","countryisocode" +"566","NG","147","countryisocode" +"520","NR","148","countryisocode" +"516","NA","149","countryisocode" +"10","AQ","150","countryisocode" +"570","NU","151","countryisocode" +"558","NI","152","countryisocode" +"562","NE","153","countryisocode" +"392","JP","154","countryisocode" +"732","EH","155","countryisocode" +"540","NC","156","countryisocode" +"554","NZ","157","countryisocode" +"524","NP","158","countryisocode" +"574","NF","159","countryisocode" +"578","NO","160","countryisocode" +"334","HM","161","countryisocode" +"48","BH","162","countryisocode" +"332","HT","163","countryisocode" +"586","PK","164","countryisocode" +"336","VA","165","countryisocode" +"591","PA","166","countryisocode" +"548","VU","167","countryisocode" +"44","BS","168","countryisocode" +"598","PG","169","countryisocode" +"60","BM","170","countryisocode" +"585","PW","171","countryisocode" +"600","PY","172","countryisocode" +"52","BB","173","countryisocode" +"275","PS","174","countryisocode" +"348","HU","175","countryisocode" +"50","BD","176","countryisocode" +"626","TL","177","countryisocode" +"612","PN","178","countryisocode" +"242","FJ","179","countryisocode" +"608","PH","180","countryisocode" +"246","FI","181","countryisocode" +"64","BT","182","countryisocode" +"74","BV","183","countryisocode" +"630","PR","184","countryisocode" +"234","FO","185","countryisocode" +"238","FK","186","countryisocode" +"76","BR","187","countryisocode" +"250","FR","188","countryisocode" +"254","GF","189","countryisocode" +"258","PF","190","countryisocode" +"260","TF","191","countryisocode" +"100","BG","192","countryisocode" +"854","BF","193","countryisocode" +"96","BN","194","countryisocode" +"108","BI","195","countryisocode" +"704","VN","196","countryisocode" +"204","BJ","197","countryisocode" +"862","VE","198","countryisocode" +"112","BY","199","countryisocode" +"84","BZ","200","countryisocode" +"604","PE","201","countryisocode" +"56","BE","202","countryisocode" +"616","PL","203","countryisocode" +"70","BA","204","countryisocode" +"72","BW","205","countryisocode" +"535","BQ","206","countryisocode" +"68","BO","207","countryisocode" +"620","PT","208","countryisocode" +"344","HK","209","countryisocode" +"340","HN","210","countryisocode" +"584","MH","211","countryisocode" +"446","MO","212","countryisocode" +"807","MK","213","countryisocode" +"450","MG","214","countryisocode" +"175","YT","215","countryisocode" +"454","MW","216","countryisocode" +"466","ML","217","countryisocode" +"470","MT","218","countryisocode" +"474","MQ","219","countryisocode" +"458","MY","220","countryisocode" +"833","IM","221","countryisocode" +"583","FM","222","countryisocode" +"710","ZA","223","countryisocode" +"728","SS","224","countryisocode" +"104","MM","225","countryisocode" +"484","MX","226","countryisocode" +"480","MU","227","countryisocode" +"478","MR","228","countryisocode" +"508","MZ","229","countryisocode" +"492","MC","230","countryisocode" +"462","MV","231","countryisocode" +"498","MD","232","countryisocode" +"504","MA","233","countryisocode" +"496","MN","234","countryisocode" +"499","ME","235","countryisocode" +"500","MS","236","countryisocode" +"400","JO","237","countryisocode" +"418","LA","238","countryisocode" +"428","LV","239","countryisocode" +"440","LT","240","countryisocode" +"434","LY","241","countryisocode" +"438","LI","242","countryisocode" +"430","LR","243","countryisocode" +"642","RO","244","countryisocode" +"442","LU","245","countryisocode" +"646","RW","246","countryisocode" +"422","LB","247","countryisocode" +"638","RE","248","countryisocode" +"643","RU","249","countryisocode" diff --git a/src/Eccube/Service/AgentCommerce/AddressMappingService.php b/src/Eccube/Service/AgentCommerce/AddressMappingService.php index 261d93d0de3..c286d14d76c 100644 --- a/src/Eccube/Service/AgentCommerce/AddressMappingService.php +++ b/src/Eccube/Service/AgentCommerce/AddressMappingService.php @@ -18,280 +18,27 @@ use Eccube\Entity\Master\Country; use Eccube\Entity\Master\Pref; use Eccube\Entity\Shipping; +use Eccube\Repository\Master\CountryIsoCodeRepository; /** * EC-CUBE の住所系エンティティ (Customer / CustomerAddress / Shipping) を * ACP / UCP の住所 DTO へ写すためのマッピングサービス。 * - * - 国コードは Country.id (ISO 3166-1 numeric) を alpha-2 へ変換する。 - * mtb_country.csv に登録されている全 id を ISO 3166-1 標準どおりにマップする。 + * - 国コードは Country.id (ISO 3166-1 numeric) を alpha-2 へ変換する。変換表は + * マスタ mtb_country_iso_code (id=ISO numeric, name=alpha-2) で管理し、 + * CountryIsoCodeRepository 経由で解決する (コードにハードコードしない)。 * - region は Pref 名 (例: 東京都) をそのまま返す。 - * - app/Customize で変換テーブル/ロジックを差し替え可能にする意図のため、 - * NUMERIC_TO_ALPHA2 への参照は必ずメソッド (getAlpha2FromCountryId) 経由で行う。 */ class AddressMappingService { - /** - * ISO 3166-1 numeric (mtb_country.id) => alpha-2。 - * mtb_country.csv に出現する全 id を網羅する。未知 id は null を返す設計。 - * - * @var array - */ - private const NUMERIC_TO_ALPHA2 = [ - 352 => 'IS', // アイスランド - 372 => 'IE', // アイルランド - 31 => 'AZ', // アゼルバイジャン - 4 => 'AF', // アフガニスタン - 840 => 'US', // アメリカ合衆国 - 850 => 'VI', // アメリカ領ヴァージン諸島 - 16 => 'AS', // アメリカ領サモア - 784 => 'AE', // アラブ首長国連邦 - 12 => 'DZ', // アルジェリア - 32 => 'AR', // アルゼンチン - 533 => 'AW', // アルバ - 8 => 'AL', // アルバニア - 51 => 'AM', // アルメニア - 660 => 'AI', // アンギラ - 24 => 'AO', // アンゴラ - 28 => 'AG', // アンティグア・バーブーダ - 20 => 'AD', // アンドラ - 887 => 'YE', // イエメン - 826 => 'GB', // イギリス - 86 => 'IO', // イギリス領インド洋地域 - 92 => 'VG', // イギリス領ヴァージン諸島 - 376 => 'IL', // イスラエル - 380 => 'IT', // イタリア - 368 => 'IQ', // イラク - 364 => 'IR', // イラン - 356 => 'IN', // インド - 360 => 'ID', // インドネシア - 876 => 'WF', // ウォリス・フツナ - 800 => 'UG', // ウガンダ - 804 => 'UA', // ウクライナ - 860 => 'UZ', // ウズベキスタン - 858 => 'UY', // ウルグアイ - 218 => 'EC', // エクアドル - 818 => 'EG', // エジプト - 233 => 'EE', // エストニア - 231 => 'ET', // エチオピア - 232 => 'ER', // エリトリア - 222 => 'SV', // エルサルバドル - 36 => 'AU', // オーストラリア - 40 => 'AT', // オーストリア - 248 => 'AX', // オーランド諸島 - 512 => 'OM', // オマーン - 528 => 'NL', // オランダ - 288 => 'GH', // ガーナ - 132 => 'CV', // カーボベルデ - 831 => 'GG', // ガーンジー - 328 => 'GY', // ガイアナ - 398 => 'KZ', // カザフスタン - 634 => 'QA', // カタール - 581 => 'UM', // 合衆国領有小離島 - 124 => 'CA', // カナダ - 266 => 'GA', // ガボン - 120 => 'CM', // カメルーン - 270 => 'GM', // ガンビア - 116 => 'KH', // カンボジア - 580 => 'MP', // 北マリアナ諸島 - 324 => 'GN', // ギニア - 624 => 'GW', // ギニアビサウ - 196 => 'CY', // キプロス - 192 => 'CU', // キューバ - 531 => 'CW', // キュラソー島 - 300 => 'GR', // ギリシャ - 296 => 'KI', // キリバス - 417 => 'KG', // キルギス - 320 => 'GT', // グアテマラ - 312 => 'GP', // グアドループ - 316 => 'GU', // グアム - 414 => 'KW', // クウェート - 184 => 'CK', // クック諸島 - 304 => 'GL', // グリーンランド - 162 => 'CX', // クリスマス島 - 268 => 'GE', // グルジア - 308 => 'GD', // グレナダ - 191 => 'HR', // クロアチア - 136 => 'KY', // ケイマン諸島 - 404 => 'KE', // ケニア - 384 => 'CI', // コートジボワール - 166 => 'CC', // ココス諸島 - 188 => 'CR', // コスタリカ - 174 => 'KM', // コモロ - 170 => 'CO', // コロンビア - 178 => 'CG', // コンゴ共和国 - 180 => 'CD', // コンゴ民主共和国 - 682 => 'SA', // サウジアラビア - 239 => 'GS', // サウスジョージア・サウスサンドウィッチ諸島 - 882 => 'WS', // サモア - 678 => 'ST', // サントメ・プリンシペ - 652 => 'BL', // サン・バルテルミー島 - 894 => 'ZM', // ザンビア - 666 => 'PM', // サンピエール島・ミクロン島 - 674 => 'SM', // サンマリノ - 663 => 'MF', // サン・マルタン (フランス領) - 694 => 'SL', // シエラレオネ - 262 => 'DJ', // ジブチ - 292 => 'GI', // ジブラルタル - 832 => 'JE', // ジャージー - 388 => 'JM', // ジャマイカ - 760 => 'SY', // シリア - 702 => 'SG', // シンガポール - 534 => 'SX', // シント・マールテン - 716 => 'ZW', // ジンバブエ - 756 => 'CH', // スイス - 752 => 'SE', // スウェーデン - 729 => 'SD', // スーダン - 744 => 'SJ', // スヴァールバル諸島およびヤンマイエン島 - 724 => 'ES', // スペイン - 740 => 'SR', // スリナム - 144 => 'LK', // スリランカ - 703 => 'SK', // スロバキア - 705 => 'SI', // スロベニア - 748 => 'SZ', // スワジランド - 690 => 'SC', // セーシェル - 226 => 'GQ', // 赤道ギニア - 686 => 'SN', // セネガル - 688 => 'RS', // セルビア - 659 => 'KN', // セントクリストファー・ネイビス - 670 => 'VC', // セントビンセント・グレナディーン - 426 => 'LS', // レソト - 654 => 'SH', // セントヘレナ・アセンションおよびトリスタンダクーニャ - 662 => 'LC', // セントルシア - 706 => 'SO', // ソマリア - 90 => 'SB', // ソロモン諸島 - 796 => 'TC', // タークス・カイコス諸島 - 764 => 'TH', // タイ王国 - 410 => 'KR', // 大韓民国 - 158 => 'TW', // 台湾 - 762 => 'TJ', // タジキスタン - 834 => 'TZ', // タンザニア - 203 => 'CZ', // チェコ - 148 => 'TD', // チャド - 140 => 'CF', // 中央アフリカ共和国 - 156 => 'CN', // 中華人民共和国 - 788 => 'TN', // チュニジア - 408 => 'KP', // 朝鮮民主主義人民共和国 - 152 => 'CL', // チリ - 798 => 'TV', // ツバル - 208 => 'DK', // デンマーク - 276 => 'DE', // ドイツ - 768 => 'TG', // トーゴ - 772 => 'TK', // トケラウ - 214 => 'DO', // ドミニカ共和国 - 212 => 'DM', // ドミニカ国 - 780 => 'TT', // トリニダード・トバゴ - 795 => 'TM', // トルクメニスタン - 792 => 'TR', // トルコ - 776 => 'TO', // トンガ - 566 => 'NG', // ナイジェリア - 520 => 'NR', // ナウル - 516 => 'NA', // ナミビア - 10 => 'AQ', // 南極 - 570 => 'NU', // ニウエ - 558 => 'NI', // ニカラグア - 562 => 'NE', // ニジェール - 392 => 'JP', // 日本 - 732 => 'EH', // 西サハラ - 540 => 'NC', // ニューカレドニア - 554 => 'NZ', // ニュージーランド - 524 => 'NP', // ネパール - 574 => 'NF', // ノーフォーク島 - 578 => 'NO', // ノルウェー - 334 => 'HM', // ハード島とマクドナルド諸島 - 48 => 'BH', // バーレーン - 332 => 'HT', // ハイチ - 586 => 'PK', // パキスタン - 336 => 'VA', // バチカン - 591 => 'PA', // パナマ - 548 => 'VU', // バヌアツ - 44 => 'BS', // バハマ - 598 => 'PG', // パプアニューギニア - 60 => 'BM', // バミューダ諸島 - 585 => 'PW', // パラオ - 600 => 'PY', // パラグアイ - 52 => 'BB', // バルバドス - 275 => 'PS', // パレスチナ - 348 => 'HU', // ハンガリー - 50 => 'BD', // バングラデシュ - 626 => 'TL', // 東ティモール - 612 => 'PN', // ピトケアン諸島 - 242 => 'FJ', // フィジー - 608 => 'PH', // フィリピン - 246 => 'FI', // フィンランド - 64 => 'BT', // ブータン - 74 => 'BV', // ブーベ島 - 630 => 'PR', // プエルトリコ - 234 => 'FO', // フェロー諸島 - 238 => 'FK', // フォークランド諸島 - 76 => 'BR', // ブラジル - 250 => 'FR', // フランス - 254 => 'GF', // フランス領ギアナ - 258 => 'PF', // フランス領ポリネシア - 260 => 'TF', // フランス領南方・南極地域 - 100 => 'BG', // ブルガリア - 854 => 'BF', // ブルキナファソ - 96 => 'BN', // ブルネイ - 108 => 'BI', // ブルンジ - 704 => 'VN', // ベトナム - 204 => 'BJ', // ベナン - 862 => 'VE', // ベネズエラ - 112 => 'BY', // ベラルーシ - 84 => 'BZ', // ベリーズ - 604 => 'PE', // ペルー - 56 => 'BE', // ベルギー - 616 => 'PL', // ポーランド - 70 => 'BA', // ボスニア・ヘルツェゴビナ - 72 => 'BW', // ボツワナ - 535 => 'BQ', // BES諸島 - 68 => 'BO', // ボリビア - 620 => 'PT', // ポルトガル - 344 => 'HK', // 香港 - 340 => 'HN', // ホンジュラス - 584 => 'MH', // マーシャル諸島 - 446 => 'MO', // マカオ - 807 => 'MK', // マケドニア共和国 - 450 => 'MG', // マダガスカル - 175 => 'YT', // マヨット - 454 => 'MW', // マラウイ - 466 => 'ML', // マリ共和国 - 470 => 'MT', // マルタ - 474 => 'MQ', // マルティニーク - 458 => 'MY', // マレーシア - 833 => 'IM', // マン島 - 583 => 'FM', // ミクロネシア連邦 - 710 => 'ZA', // 南アフリカ共和国 - 728 => 'SS', // 南スーダン - 104 => 'MM', // ミャンマー - 484 => 'MX', // メキシコ - 480 => 'MU', // モーリシャス - 478 => 'MR', // モーリタニア - 508 => 'MZ', // モザンビーク - 492 => 'MC', // モナコ - 462 => 'MV', // モルディブ - 498 => 'MD', // モルドバ - 504 => 'MA', // モロッコ - 496 => 'MN', // モンゴル国 - 499 => 'ME', // モンテネグロ - 500 => 'MS', // モントセラト - 400 => 'JO', // ヨルダン - 418 => 'LA', // ラオス - 428 => 'LV', // ラトビア - 440 => 'LT', // リトアニア - 434 => 'LY', // リビア - 438 => 'LI', // リヒテンシュタイン - 430 => 'LR', // リベリア - 642 => 'RO', // ルーマニア - 442 => 'LU', // ルクセンブルク - 646 => 'RW', // ルワンダ - 422 => 'LB', // レバノン - 638 => 'RE', // レユニオン - 643 => 'RU', // ロシア - ]; + public function __construct( + private readonly CountryIsoCodeRepository $countryIsoCodeRepository, + ) { + } /** - * ISO 3166-1 numeric (Country.id) を alpha-2 へ変換する。 - * 未知の id / null は null を返す。 + * ISO 3166-1 numeric (mtb_country.id) を alpha-2 へ変換する。 + * mtb_country_iso_code マスタに無い id / null は null を返す。 */ public function getAlpha2FromCountryId(?int $numericCountryId): ?string { @@ -299,7 +46,7 @@ public function getAlpha2FromCountryId(?int $numericCountryId): ?string return null; } - return self::NUMERIC_TO_ALPHA2[$numericCountryId] ?? null; + return $this->countryIsoCodeRepository->find($numericCountryId)?->getName(); } /** @@ -346,7 +93,7 @@ public function toAddressArray(Customer|CustomerAddress|Shipping $source): array 'region' => $this->getRegionFromPref($this->extractPref($source)), 'address1' => $this->callIfExists($source, 'getAddr01'), 'address2' => $this->callIfExists($source, 'getAddr02'), - 'country' => $this->getAlpha2FromCountryId($countryId ?? null), + 'country' => $this->getAlpha2FromCountryId($countryId), 'phone' => $this->extractPhoneNumber($source), ]; } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php index add67b72910..cc70987718d 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php @@ -15,28 +15,34 @@ namespace Eccube\Tests\Service\AgentCommerce; +use Doctrine\Persistence\ManagerRegistry; use Eccube\Entity\Customer; use Eccube\Entity\Master\Country; use Eccube\Entity\Master\Pref; use Eccube\Entity\Shipping; +use Eccube\Repository\Master\CountryIsoCodeRepository; use Eccube\Service\AgentCommerce\AddressMappingService; -use PHPUnit\Framework\TestCase; +use Eccube\Tests\EccubeTestCase; /** - * Layer 1 (pure logic) tests for AddressMappingService. + * Layer 2 tests for AddressMappingService. * - * Verifies ISO 3166-1 numeric (mtb_country.id) -> alpha-2 mapping, that every - * id shipped in mtb_country.csv resolves to a non-null alpha-2, name/region - * splitting, and DTO array shaping. Entities are constructed in-memory (no DB). + * 国コード変換はマスタ mtb_country_iso_code (CountryIsoCodeRepository) 経由になったため、 + * fixtures を読み込んだ DB 上で検証する。ISO 3166-1 numeric (mtb_country.id) -> alpha-2 の + * 解決、mtb_country.csv の全 id がマスタで解決できること、姓名分離・DTO 整形を確認する。 */ -final class AddressMappingServiceTest extends TestCase +final class AddressMappingServiceTest extends EccubeTestCase { - private AddressMappingService $service; + private ?AddressMappingService $service = null; protected function setUp(): void { parent::setUp(); - $this->service = new AddressMappingService(); + // AddressMappingService / CountryIsoCodeRepository は未参照の private サービスで + // コンパイラに除去されるため、ManagerRegistry からリポジトリを構築して直接注入する。 + $registry = self::getContainer()->get('doctrine'); + \assert($registry instanceof ManagerRegistry); + $this->service = new AddressMappingService(new CountryIsoCodeRepository($registry)); } public function testGetAlpha2FromCountryIdKnownIds(): void @@ -52,22 +58,22 @@ public function testGetAlpha2FromCountryIdKnownIds(): void public function testGetAlpha2FromCountryIdNullAndUnknown(): void { $this->assertNull($this->service->getAlpha2FromCountryId(null), 'Null country id must yield null alpha-2'); - $this->assertNull($this->service->getAlpha2FromCountryId(999999), 'Unknown numeric id must yield null alpha-2 (no exception)'); + $this->assertNull($this->service->getAlpha2FromCountryId(999), 'Unknown numeric id must yield null alpha-2 (no exception)'); } /** - * Every numeric id present in the shipped mtb_country.csv must map to a - * non-null alpha-2, otherwise an address built from that country breaks. + * mtb_country.csv に存在する全 numeric id が mtb_country_iso_code マスタで + * 非 null の alpha-2 に解決できること (どの国でも住所変換が破綻しない保証)。 */ - public function testAllCsvCountryIdsResolve(): void + public function testAllCountryIdsResolveViaMaster(): void { $ids = $this->loadCountryIdsFromCsv(); $this->assertNotEmpty($ids, 'mtb_country.csv must contain country rows for this assertion to be meaningful'); foreach ($ids as $id) { $alpha2 = $this->service->getAlpha2FromCountryId($id); - $this->assertNotNull($alpha2, sprintf('mtb_country.csv id %d must map to a non-null ISO 3166-1 alpha-2', $id)); - $this->assertMatchesRegularExpression('/^[A-Z]{2}$/', $alpha2, sprintf('mtb_country.csv id %d must map to a two-letter uppercase alpha-2 code', $id)); + $this->assertNotNull($alpha2, sprintf('mtb_country id %d must resolve to a non-null alpha-2 via mtb_country_iso_code', $id)); + $this->assertMatchesRegularExpression('/^[A-Z]{2}$/', $alpha2, sprintf('mtb_country id %d must map to a two-letter uppercase alpha-2 code', $id)); } } @@ -114,7 +120,7 @@ public function testToAddressArrayFromCustomerSplitsNameAndMapsCountry(): void $this->assertSame('東京都', $address['region'], 'region must be the prefecture name'); $this->assertSame('千代田区千代田', $address['address1'], 'address1 must come from addr01'); $this->assertSame('1-1', $address['address2'], 'address2 must come from addr02'); - $this->assertSame('JP', $address['country'], 'country must be alpha-2 derived from Country.id (392 -> JP)'); + $this->assertSame('JP', $address['country'], 'country must be alpha-2 resolved from Country.id (392 -> JP)'); $this->assertSame('0312345678', $address['phone'], 'phone must come from phone_number'); } @@ -141,7 +147,7 @@ public function testToAddressArrayFromShipping(): void $this->assertSame('佐藤', $address['family_name'], 'Shipping family_name must come from name01'); $this->assertSame('花子', $address['given_name'], 'Shipping given_name must come from name02'); $this->assertSame('北海道', $address['region'], 'Shipping region must be the prefecture name'); - $this->assertSame('US', $address['country'], 'Shipping country must be alpha-2 derived from Country.id (840 -> US)'); + $this->assertSame('US', $address['country'], 'Shipping country must be alpha-2 resolved from Country.id (840 -> US)'); $this->assertSame('0111234567', $address['phone'], 'Shipping phone must come from phone_number'); } @@ -168,8 +174,7 @@ private function loadCountryIdsFromCsv(): array $ids = []; $handle = fopen($csvPath, 'r'); - $header = fgetcsv($handle, null, ',', '"', ''); // skip header row - $this->assertIsArray($header, 'mtb_country.csv must have a header row'); + fgetcsv($handle, null, ',', '"', ''); // skip header row while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { if (!isset($row[0]) || $row[0] === '') { continue; From cbd91b008eb2735a2a351e36a1788f8ffb3666d3 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 4 Jun 2026 17:38:02 +0900 Subject: [PATCH 07/28] =?UTF-8?q?fix(agent-commerce):=20rector=20=E6=8C=87?= =?UTF-8?q?=E6=91=98=E5=AF=BE=E5=BF=9C=20(AddressMappingServiceTest=20?= =?UTF-8?q?=E3=81=AE=E3=82=B3=E3=83=B3=E3=83=86=E3=83=8A=E5=8F=96=E5=BE=97?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI rector ジョブ (ContainerGetNameToTypeInTestsRector / AssertFuncCallToPHPUnitAssertRector) の指摘に対応。get('doctrine') + ManagerRegistry 手動構築をやめ、services_test.yaml で AddressMappingService を public 化しコンテナから FQCN 取得する方式へ変更。 - services_test.yaml: AddressMappingService を public 化 (consumer 未実装で private では除去されるため) - AddressMappingServiceTest: self::getContainer()->get(AddressMappingService::class) に簡素化 検証: PHPUnit 52/608、PHPStan level6 No errors、php-cs-fixer 0、rector dry-run クリーン。 Co-Authored-By: Claude Opus 4.8 --- app/config/eccube/services_test.yaml | 4 ++++ .../AgentCommerce/AddressMappingServiceTest.php | 10 +++------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/config/eccube/services_test.yaml b/app/config/eccube/services_test.yaml index ea2133e30e6..9e0513a6ba9 100644 --- a/app/config/eccube/services_test.yaml +++ b/app/config/eccube/services_test.yaml @@ -77,3 +77,7 @@ services: Eccube\Service\Composer\ComposerApiService: autowire: true public: true + # AddressMappingService はまだ consumer が無く private では除去されるためテスト時のみ public 化 + Eccube\Service\AgentCommerce\AddressMappingService: + autowire: true + public: true diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php index cc70987718d..5a68eb82751 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php @@ -15,12 +15,10 @@ namespace Eccube\Tests\Service\AgentCommerce; -use Doctrine\Persistence\ManagerRegistry; use Eccube\Entity\Customer; use Eccube\Entity\Master\Country; use Eccube\Entity\Master\Pref; use Eccube\Entity\Shipping; -use Eccube\Repository\Master\CountryIsoCodeRepository; use Eccube\Service\AgentCommerce\AddressMappingService; use Eccube\Tests\EccubeTestCase; @@ -38,11 +36,9 @@ final class AddressMappingServiceTest extends EccubeTestCase protected function setUp(): void { parent::setUp(); - // AddressMappingService / CountryIsoCodeRepository は未参照の private サービスで - // コンパイラに除去されるため、ManagerRegistry からリポジトリを構築して直接注入する。 - $registry = self::getContainer()->get('doctrine'); - \assert($registry instanceof ManagerRegistry); - $this->service = new AddressMappingService(new CountryIsoCodeRepository($registry)); + // AddressMappingService はまだ consumer が無く private では除去されるため、 + // services_test.yaml で public 化してコンテナから取得する。 + $this->service = self::getContainer()->get(AddressMappingService::class); } public function testGetAlpha2FromCountryIdKnownIds(): void From 97f68532a59337d7c46fef9358b4b1c25c09a2e6 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Tue, 9 Jun 2026 16:16:13 +0900 Subject: [PATCH 08/28] =?UTF-8?q?refactor(agent-commerce):=20=E3=83=95?= =?UTF-8?q?=E3=83=A9=E3=82=B0=E3=82=92=20checkout=20=E5=88=B6=E5=BE=A1?= =?UTF-8?q?=E3=81=B8=E6=95=B4=E7=90=86=E3=81=97=E5=BA=97=E8=88=97=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E3=81=AB=E3=83=88=E3=82=B0=E3=83=AB=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BaseInfo の有効化フラグを「checkout の有無を制御する」意味へ整理する。 discovery / catalog は公開して害がないため常時公開とし (ゲート撤去は #6794)、 ACP feed push は認証情報の有無で実質ガードされるためフラグ不要とする。 - 改名: acp_enabled → acp_checkout_enabled / ucp_enabled → ucp_checkout_enabled (getter は isAcpCheckoutEnabled / isUcpCheckoutEnabled) - 削除: acp_feed_enabled (push は base URL + API key の有無でガード) / ucp_catalog_api_enabled (UCP Catalog は常時公開) - 維持: ucp_catalog_requires_auth (catalog の OAuth 必須モードを api4 着手時に実装) - dtb_base_info.csv (ja/en) ヘッダを 3 フラグへ更新 - 店舗設定 (ShopMasterType / @admin/Setting/Shop/shop_master.twig) に acp_checkout_enabled / ucp_checkout_enabled のトグルを追加 (checkout は日本未提供の注記つき) - BaseInfoAgentCommerceFlagsTest を 3 フラグへ更新 PHPStan level 6 No errors / php-cs-fixer 0 / 関連テスト green。 カラム変更は schema:update 方式 (ALTER マイグレーションは書かない)。 Refs #6777 Co-Authored-By: Claude Opus 4.8 --- src/Eccube/Entity/BaseInfo.php | 74 ++++--------------- src/Eccube/Form/Type/Admin/ShopMasterType.php | 3 + .../doctrine/import_csv/en/dtb_base_info.csv | 2 +- .../doctrine/import_csv/ja/dtb_base_info.csv | 2 +- src/Eccube/Resource/locale/messages.en.yaml | 4 + src/Eccube/Resource/locale/messages.ja.yaml | 4 + .../admin/Setting/Shop/shop_master.twig | 24 ++++++ .../BaseInfoAgentCommerceFlagsTest.php | 20 ++--- 8 files changed, 63 insertions(+), 70 deletions(-) diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index e31a26c4c5a..b5e3fd92f66 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -154,17 +154,11 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'ga_id', type: Types::STRING, length: 255, nullable: true)] private ?string $gaId = null; - #[ORM\Column(name: 'acp_enabled', type: Types::BOOLEAN, options: ['default' => false])] - private bool $acp_enabled = false; + #[ORM\Column(name: 'acp_checkout_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $acp_checkout_enabled = false; - #[ORM\Column(name: 'ucp_enabled', type: Types::BOOLEAN, options: ['default' => false])] - private bool $ucp_enabled = false; - - #[ORM\Column(name: 'acp_feed_enabled', type: Types::BOOLEAN, options: ['default' => false])] - private bool $acp_feed_enabled = false; - - #[ORM\Column(name: 'ucp_catalog_api_enabled', type: Types::BOOLEAN, options: ['default' => false])] - private bool $ucp_catalog_api_enabled = false; + #[ORM\Column(name: 'ucp_checkout_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_checkout_enabled = false; #[ORM\Column(name: 'ucp_catalog_requires_auth', type: Types::BOOLEAN, options: ['default' => false])] private bool $ucp_catalog_requires_auth = false; @@ -848,75 +842,39 @@ public function getGaId(): ?string } /** - * Set acpEnabled. - */ - public function setAcpEnabled(bool $acpEnabled): BaseInfo - { - $this->acp_enabled = $acpEnabled; - - return $this; - } - - /** - * Get acpEnabled. - */ - public function isAcpEnabled(): bool - { - return $this->acp_enabled; - } - - /** - * Set ucpEnabled. - */ - public function setUcpEnabled(bool $ucpEnabled): BaseInfo - { - $this->ucp_enabled = $ucpEnabled; - - return $this; - } - - /** - * Get ucpEnabled. - */ - public function isUcpEnabled(): bool - { - return $this->ucp_enabled; - } - - /** - * Set acpFeedEnabled. + * Set acpCheckoutEnabled. */ - public function setAcpFeedEnabled(bool $acpFeedEnabled): BaseInfo + public function setAcpCheckoutEnabled(bool $acpCheckoutEnabled): BaseInfo { - $this->acp_feed_enabled = $acpFeedEnabled; + $this->acp_checkout_enabled = $acpCheckoutEnabled; return $this; } /** - * Get acpFeedEnabled. + * Get acpCheckoutEnabled. */ - public function isAcpFeedEnabled(): bool + public function isAcpCheckoutEnabled(): bool { - return $this->acp_feed_enabled; + return $this->acp_checkout_enabled; } /** - * Set ucpCatalogApiEnabled. + * Set ucpCheckoutEnabled. */ - public function setUcpCatalogApiEnabled(bool $ucpCatalogApiEnabled): BaseInfo + public function setUcpCheckoutEnabled(bool $ucpCheckoutEnabled): BaseInfo { - $this->ucp_catalog_api_enabled = $ucpCatalogApiEnabled; + $this->ucp_checkout_enabled = $ucpCheckoutEnabled; return $this; } /** - * Get ucpCatalogApiEnabled. + * Get ucpCheckoutEnabled. */ - public function isUcpCatalogApiEnabled(): bool + public function isUcpCheckoutEnabled(): bool { - return $this->ucp_catalog_api_enabled; + return $this->ucp_checkout_enabled; } /** diff --git a/src/Eccube/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index ec5f4de8a9c..2bf342d587e 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -218,6 +218,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]), ], ]) + // エージェントコマース checkout の有効化フラグ (discovery / catalog は常時公開、checkout のみ制御) + ->add('acp_checkout_enabled', ToggleSwitchType::class) + ->add('ucp_checkout_enabled', ToggleSwitchType::class) ; $builder->add( diff --git a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv index 588eed4edf7..2e993540906 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_enabled,ucp_enabled,acp_feed_enabled,ucp_catalog_api_enabled,ucp_catalog_requires_auth +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_checkout_enabled,ucp_checkout_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv index 588eed4edf7..2e993540906 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_enabled,ucp_enabled,acp_feed_enabled,ucp_catalog_api_enabled,ucp_catalog_requires_auth +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_checkout_enabled,ucp_checkout_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 3807b8b6380..22771af0744 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1186,6 +1186,10 @@ admin.setting.shop.shop.option_point_rate: Point Return Rate admin.setting.shop.shop.option_point_conversion_rate: Point Conversion Rate admin.setting.shop.shop.ga: "Google Analytics" admin.setting.shop.shop.ga.tracking_id: "Tracking ID" +admin.setting.shop.shop.agent_commerce: "Agentic Commerce" +admin.setting.shop.shop.agent_commerce.description: "Enable checkout via AI agents (ChatGPT / Gemini, etc.). Discovery and catalog are always public; only checkout is controlled here. Checkout is not yet available in Japan, so normally leave these disabled." +admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "Enable ACP checkout" +admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "Enable UCP checkout" #------------------------------------------------------------------------------------ # Settings:Store Settings:Trade Law Settings diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 96445e787c0..fb4ec95a305 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1185,6 +1185,10 @@ admin.setting.shop.shop.option_point_rate: ポイント付与率 admin.setting.shop.shop.option_point_conversion_rate: ポイント換算レート admin.setting.shop.shop.ga: "Googleアナリティクス設定" admin.setting.shop.shop.ga.tracking_id: "トラッキングID" +admin.setting.shop.shop.agent_commerce: "エージェントコマース" +admin.setting.shop.shop.agent_commerce.description: "AIエージェント (ChatGPT / Gemini 等) 経由のチェックアウトを有効化します。Discovery / カタログは常時公開され、ここでは checkout のみを制御します。checkout は日本未提供のため、通常は無効のままにしてください。" +admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "ACP チェックアウトを有効にする" +admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "UCP チェックアウトを有効にする" #------------------------------------------------------------------------------------ # 設定:店舗設定:特定商取引法設定 diff --git a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig index b30e83f8049..67ef3f96e25 100644 --- a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig +++ b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig @@ -373,6 +373,30 @@ file that was distributed with this source code. +
+
{{ 'admin.setting.shop.shop.agent_commerce'|trans }}
+
+

{{ 'admin.setting.shop.shop.agent_commerce.description'|trans }}

+
+
+ {{ 'admin.setting.shop.shop.agent_commerce.acp_checkout_enabled'|trans }} +
+
+ {{ form_widget(form.acp_checkout_enabled) }} + {{ form_errors(form.acp_checkout_enabled) }} +
+
+
+
+ {{ 'admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled'|trans }} +
+
+ {{ form_widget(form.ucp_checkout_enabled) }} + {{ form_errors(form.ucp_checkout_enabled) }} +
+
+
+
diff --git a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php index cc0757bf93f..fb0391bf169 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php @@ -22,20 +22,20 @@ /** * Layer 2 (Doctrine) tests for the agent-commerce BaseInfo flags. * - * Verifies that the five enable flags (acp_enabled / ucp_enabled / - * acp_feed_enabled / ucp_catalog_api_enabled / ucp_catalog_requires_auth) - * default to false for both the persisted BaseInfo (id=1) and a freshly - * constructed instance, matching the "default false / off by default" - * contract from the base plan. + * Verifies that the enable flags (acp_checkout_enabled / ucp_checkout_enabled / + * ucp_catalog_requires_auth) default to false for both the persisted BaseInfo + * (id=1) and a freshly constructed instance, matching the "default false / off + * by default" contract from the base plan. + * + * Discovery / catalog are always public (no flag); only checkout and the future + * catalog auth mode are gated by these flags. */ final class BaseInfoAgentCommerceFlagsTest extends EccubeTestCase { - /** @var string[] The five enable flags that MUST default to false. */ + /** @var string[] The enable flags that MUST default to false. */ private const FLAG_PROPERTIES = [ - 'acp_enabled', - 'ucp_enabled', - 'acp_feed_enabled', - 'ucp_catalog_api_enabled', + 'acp_checkout_enabled', + 'ucp_checkout_enabled', 'ucp_catalog_requires_auth', ]; From 637977437f67a404afa7cf2ea60c09a31e598f9b Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 11 Jun 2026 15:55:41 +0900 Subject: [PATCH 09/28] =?UTF-8?q?fix(agent-commerce):=20CodeRabbit=20?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C=20(#6802)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MinorUnitConverter: 不正な金額文字列 (abc / 1,000 / 1..2 等) を BCMath 呼び出し前に正規表現で弾き、ValueError を防止 (仕様の「不正は 0」契約遵守) - FilesystemKeyStore: 鍵書き込み時の mkdir / file_put_contents 失敗を RuntimeException で検知し、サイレント失敗を防止 - Version20260604120000: down() を不可逆 migration として明示し、 新規インストール (up() が no-op) 環境での import_csv 由来データ巻き添え削除を回避 - AddressMappingServiceTest: fopen 後に assertIsResource を追加し診断性を向上 - MinorUnitConverterTest: 不正・空入力の回帰テスト 8 ケースを追加 CodeRabbit 指摘のうち COUNT(*) 判定と (bool) キャストの 2 件は非妥当のため非対応。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Version20260604120000.php | 9 ++++----- .../AgentCommerce/MinorUnitConverter.php | 6 ++++-- .../Security/FilesystemKeyStore.php | 8 +++++--- .../AddressMappingServiceTest.php | 1 + .../AgentCommerce/MinorUnitConverterTest.php | 19 +++++++++++++++++++ 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/DoctrineMigrations/Version20260604120000.php b/app/DoctrineMigrations/Version20260604120000.php index 58a0f48dabe..74274ee06bb 100644 --- a/app/DoctrineMigrations/Version20260604120000.php +++ b/app/DoctrineMigrations/Version20260604120000.php @@ -47,10 +47,9 @@ public function up(Schema $schema): void public function down(Schema $schema): void { - if (!$schema->hasTable(self::NAME)) { - return; - } - - $this->addSql('DELETE FROM mtb_country_iso_code'); + // 新規インストールでは up() が no-op (import_csv 投入済み) のため、 + // 一律 DELETE すると import_csv 由来の初期データまで巻き添えで消える。 + // マスタ初期データは不可逆として扱い、ロールバックでは何もしない。 + $this->throwIrreversibleMigrationException(self::NAME.' はマスタ初期データのため down は非対応です.'); } } diff --git a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php index a513f80dc40..5ed832b1c97 100644 --- a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php +++ b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php @@ -54,8 +54,10 @@ public function toMinorUnits(string $amount, string $currency): int $amount = substr($amount, 1); } - // 空や不正な表記は 0 とみなす。 - if ($amount === '' || $amount === '.') { + // 空や不正な表記は 0 とみなす (符号は分離済みなので非負の decimal 形式のみ許可)。 + // 受け付けない文字列 (abc / 1,000 / 1..2 等) をここで弾かないと + // bcmul()/bcadd() が PHP 8 系で ValueError を送出してしまう。 + if ($amount === '' || !preg_match('/^(?:\d+|\d+\.\d*|\.\d+)$/', $amount)) { return 0; } diff --git a/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php index bb94f601745..43ad1179757 100644 --- a/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php +++ b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php @@ -56,11 +56,13 @@ public function write(string $purpose, string $pem): void $path = $this->resolvePath($purpose); $dir = \dirname($path); - if (!is_dir($dir)) { - mkdir($dir, 0700, true); + if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new \RuntimeException(sprintf('鍵格納ディレクトリ "%s" を作成できません.', $dir)); } - file_put_contents($path, $pem); + if (file_put_contents($path, $pem) === false) { + throw new \RuntimeException(sprintf('鍵ファイル "%s" への書き込みに失敗しました.', $path)); + } chmod($path, 0600); } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php index 5a68eb82751..ba98e0cbf15 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php @@ -170,6 +170,7 @@ private function loadCountryIdsFromCsv(): array $ids = []; $handle = fopen($csvPath, 'r'); + $this->assertIsResource($handle, 'mtb_country.csv must be readable'); fgetcsv($handle, null, ',', '"', ''); // skip header row while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { if (!isset($row[0]) || $row[0] === '') { diff --git a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php index 0ab55645e20..9bc18ad23c2 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php @@ -68,6 +68,25 @@ public function testToMinorUnitsRoundHalfUp(): void $this->assertSame(-1010, $this->converter->toMinorUnits('-10.095', 'USD'), 'Round-half-up on negative magnitude: -10.095 USD rounds to -1010 cents'); } + #[DataProvider(methodName: 'malformedAmountProvider')] + public function testToMinorUnitsMalformedReturnsZero(string $amount): void + { + // 仕様: 空や不正な表記は 0。BCMath に渡す前に弾き ValueError を起こさないこと。 + $this->assertSame(0, $this->converter->toMinorUnits($amount, 'USD'), sprintf('Malformed amount "%s" must convert to 0 without throwing', $amount)); + } + + public static function malformedAmountProvider(): \Iterator + { + yield 'empty' => ['']; + yield 'dot only' => ['.']; + yield 'sign only' => ['-']; + yield 'alpha' => ['abc']; + yield 'thousands separator' => ['1,000']; + yield 'double dot' => ['1..2']; + yield 'trailing garbage' => ['12.34x']; + yield 'whitespace inside' => ['1 000']; + } + public function testToAmountStringZeroDecimalJpy(): void { $this->assertSame('1000', $this->converter->toAmountString(1000, 'JPY'), 'JPY minor units map back to the same integer string with no decimal point'); From 2501c7c34b03cb63713d7b867f93e04168a21eeb Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 11 Jun 2026 16:03:20 +0900 Subject: [PATCH 10/28] =?UTF-8?q?refactor(agent-commerce):=20=E4=B8=8D?= =?UTF-8?q?=E6=AD=A3=E9=87=91=E9=A1=8D=E3=82=AC=E3=83=BC=E3=83=89=E3=82=92?= =?UTF-8?q?=E6=AD=A3=E8=A6=8F=E8=A1=A8=E7=8F=BE=E3=81=8B=E3=82=89=20ValueE?= =?UTF-8?q?rror=20=E6=8D=95=E6=8D=89=E3=81=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MinorUnitConverter::toMinorUnits の入力検証を、正規表現での形式チェックから bcmul/bcadd の ValueError 捕捉へ変更する。 - BCMath が「受け付ける数値文字列形式」の唯一の権威であり、正規表現で再実装すると 仕様の二重管理・他箇所への正規表現の拡散を招くため、判定を BCMath 自身へ委譲する - 空文字 '' / '.' は BCMath が 0 として扱うため明示チェックも不要になり簡素化 - is_numeric は指数表記 (1e3) を true と判定するが BCMath では ValueError になり 不適 (実証済み)。ValueError 捕捉なら指数表記・hex も正しく 0 に倒せる - 回帰テストに指数表記 (1e3 / 1.5e3) と hex (0x1A) ケースを追加 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AgentCommerce/MinorUnitConverter.php | 22 +++++++++---------- .../AgentCommerce/MinorUnitConverterTest.php | 3 +++ 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php index 5ed832b1c97..b95ad5677de 100644 --- a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php +++ b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php @@ -54,22 +54,22 @@ public function toMinorUnits(string $amount, string $currency): int $amount = substr($amount, 1); } - // 空や不正な表記は 0 とみなす (符号は分離済みなので非負の decimal 形式のみ許可)。 - // 受け付けない文字列 (abc / 1,000 / 1..2 等) をここで弾かないと - // bcmul()/bcadd() が PHP 8 系で ValueError を送出してしまう。 - if ($amount === '' || !preg_match('/^(?:\d+|\d+\.\d*|\.\d+)$/', $amount)) { - return 0; - } - // scale を桁数 + 1 にして、丸め用の補正を加える。 $scale = $digits + 1; $factor = bcpow('10', (string) $digits, 0); - // amount * 10^digits を計算 (まだ小数を含みうる)。 - $scaled = bcmul($amount, $factor, $scale); + try { + // amount * 10^digits を計算 (まだ小数を含みうる)。 + $scaled = bcmul($amount, $factor, $scale); - // round-half-up: 正の値に 0.5 を足してから切り捨て (bcmath は truncate)。 - $rounded = bcadd($scaled, '0.5', 0); + // round-half-up: 正の値に 0.5 を足してから切り捨て (bcmath は truncate)。 + $rounded = bcadd($scaled, '0.5', 0); + } catch (\ValueError) { + // 受け付ける数値文字列形式の判定は BCMath 自身に委譲する。 + // 不正な表記 (abc / 1,000 / 1e3 等) は ValueError になるため 0 とみなす。 + // (空文字 '' や '.' は BCMath が 0 として扱うのでここには来ない) + return 0; + } $result = (int) $rounded; diff --git a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php index 9bc18ad23c2..ddbd7e69c7b 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php @@ -85,6 +85,9 @@ public static function malformedAmountProvider(): \Iterator yield 'double dot' => ['1..2']; yield 'trailing garbage' => ['12.34x']; yield 'whitespace inside' => ['1 000']; + yield 'exponential notation' => ['1e3']; + yield 'exponential with decimal' => ['1.5e3']; + yield 'hex' => ['0x1A']; } public function testToAmountStringZeroDecimalJpy(): void From 192db990bafa780e92c350c4c4698dc46dfca2a8 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Fri, 12 Jun 2026 15:09:03 +0900 Subject: [PATCH 11/28] =?UTF-8?q?fix(agent-commerce):=20=E7=A7=98=E5=AF=86?= =?UTF-8?q?=E9=8D=B5=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF=E3=81=AE=20umask?= =?UTF-8?q?=20=E3=83=AC=E3=83=BC=E3=82=B9=E3=82=92=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FilesystemKeyStore::write が file_put_contents で umask 既定 (通常 0644) の パーミッションでファイルを作成し、その後 chmod(0600) するため、その間だけ 秘密鍵が group/other から読める瞬間が生じていた。書き込みの間だけ umask(0077) に切り替え、作成時点から 0600 になるようにする。あわせて chmod の戻り値を検査し失敗時に例外を投げる。 CodeRabbit レビュー指摘 (PR #6825 経由・本ファイルは #6802 由来) 対応。 Co-Authored-By: Claude Opus 4.8 --- .../Security/FilesystemKeyStore.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php index 43ad1179757..ad295a4f8ab 100644 --- a/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php +++ b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php @@ -60,10 +60,20 @@ public function write(string $purpose, string $pem): void throw new \RuntimeException(sprintf('鍵格納ディレクトリ "%s" を作成できません.', $dir)); } - if (file_put_contents($path, $pem) === false) { - throw new \RuntimeException(sprintf('鍵ファイル "%s" への書き込みに失敗しました.', $path)); + // file_put_contents は umask 既定 (通常 0644) でファイルを作成してから書き込むため、 + // chmod(0600) までの間に秘密鍵が group/other から読める瞬間が生じる。 + // 作成時点から 0600 になるよう、書き込みの間だけ umask(0077) に切り替える。 + $oldUmask = umask(0077); + try { + if (file_put_contents($path, $pem) === false) { + throw new \RuntimeException(sprintf('鍵ファイル "%s" への書き込みに失敗しました.', $path)); + } + if (!chmod($path, 0600)) { + throw new \RuntimeException(sprintf('鍵ファイル "%s" のパーミッション設定に失敗しました.', $path)); + } + } finally { + umask($oldUmask); } - chmod($path, 0600); } /** From c5c1d8f326c71e5bf0b1cc2a1758e0c3efdf6941 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 11 Jun 2026 17:03:05 +0900 Subject: [PATCH 12/28] =?UTF-8?q?feat(agent-commerce):=20CheckoutSession?= =?UTF-8?q?=20=E4=B8=AD=E6=A0=B8=E3=82=92=E5=AE=9F=E8=A3=85=20(#6777=20Pha?= =?UTF-8?q?se=201b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit エージェントコマース (ACP/UCP) checkout の前提となる、プロトコル非依存の CheckoutSession 中核を実装する。checkout エンドポイント本体は #6776/#6574 に委ね、 本コミットは再利用可能なエンティティ・サービス・認証部品とテストを提供する。 - CheckoutSession エンティティ + Repository を新規追加 (Cart をセッションレスに 束ねる上位層・status 正規化(canceled 含む)・json 列・Cart/Order/Customer 関連) - Order に agent_protocol / agent_id を NULL 許容で追加 (通常購入は両 NULL) - AgentCheckoutPurchaseFlowAdapter: 中立 DTO → Cart(永続) → OrderHelper → 既存 shopping flow 再利用で税・送料・在庫を再計算 (新規フローは作らない) - FulfillmentOptionMapper: 送料(DeliveryFee×Pref)・代引手数料(PaymentOption→ Payment::charge)・配送日数(DeliveryDuration の明細横断 max) を解決 - CustomerResolverInterface + GuestCustomerResolver (標準はゲスト=null。会員 ID 連携は eccube-api4#189 landing 後に差し替える seam) - AgentCommerceOAuth2Authenticator: Symfony 標準 AccessTokenHandlerInterface 経由で トークン検証 + scope×protocol 照合。eccube-api4 具象に依存せず、未導入時 503 - AgentCheckoutException + ErrorCode enum、AgentCheckoutPaymentHandlerInterface 共通基底 - カラム追加・新規テーブルは migration を書かず schema:update 方式 (既存規約に準拠) テスト: Layer 0 (仕様適合) / Layer 2 (Doctrine) / Layer 3 (PurchaseFlow 連携) / Layer 4a (OAuth2 ユニット・api4 不要)。AgentCommerce スイート 92 tests 0 失敗、 PHPStan level6 No errors。Layer 4b 統合は eccube-api4#188 landing 後。 Co-Authored-By: Claude Opus 4.8 --- app/config/eccube/services.yaml | 13 + app/config/eccube/services_test.yaml | 14 + src/Eccube/Entity/CheckoutSession.php | 381 ++++++++++++++++++ src/Eccube/Entity/Order.php | 40 ++ .../Repository/CheckoutSessionRepository.php | 66 +++ .../AgentCheckoutPurchaseFlowAdapter.php | 211 ++++++++++ .../CheckoutSession/AgentCheckoutAddress.php | 38 ++ .../CheckoutSession/AgentCheckoutLineItem.php | 29 ++ .../CheckoutSession/AgentCheckoutMessage.php | 26 ++ .../AgentCheckoutMessageLevel.php | 28 ++ .../CheckoutSession/AgentCheckoutRequest.php | 35 ++ .../CheckoutSession/AgentCheckoutResult.php | 56 +++ .../CustomerResolverInterface.php | 35 ++ .../CheckoutSession/GuestCustomerResolver.php | 30 ++ .../Exception/AgentCheckoutErrorCode.php | 45 +++ .../Exception/AgentCheckoutException.php | 35 ++ .../Fulfillment/FulfillmentOption.php | 37 ++ .../FulfillmentOptionMapperInterface.php | 34 ++ .../Fulfillment/FulfillmentPaymentOption.php | 30 ++ .../StandardFulfillmentOptionMapper.php | 152 +++++++ .../AgentCheckoutPaymentHandlerInterface.php | 45 +++ .../AgentCommerceOAuth2Authenticator.php | 102 +++++ .../Tests/Entity/CheckoutSessionTest.php | 160 ++++++++ .../AgentCheckoutPurchaseFlowAdapterTest.php | 178 ++++++++ .../AgentCheckoutCoreConformanceTest.php | 96 +++++ .../StandardFulfillmentOptionMapperTest.php | 145 +++++++ .../AgentCommerceOAuth2AuthenticatorTest.php | 136 +++++++ 27 files changed, 2197 insertions(+) create mode 100644 src/Eccube/Entity/CheckoutSession.php create mode 100644 src/Eccube/Repository/CheckoutSessionRepository.php create mode 100644 src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php create mode 100644 src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php create mode 100644 src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutLineItem.php create mode 100644 src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutMessage.php create mode 100644 src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutMessageLevel.php create mode 100644 src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php create mode 100644 src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php create mode 100644 src/Eccube/Service/AgentCommerce/CheckoutSession/CustomerResolverInterface.php create mode 100644 src/Eccube/Service/AgentCommerce/CheckoutSession/GuestCustomerResolver.php create mode 100644 src/Eccube/Service/AgentCommerce/Exception/AgentCheckoutErrorCode.php create mode 100644 src/Eccube/Service/AgentCommerce/Exception/AgentCheckoutException.php create mode 100644 src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOption.php create mode 100644 src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOptionMapperInterface.php create mode 100644 src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php create mode 100644 src/Eccube/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapper.php create mode 100644 src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerInterface.php create mode 100644 src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php create mode 100644 tests/Eccube/Tests/Entity/CheckoutSessionTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index c6c790ff9e7..4cbc1796ae1 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -250,3 +250,16 @@ services: Eccube\Service\AgentCommerce\Security\AgentCommerceMessageSignerInterface: alias: Eccube\Service\AgentCommerce\Security\UcpMessageSigner + + # Agent Commerce CheckoutSession 中核 (#6777 Phase 1b) + Eccube\Service\AgentCommerce\Fulfillment\FulfillmentOptionMapperInterface: + alias: Eccube\Service\AgentCommerce\Fulfillment\StandardFulfillmentOptionMapper + + # 標準はゲスト購入 (会員解決なし)。会員 ID 連携は eccube-api4#189 landing 後に差し替える。 + Eccube\Service\AgentCommerce\CheckoutSession\CustomerResolverInterface: + alias: Eccube\Service\AgentCommerce\CheckoutSession\GuestCustomerResolver + + # OAuth2 トークン検証。リソースサーバー (eccube-api4#188) 未導入時は handler が null となり 503 を返す。 + Eccube\Service\AgentCommerce\Security\AgentCommerceOAuth2Authenticator: + arguments: + $accessTokenHandler: '@?Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface' diff --git a/app/config/eccube/services_test.yaml b/app/config/eccube/services_test.yaml index 9e0513a6ba9..9a8c18be6b5 100644 --- a/app/config/eccube/services_test.yaml +++ b/app/config/eccube/services_test.yaml @@ -81,3 +81,17 @@ services: Eccube\Service\AgentCommerce\AddressMappingService: autowire: true public: true + # CheckoutSession 中核 (#6777 Phase 1b) も consumer (checkout controller) が #6776/#6574 まで + # 無いため、テスト時のみ public 化してコンテナから取得する。 + Eccube\Service\AgentCommerce\AgentCheckoutPurchaseFlowAdapter: + autowire: true + public: true + # 明示定義により _defaults の $shoppingPurchaseFlow bind が外れるため、ここで再束縛する。 + arguments: + $shoppingPurchaseFlow: '@eccube.purchase.flow.shopping' + Eccube\Service\AgentCommerce\Fulfillment\StandardFulfillmentOptionMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\CheckoutSession\GuestCustomerResolver: + autowire: true + public: true diff --git a/src/Eccube/Entity/CheckoutSession.php b/src/Eccube/Entity/CheckoutSession.php new file mode 100644 index 00000000000..f5ccebbf870 --- /dev/null +++ b/src/Eccube/Entity/CheckoutSession.php @@ -0,0 +1,381 @@ + true])] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * エージェントへ公開するセッション識別子 (推測困難・API パスで使用). + * + * 整数 PK は列挙可能なため、外部公開用にはこの不透明な識別子を用いる。 + */ + #[ORM\Column(name: 'session_id', type: Types::STRING, length: 255)] + private string $session_id = ''; + + /** + * プロトコル名 (`acp` / `ucp`). + */ + #[ORM\Column(name: 'protocol', type: Types::STRING, length: 255)] + private string $protocol = ''; + + /** + * セッションを開始したエージェントの識別子. + */ + #[ORM\Column(name: 'agent_id', type: Types::STRING, length: 255, nullable: true)] + private ?string $agent_id = null; + + /** + * 正規化ステータス (`incomplete`/`ready`/`completed`/`canceled`/`expired`). + */ + #[ORM\Column(name: 'status', type: Types::STRING, length: 255, options: ['default' => self::STATUS_INCOMPLETE])] + private string $status = self::STATUS_INCOMPLETE; + + /** + * 通貨コード (ISO 4217 alpha-3). + */ + #[ORM\Column(name: 'currency_code', type: Types::STRING, length: 3)] + private string $currency_code = 'JPY'; + + /** + * セッションの有効期限. + */ + #[ORM\Column(name: 'expires_at', type: Types::DATETIMETZ_MUTABLE, nullable: true)] + private ?\DateTime $expires_at = null; + + /** + * 購入者情報 (氏名・住所・連絡先等) の中立表現. + * + * @var array|null + */ + #[ORM\Column(name: 'buyer_data', type: Types::JSON, nullable: true)] + private ?array $buyer_data = null; + + /** + * 配送 (fulfillment) 情報の中立表現. + * + * @var array|null + */ + #[ORM\Column(name: 'fulfillment_data', type: Types::JSON, nullable: true)] + private ?array $fulfillment_data = null; + + /** + * 支払情報の中立表現 (token はマスキング/暗号化して保持). + * + * @var array|null + */ + #[ORM\Column(name: 'payment_data', type: Types::JSON, nullable: true)] + private ?array $payment_data = null; + + /** + * プロトコル固有のメタデータ (正規化ステータスに収まらない情報を保持). + * + * @var array|null + */ + #[ORM\Column(name: 'metadata', type: Types::JSON, nullable: true)] + private ?array $metadata = null; + + /** + * 明細を保持する Cart (セッションレスに束ねる対象). + */ + #[ORM\ManyToOne(targetEntity: Cart::class)] + #[ORM\JoinColumn(name: 'cart_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Cart $Cart = null; + + /** + * 確定時に生成される Order (完了前は null). + */ + #[ORM\ManyToOne(targetEntity: Order::class)] + #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Order $Order = null; + + /** + * 紐づく会員 (ゲスト購入では null). + */ + #[ORM\ManyToOne(targetEntity: Customer::class)] + #[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Customer $Customer = null; + + #[ORM\Column(name: 'create_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $create_date; + + #[ORM\Column(name: 'update_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $update_date; + + public function getId(): ?int + { + return $this->id; + } + + public function getSessionId(): string + { + return $this->session_id; + } + + public function setSessionId(string $session_id): CheckoutSession + { + $this->session_id = $session_id; + + return $this; + } + + public function getProtocol(): string + { + return $this->protocol; + } + + public function setProtocol(string $protocol): CheckoutSession + { + $this->protocol = $protocol; + + return $this; + } + + public function getAgentId(): ?string + { + return $this->agent_id; + } + + public function setAgentId(?string $agent_id = null): CheckoutSession + { + $this->agent_id = $agent_id; + + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setStatus(string $status): CheckoutSession + { + $this->status = $status; + + return $this; + } + + public function getCurrencyCode(): string + { + return $this->currency_code; + } + + public function setCurrencyCode(string $currency_code): CheckoutSession + { + $this->currency_code = $currency_code; + + return $this; + } + + public function getExpiresAt(): ?\DateTime + { + return $this->expires_at; + } + + public function setExpiresAt(?\DateTime $expires_at = null): CheckoutSession + { + $this->expires_at = $expires_at; + + return $this; + } + + /** + * @return array|null + */ + public function getBuyerData(): ?array + { + return $this->buyer_data; + } + + /** + * @param array|null $buyer_data + */ + public function setBuyerData(?array $buyer_data = null): CheckoutSession + { + $this->buyer_data = $buyer_data; + + return $this; + } + + /** + * @return array|null + */ + public function getFulfillmentData(): ?array + { + return $this->fulfillment_data; + } + + /** + * @param array|null $fulfillment_data + */ + public function setFulfillmentData(?array $fulfillment_data = null): CheckoutSession + { + $this->fulfillment_data = $fulfillment_data; + + return $this; + } + + /** + * @return array|null + */ + public function getPaymentData(): ?array + { + return $this->payment_data; + } + + /** + * @param array|null $payment_data + */ + public function setPaymentData(?array $payment_data = null): CheckoutSession + { + $this->payment_data = $payment_data; + + return $this; + } + + /** + * @return array|null + */ + public function getMetadata(): ?array + { + return $this->metadata; + } + + /** + * @param array|null $metadata + */ + public function setMetadata(?array $metadata = null): CheckoutSession + { + $this->metadata = $metadata; + + return $this; + } + + public function getCart(): ?Cart + { + return $this->Cart; + } + + public function setCart(?Cart $Cart = null): CheckoutSession + { + $this->Cart = $Cart; + + return $this; + } + + public function getOrder(): ?Order + { + return $this->Order; + } + + public function setOrder(?Order $Order = null): CheckoutSession + { + $this->Order = $Order; + + return $this; + } + + public function getCustomer(): ?Customer + { + return $this->Customer; + } + + public function setCustomer(?Customer $Customer = null): CheckoutSession + { + $this->Customer = $Customer; + + return $this; + } + + public function getCreateDate(): ?\DateTime + { + return $this->create_date ?? null; + } + + public function setCreateDate(\DateTime $create_date): CheckoutSession + { + $this->create_date = $create_date; + + return $this; + } + + public function getUpdateDate(): ?\DateTime + { + return $this->update_date ?? null; + } + + public function setUpdateDate(\DateTime $update_date): CheckoutSession + { + $this->update_date = $update_date; + + return $this; + } + + /** + * 有効期限を過ぎているか. + */ + public function isExpired(\DateTime $now): bool + { + return $this->expires_at !== null && $this->expires_at < $now; + } + } +} diff --git a/src/Eccube/Entity/Order.php b/src/Eccube/Entity/Order.php index c3bdabdd322..ea27549da76 100644 --- a/src/Eccube/Entity/Order.php +++ b/src/Eccube/Entity/Order.php @@ -474,6 +474,22 @@ public function getTotalPrice(): string #[ORM\Column(name: 'complete_mail_message', type: Types::TEXT, nullable: true)] private ?string $complete_mail_message = null; + /** + * エージェントコマース (ACP/UCP) 経由の注文を識別するプロトコル名. + * + * 通常購入では null。エージェント経由の注文でのみ `acp` / `ucp` 等がセットされる。 + */ + #[ORM\Column(name: 'agent_protocol', type: Types::STRING, length: 255, nullable: true)] + private ?string $agent_protocol = null; + + /** + * エージェントコマース経由の注文を発行したエージェントの識別子. + * + * 通常購入では null。 + */ + #[ORM\Column(name: 'agent_id', type: Types::STRING, length: 255, nullable: true)] + private ?string $agent_id = null; + /** * @var Collection */ @@ -1177,6 +1193,30 @@ public function appendCompleteMailMessage(?string $complete_mail_message = null) return $this; } + public function getAgentProtocol(): ?string + { + return $this->agent_protocol; + } + + public function setAgentProtocol(?string $agent_protocol = null): Order + { + $this->agent_protocol = $agent_protocol; + + return $this; + } + + public function getAgentId(): ?string + { + return $this->agent_id; + } + + public function setAgentId(?string $agent_id = null): Order + { + $this->agent_id = $agent_id; + + return $this; + } + /** * 商品の受注明細を取得 * diff --git a/src/Eccube/Repository/CheckoutSessionRepository.php b/src/Eccube/Repository/CheckoutSessionRepository.php new file mode 100644 index 00000000000..c89fdd26da4 --- /dev/null +++ b/src/Eccube/Repository/CheckoutSessionRepository.php @@ -0,0 +1,66 @@ + + */ +class CheckoutSessionRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CheckoutSession::class); + } + + /** + * エージェント公開用のセッション識別子で 1 件取得する. + */ + public function findOneBySessionId(string $sessionId): ?CheckoutSession + { + return $this->findOneBy(['session_id' => $sessionId]); + } + + /** + * 指定時刻より前に有効期限が切れた未完了セッションを取得する. + * + * クリーンアップコマンド (派生 issue) から利用する想定。 + * + * @return array + */ + public function findExpired(\DateTime $now): array + { + $qb = $this->createQueryBuilder('cs'); + + /** @var array $result */ + $result = $qb + ->where('cs.expires_at IS NOT NULL') + ->andWhere('cs.expires_at < :now') + ->andWhere($qb->expr()->notIn('cs.status', ':terminalStatuses')) + ->setParameter('now', $now) + ->setParameter('terminalStatuses', [ + CheckoutSession::STATUS_COMPLETED, + CheckoutSession::STATUS_CANCELED, + CheckoutSession::STATUS_EXPIRED, + ]) + ->getQuery() + ->getResult(); + + return $result; + } +} diff --git a/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php new file mode 100644 index 00000000000..55a0c144491 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php @@ -0,0 +1,211 @@ +lineItems === []) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, 'line items must not be empty'); + } + + $Customer = $member ?? $this->buildGuestCustomer($request->buyer); + + $Cart = $this->buildCart($request, $Customer); + + $Order = $this->orderHelper->createPurchaseProcessingOrder($Cart, $Customer); + $Cart->setPreOrderId($Order->getPreOrderId()); + + if ($request->protocol !== null) { + $Order->setAgentProtocol($request->protocol); + } + if ($request->agentId !== null) { + $Order->setAgentId($request->agentId); + } + + $this->entityManager->flush(); + + $messages = $this->runFlow(fn (PurchaseContext $context) => $this->shoppingPurchaseFlow->validate($Order, $context), $Order, $member); + $this->entityManager->flush(); + + return new AgentCheckoutResult($Order, $messages); + } + + /** + * 注文を確定する (在庫引当・受注番号採番・ポイント付与等). + * + * shopping flow の prepare → commit を実行する。ビジネス系エラーは messages[] に反映する。 + */ + public function complete(Order $Order, ?Customer $member = null): AgentCheckoutResult + { + $messages = $this->runFlow(function (PurchaseContext $context) use ($Order): PurchaseFlowResult { + $result = $this->shoppingPurchaseFlow->validate($Order, $context); + if (!$result->hasError()) { + $this->shoppingPurchaseFlow->prepare($Order, $context); + $this->shoppingPurchaseFlow->commit($Order, $context); + } + + return $result; + }, $Order, $member); + + $this->entityManager->flush(); + + return new AgentCheckoutResult($Order, $messages); + } + + /** + * PurchaseFlow を実行し、warning/error を中立メッセージへ写し取る. + * + * @param callable(PurchaseContext): PurchaseFlowResult $flow + * + * @return array + */ + private function runFlow(callable $flow, Order $Order, ?Customer $member): array + { + $context = new PurchaseContext(clone $Order, $member); + $result = $flow($context); + + $messages = []; + foreach ($result->getErrors() as $error) { + $messages[] = new AgentCheckoutMessage(AgentCheckoutMessageLevel::ERROR, (string) $error->getMessage()); + } + foreach ($result->getWarning() as $warning) { + $messages[] = new AgentCheckoutMessage(AgentCheckoutMessageLevel::WARNING, (string) $warning->getMessage()); + } + + return $messages; + } + + /** + * 中立明細から永続化された `Cart` を構築する. + */ + private function buildCart(AgentCheckoutRequest $request, Customer $Customer): Cart + { + $Cart = new Cart(); + // 合計は Order 側の shopping flow で再計算されるため、ここでは NOT NULL 制約を満たす初期値のみ設定する。 + $Cart->setTotalPrice('0'); + $Cart->setDeliveryFeeTotal('0'); + if ($Customer->getId()) { + $Cart->setCustomer($Customer); + } + + foreach ($request->lineItems as $lineItem) { + if ($lineItem->quantity < 1) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::INVALID_QUANTITY, 'quantity must be positive'); + } + + $ProductClass = $this->productClassRepository->find($lineItem->productClassId); + if ($ProductClass === null) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::PRODUCT_NOT_FOUND, sprintf('ProductClass #%d not found', $lineItem->productClassId)); + } + + $CartItem = new CartItem(); + $CartItem + ->setQuantity((string) $lineItem->quantity) + ->setPrice((string) $ProductClass->getPrice02IncTax()) + ->setProductClass($ProductClass); + $Cart->addCartItem($CartItem); + $CartItem->setCart($Cart); + } + + $this->entityManager->persist($Cart); + + return $Cart; + } + + /** + * 住所 DTO から transient (非永続) な会員オブジェクトを構築する (ゲスト購入用). + * + * `OrderHelper::createPurchaseProcessingOrder()` は `Customer` のプロパティを `Order`/`Shipping` + * へコピーするため、id を持たない transient な `Customer` を渡すことでゲストの住所を反映できる。 + */ + private function buildGuestCustomer(?AgentCheckoutAddress $address): Customer + { + if ($address === null) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::MISSING_ADDRESS, 'buyer address is required for guest checkout'); + } + + $Customer = new Customer(); + $Customer + ->setName01((string) $address->name01) + ->setName02((string) $address->name02) + ->setKana01($address->kana01) + ->setKana02($address->kana02) + ->setCompanyName($address->companyName) + ->setEmail($address->email) + ->setPhonenumber($address->phoneNumber) + ->setPostalcode($address->postalCode) + ->setAddr01($address->addr01) + ->setAddr02($address->addr02); + + if ($address->prefId !== null) { + $Pref = $this->prefRepository->find($address->prefId); + if ($Pref === null) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::INVALID_PREF, sprintf('Pref #%d not found', $address->prefId)); + } + $Customer->setPref($Pref); + } + + return $Customer; + } +} diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php new file mode 100644 index 00000000000..a4b81f446ea --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php @@ -0,0 +1,38 @@ + $lineItems + */ + public function __construct( + public readonly array $lineItems, + public readonly ?AgentCheckoutAddress $buyer = null, + public readonly string $currencyCode = 'JPY', + public readonly ?string $protocol = null, + public readonly ?string $agentId = null, + ) { + } +} diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php new file mode 100644 index 00000000000..797f583d7d9 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php @@ -0,0 +1,56 @@ + $messages + */ + public function __construct( + public readonly Order $order, + public readonly array $messages = [], + ) { + } + + public function hasError(): bool + { + foreach ($this->messages as $message) { + if ($message->level === AgentCheckoutMessageLevel::ERROR) { + return true; + } + } + + return false; + } + + public function hasWarning(): bool + { + foreach ($this->messages as $message) { + if ($message->level === AgentCheckoutMessageLevel::WARNING) { + return true; + } + } + + return false; + } +} diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/CustomerResolverInterface.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/CustomerResolverInterface.php new file mode 100644 index 00000000000..96f5cf6be9a --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/CustomerResolverInterface.php @@ -0,0 +1,35 @@ +value, 0, $previous); + } + + public function getErrorCode(): AgentCheckoutErrorCode + { + return $this->errorCode; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOption.php b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOption.php new file mode 100644 index 00000000000..695136c3ac5 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOption.php @@ -0,0 +1,37 @@ + $paymentOptions + */ + public function __construct( + public readonly int $deliveryId, + public readonly string $name, + public readonly int $shippingFeeMinor, + public readonly string $currencyCode, + public readonly ?int $estimatedDeliveryDays, + public readonly array $paymentOptions, + ) { + } +} diff --git a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOptionMapperInterface.php b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOptionMapperInterface.php new file mode 100644 index 00000000000..4809605c08a --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOptionMapperInterface.php @@ -0,0 +1,34 @@ + $productClasses 対象明細の商品規格 + * + * @return array + */ + public function mapForDestination(iterable $productClasses, Pref $pref, string $currencyCode = 'JPY'): array; +} diff --git a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php new file mode 100644 index 00000000000..962afd4398f --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php @@ -0,0 +1,30 @@ +extractSaleTypes($items); + if ($saleTypes === []) { + return []; + } + + $estimatedDeliveryDays = $this->resolveDeliveryDays($items); + + $options = []; + foreach ($this->deliveryRepository->getDeliveries($saleTypes) as $Delivery) { + $DeliveryFee = $this->deliveryFeeRepository->findOneBy([ + 'Delivery' => $Delivery, + 'Pref' => $pref, + ]); + if ($DeliveryFee === null) { + // 配送先に対する送料設定が無い = この配送方法では届けられない。 + continue; + } + + $options[] = new FulfillmentOption( + deliveryId: (int) $Delivery->getId(), + name: (string) $Delivery->getName(), + shippingFeeMinor: $this->minorUnitConverter->toMinorUnits($DeliveryFee->getFee() ?? '0', $currencyCode), + currencyCode: $currencyCode, + estimatedDeliveryDays: $estimatedDeliveryDays, + paymentOptions: $this->resolvePaymentOptions($Delivery, $currencyCode), + ); + } + + return $options; + } + + /** + * 明細から重複を除いた SaleType の一覧を取得する. + * + * @param array $items + * + * @return array + */ + private function extractSaleTypes(array $items): array + { + $saleTypes = []; + foreach ($items as $ProductClass) { + $SaleType = $ProductClass->getSaleType(); + if ($SaleType === null) { + continue; + } + $saleTypes[(int) $SaleType->getId()] = $SaleType; + } + + return array_values($saleTypes); + } + + /** + * 明細横断の配送日数 (最大値) を返す. + * + * お取り寄せ (duration < 0) が 1 件でもあれば未確定として null を返す。 + * 配送日数の設定が皆無の場合も null。 + * + * @param array $items + */ + private function resolveDeliveryDays(array $items): ?int + { + $maxDays = null; + foreach ($items as $ProductClass) { + $DeliveryDuration = $ProductClass->getDeliveryDuration(); + if ($DeliveryDuration === null) { + continue; + } + $duration = $DeliveryDuration->getDuration(); + if ($duration < 0) { + return null; + } + if ($maxDays === null || $duration > $maxDays) { + $maxDays = $duration; + } + } + + return $maxDays; + } + + /** + * 配送方法に紐づく (表示可能な) 支払方法の選択肢を返す. + * + * @return array + */ + private function resolvePaymentOptions(Delivery $Delivery, string $currencyCode): array + { + $paymentOptions = []; + foreach ($Delivery->getPaymentOptions() as $PaymentOption) { + $Payment = $PaymentOption->getPayment(); + if ($Payment === null || !$Payment->isVisible()) { + continue; + } + $paymentOptions[] = new FulfillmentPaymentOption( + paymentId: (int) $Payment->getId(), + method: (string) $Payment->getMethod(), + chargeMinor: $this->minorUnitConverter->toMinorUnits($Payment->getCharge() ?? '0', $currencyCode), + currencyCode: $currencyCode, + ); + } + + return $paymentOptions; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerInterface.php b/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerInterface.php new file mode 100644 index 00000000000..f65e1a00c2c --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerInterface.php @@ -0,0 +1,45 @@ + $paymentData 支払トークン等の中立表現 + */ + public function authorize(Order $order, array $paymentData): void; + + /** + * 売上確定 (キャプチャ) を行う. + * + * @param array $paymentData 支払トークン等の中立表現 + */ + public function capture(Order $order, array $paymentData): void; + + /** + * このハンドラが指定の支払方法 (Payment.method_class 等) を扱えるか. + */ + public function supports(Order $order): bool; +} diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php new file mode 100644 index 00000000000..602f9226f39 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php @@ -0,0 +1,102 @@ + もしくは `scope`: 空白区切り string)。 + * + * 本クラスは検証ロジックの中核であり、firewall への配線 (Symfony の access_token + * authenticator 化) は checkout エンドポイントを持つ #6776/#6574 で行う。 + */ +class AgentCommerceOAuth2Authenticator +{ + public function __construct( + private readonly AgentCommerceScopeRegistry $scopeRegistry, + private readonly ?AccessTokenHandlerInterface $accessTokenHandler = null, + ) { + } + + /** + * eccube-api4 (リソースサーバー) が利用可能か. + */ + public function isAvailable(): bool + { + return $this->accessTokenHandler !== null; + } + + /** + * アクセストークンを検証し、protocol/capability に必要な scope を持つか確認する. + * + * @return UserBadge 検証済みユーザーバッジ (subject 識別子 + attributes) + * + * @throws ServiceUnavailableHttpException eccube-api4 未導入 (503 相当) + * @throws \Symfony\Component\Security\Core\Exception\AuthenticationException トークン不正 (401 相当) + * @throws AccessDeniedException scope 不足・protocol 越境 (403 相当) + */ + public function authenticate(string $accessToken, string $protocol, string $capability): UserBadge + { + if ($this->accessTokenHandler === null) { + throw new ServiceUnavailableHttpException(null, 'OAuth2 resource server (eccube-api4) is not installed'); + } + + // 不正トークンは AuthenticationException (401 相当) が送出される。 + $userBadge = $this->accessTokenHandler->getUserBadgeFrom($accessToken); + + $grantedScopes = $this->extractScopes($userBadge); + if (!$this->scopeRegistry->supports($protocol, $capability, $grantedScopes)) { + throw new AccessDeniedException(sprintf('The token is not granted the required scope "%s:%s".', $protocol, $capability)); + } + + return $userBadge; + } + + /** + * UserBadge の attributes から付与 scope 一覧を取り出す. + * + * `scopes` (array) を優先し、無ければ `scope` (空白区切り string) を解釈する。 + * + * @return list + */ + private function extractScopes(UserBadge $userBadge): array + { + $attributes = $userBadge->getAttributes() ?? []; + + if (isset($attributes['scopes']) && is_array($attributes['scopes'])) { + return array_values(array_filter( + array_map(static fn ($scope): string => (string) $scope, $attributes['scopes']), + static fn (string $scope): bool => $scope !== '', + )); + } + + if (isset($attributes['scope']) && is_string($attributes['scope'])) { + return array_values(array_filter(explode(' ', $attributes['scope']), static fn (string $scope): bool => $scope !== '')); + } + + return []; + } +} diff --git a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php new file mode 100644 index 00000000000..adfb5846d56 --- /dev/null +++ b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php @@ -0,0 +1,160 @@ + ready -> completed / canceled / expired)。 + * - SaveEventSubscriber による create_date/update_date 自動付与。 + * - json カラム (buyer_data 等) のラウンドトリップ。 + * - 通常購入の Order で agent_protocol/agent_id が NULL である回帰確認。 + */ +final class CheckoutSessionTest extends EccubeTestCase +{ + private ?CheckoutSessionRepository $checkoutSessionRepository = null; + + protected function setUp(): void + { + parent::setUp(); + $this->checkoutSessionRepository = self::getContainer()->get(CheckoutSessionRepository::class); + } + + private function createSession(string $sessionId): CheckoutSession + { + $session = new CheckoutSession(); + $session + ->setSessionId($sessionId) + ->setProtocol(CheckoutSession::PROTOCOL_ACP) + ->setCurrencyCode('JPY') + ->setBuyerData(['family_name' => '山田', 'given_name' => '太郎']) + ->setMetadata(['acp_status' => 'not_ready_for_payment']); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + return $session; + } + + public function testPersistAssignsIdentifierAndTimestamps(): void + { + $session = $this->createSession('cs_test_persist'); + + self::assertNotNull($session->getId(), '永続化で id が採番される'); + self::assertNotNull($session->getCreateDate(), 'SaveEventSubscriber が create_date を自動付与する'); + self::assertNotNull($session->getUpdateDate(), 'SaveEventSubscriber が update_date を自動付与する'); + self::assertSame(CheckoutSession::STATUS_INCOMPLETE, $session->getStatus(), '初期 status は incomplete'); + } + + public function testFindOneBySessionId(): void + { + $this->createSession('cs_test_lookup'); + $this->entityManager->clear(); + + $found = $this->checkoutSessionRepository->findOneBySessionId('cs_test_lookup'); + + self::assertNotNull($found, 'session_id で取得できる'); + self::assertSame('cs_test_lookup', $found->getSessionId()); + } + + public function testJsonColumnsRoundTrip(): void + { + $session = $this->createSession('cs_test_json'); + $id = $session->getId(); + $this->entityManager->clear(); + + /** @var CheckoutSession $reloaded */ + $reloaded = $this->checkoutSessionRepository->find($id); + + self::assertSame(['family_name' => '山田', 'given_name' => '太郎'], $reloaded->getBuyerData(), 'json buyer_data がラウンドトリップする'); + self::assertSame(['acp_status' => 'not_ready_for_payment'], $reloaded->getMetadata(), 'json metadata がラウンドトリップする'); + self::assertNull($reloaded->getFulfillmentData(), '未設定の json カラムは null'); + } + + public function testStatusTransitions(): void + { + $session = $this->createSession('cs_test_status'); + + foreach ([ + CheckoutSession::STATUS_READY, + CheckoutSession::STATUS_COMPLETED, + ] as $next) { + $session->setStatus($next); + $this->entityManager->flush(); + self::assertSame($next, $session->getStatus()); + } + + // canceled / expired も正規化ステータスとして保持できる。 + $session->setStatus(CheckoutSession::STATUS_CANCELED); + $this->entityManager->flush(); + self::assertSame('canceled', $session->getStatus(), 'canceled を正規化ステータスとして保持できる'); + } + + public function testIsExpired(): void + { + $session = $this->createSession('cs_test_expire'); + $now = new \DateTime('2026-06-11 12:00:00'); + + self::assertFalse($session->isExpired($now), 'expires_at 未設定なら期限切れにならない'); + + $session->setExpiresAt(new \DateTime('2026-06-11 11:59:59')); + self::assertTrue($session->isExpired($now), 'expires_at を過ぎていれば期限切れ'); + + $session->setExpiresAt(new \DateTime('2026-06-11 12:00:01')); + self::assertFalse($session->isExpired($now), 'expires_at が未来なら期限切れでない'); + } + + public function testFindExpiredExcludesTerminalStatuses(): void + { + $now = new \DateTime('2026-06-11 12:00:00'); + $past = new \DateTime('2026-06-11 11:00:00'); + + $active = $this->createSession('cs_expired_active'); + $active->setExpiresAt($past); + + $completed = $this->createSession('cs_expired_completed'); + $completed->setExpiresAt($past)->setStatus(CheckoutSession::STATUS_COMPLETED); + $this->entityManager->flush(); + + $expired = $this->checkoutSessionRepository->findExpired($now); + $sessionIds = array_map(static fn (CheckoutSession $s): string => $s->getSessionId(), $expired); + + self::assertContains('cs_expired_active', $sessionIds, '期限切れの未完了セッションは対象'); + self::assertNotContains('cs_expired_completed', $sessionIds, '完了済セッションはクリーンアップ対象外'); + } + + public function testNormalOrderHasNullAgentColumns(): void + { + // Order に追加した agent_protocol/agent_id が、通常購入相当の Order で NULL であることの回帰確認。 + $order = new Order(); + + self::assertNull($order->getAgentProtocol(), '通常購入では agent_protocol は NULL'); + self::assertNull($order->getAgentId(), '通常購入では agent_id は NULL'); + } + + public function testOrderAgentColumnsAreSettable(): void + { + $order = new Order(); + $order->setAgentProtocol(CheckoutSession::PROTOCOL_ACP)->setAgentId('agent-123'); + + self::assertSame('acp', $order->getAgentProtocol()); + self::assertSame('agent-123', $order->getAgentId()); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php new file mode 100644 index 00000000000..65b69632cb3 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php @@ -0,0 +1,178 @@ + Cart(永続) -> Order -> shopping flow 再計算 -> 結果 の経路を、 + * fixtures を読み込んだ DB 上で検証する。ゲスト購入を基準線とし、税・送料・手数料が + * 既存 shopping flow で計算されること、ビジネス系結果 (在庫超過) が例外でなく + * messages[] に反映されること、プロトコル系エラーが例外になることを確認する。 + */ +final class AgentCheckoutPurchaseFlowAdapterTest extends EccubeTestCase +{ + private ?AgentCheckoutPurchaseFlowAdapter $adapter = null; + + protected function setUp(): void + { + parent::setUp(); + $this->adapter = self::getContainer()->get(AgentCheckoutPurchaseFlowAdapter::class); + } + + private function guestAddress(): AgentCheckoutAddress + { + return new AgentCheckoutAddress( + name01: '山田', + name02: '太郎', + kana01: 'ヤマダ', + kana02: 'タロウ', + postalCode: '5300001', + prefId: 27, // 大阪府 + addr01: '大阪市北区', + addr02: '梅田1-1-1', + email: 'agent-guest@example.com', + phoneNumber: '0612345678', + ); + } + + private function createPurchasableProductClass(string $stock = '100'): ProductClass + { + $Product = $this->createProduct('エージェント注文テスト商品', 1); + /** @var ProductClass $ProductClass */ + $ProductClass = $Product->getProductClasses()[0]; + $ProductClass->setStock($stock); + $ProductClass->setStockUnlimited(false); + $this->entityManager->flush(); + + return $ProductClass; + } + + public function testBuildOrderComputesTotalsForGuest(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 2)], + buyer: $this->guestAddress(), + currencyCode: 'JPY', + protocol: 'acp', + agentId: 'agent-xyz', + ); + + $result = $this->adapter->buildOrder($request); + $Order = $result->order; + + self::assertFalse($result->hasError(), '在庫十分なゲスト注文ではエラーメッセージは出ない'); + self::assertSame('acp', $Order->getAgentProtocol(), 'agent_protocol が Order に刻まれる'); + self::assertSame('agent-xyz', $Order->getAgentId(), 'agent_id が Order に刻まれる'); + self::assertNull($Order->getCustomer(), 'ゲスト購入では Order.Customer は null'); + self::assertSame('山田', $Order->getName01(), 'buyer 住所が Order にコピーされる'); + + // 明細が DTO 通り (単価 = price02 / 数量 = 2) に構築されていること。 + $productItems = $Order->getProductOrderItems(); + self::assertCount(1, $productItems, '商品明細は 1 行'); + self::assertSame(2, (int) $productItems[0]->getQuantity(), '数量が DTO の通り反映される'); + self::assertSame(0, bccomp((string) $productItems[0]->getPrice(), (string) $ProductClass->getPrice02(), 2), '明細単価は price02'); + // shopping flow が税・送料・手数料込みの支払総額を計算していること。 + self::assertGreaterThan(0, (int) $Order->getPaymentTotal(), 'shopping flow で支払総額 (税送料込) が計算される'); + } + + public function testCompleteFinalizesOrder(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 1)], + buyer: $this->guestAddress(), + protocol: 'acp', + ); + + $build = $this->adapter->buildOrder($request); + $result = $this->adapter->complete($build->order); + + self::assertFalse($result->hasError(), 'complete でエラーが出ない'); + self::assertNotNull($result->order->getOrderNo(), 'complete で受注番号が採番される'); + } + + public function testOverStockSurfacesAsMessageNotException(): void + { + $ProductClass = $this->createPurchasableProductClass('1'); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 5)], + buyer: $this->guestAddress(), + protocol: 'acp', + ); + + // 在庫超過は例外でなく messages[] (ビジネス系) として返る。 + $result = $this->adapter->buildOrder($request); + + self::assertNotEmpty($result->messages, '在庫超過は messages[] に反映される (HTTP 200 + messages[] 系統)'); + } + + public function testEmptyLineItemsThrows(): void + { + $request = new AgentCheckoutRequest(lineItems: [], buyer: $this->guestAddress()); + + try { + $this->adapter->buildOrder($request); + self::fail('空明細は AgentCheckoutException を投げる'); + } catch (AgentCheckoutException $e) { + self::assertSame(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, $e->getErrorCode()); + } + } + + public function testUnknownProductThrows(): void + { + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem(999999999, 1)], + buyer: $this->guestAddress(), + ); + + try { + $this->adapter->buildOrder($request); + self::fail('未知の商品参照は AgentCheckoutException を投げる'); + } catch (AgentCheckoutException $e) { + self::assertSame(AgentCheckoutErrorCode::PRODUCT_NOT_FOUND, $e->getErrorCode()); + } + } + + public function testGuestWithoutAddressThrows(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 1)], + buyer: null, + ); + + try { + $this->adapter->buildOrder($request); + self::fail('ゲストで住所が無い場合は AgentCheckoutException を投げる'); + } catch (AgentCheckoutException $e) { + self::assertSame(AgentCheckoutErrorCode::MISSING_ADDRESS, $e->getErrorCode()); + } + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php new file mode 100644 index 00000000000..6fa5289ef11 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php @@ -0,0 +1,96 @@ + $l->value, AgentCheckoutMessageLevel::cases()); + + self::assertEqualsCanonicalizing(['error', 'warning', 'info'], $levels, 'MUST: ビジネス系メッセージは error/warning/info の 3 段を持つ'); + } + + /** + * エージェント経由の注文は Order に protocol/agent 識別子を保持でき、通常購入では NULL である. + */ + public function testOrderCarriesAgentAttributionAndDefaultsNull(): void + { + $normal = new Order(); + self::assertNull($normal->getAgentProtocol(), 'MUST: 通常購入の Order は agent_protocol が NULL'); + self::assertNull($normal->getAgentId(), 'MUST: 通常購入の Order は agent_id が NULL'); + + $agentOrder = new Order(); + $agentOrder->setAgentProtocol(CheckoutSession::PROTOCOL_ACP)->setAgentId('agent-1'); + self::assertSame('acp', $agentOrder->getAgentProtocol(), 'エージェント注文は protocol を保持できる'); + } + + /** + * プロトコル系 (HTTP 4xx/5xx) とビジネス系 (HTTP 200 + messages[]) の 2 系統への + * 実際の HTTP 変換は checkout controller (#6776/#6574) の責務であり中核の対象外. + */ + public function testTwoTierHttpMappingIsDeferredToControllerLayer(): void + { + self::markTestIncomplete('HTTP ステータスと messages[] への 2 系統変換は ACP/UCP checkout controller (#6776/#6574) で検証する。'); + } + + /** + * リプレイ (Idempotency-Key) で副作用を再実行しない不変条件は、controller/middleware の + * 冪等性処理に依存するため中核では未実装. + */ + public function testIdempotentReplayIsDeferredToControllerLayer(): void + { + self::markTestIncomplete('Idempotency-Key によるリプレイ抑止は checkout controller (#6776/#6574) で検証する。'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php new file mode 100644 index 00000000000..cc28c4f8732 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php @@ -0,0 +1,145 @@ + Payment::getCharge())・配送日数 (DeliveryDuration の + * 明細横断 max) が正しく解決されることを検証する。金額は minor unit (JPY=0桁) で確認する。 + */ +final class StandardFulfillmentOptionMapperTest extends EccubeTestCase +{ + private ?StandardFulfillmentOptionMapper $mapper = null; + + private ?PrefRepository $prefRepository = null; + + private ?PaymentRepository $paymentRepository = null; + + protected function setUp(): void + { + parent::setUp(); + $this->mapper = self::getContainer()->get(StandardFulfillmentOptionMapper::class); + $this->prefRepository = self::getContainer()->get(PrefRepository::class); + $this->paymentRepository = self::getContainer()->get(PaymentRepository::class); + } + + private function pref(int $id): Pref + { + /** @var Pref $pref */ + $pref = $this->prefRepository->find($id); + + return $pref; + } + + private function firstProductClass(string $name): ProductClass + { + $Product = $this->createProduct($name, 1); + + /** @var ProductClass $ProductClass */ + $ProductClass = $Product->getProductClasses()[0]; + + return $ProductClass; + } + + public function testReturnsOptionsWithShippingFeeAndPaymentOptions(): void + { + $ProductClass = $this->firstProductClass('配送テスト商品'); + + $options = $this->mapper->mapForDestination([$ProductClass], $this->pref(27), 'JPY'); + + self::assertNotEmpty($options, '利用可能な配送方法が 1 件以上返る'); + $option = $options[0]; + self::assertGreaterThan(0, $option->deliveryId, 'deliveryId が解決される'); + self::assertNotSame('', $option->name, '配送方法名が解決される'); + self::assertGreaterThanOrEqual(0, $option->shippingFeeMinor, '送料が minor unit (整数) で解決される'); + self::assertSame('JPY', $option->currencyCode); + self::assertIsArray($option->paymentOptions, '支払方法の選択肢が配列で返る'); + } + + public function testCodChargeResolvedViaPaymentOption(): void + { + // 代金引換 (id=4) に手数料を設定し、PaymentOption 経由で minor unit に解決されることを確認。 + /** @var Payment $cod */ + $cod = $this->paymentRepository->find(4); + $cod->setCharge('330'); + $this->entityManager->flush(); + + $ProductClass = $this->firstProductClass('代引テスト商品'); + $options = $this->mapper->mapForDestination([$ProductClass], $this->pref(13), 'JPY'); + + $codChargeMinor = null; + foreach ($options as $option) { + foreach ($option->paymentOptions as $paymentOption) { + if ($paymentOption->paymentId === 4) { + $codChargeMinor = $paymentOption->chargeMinor; + break 2; + } + } + } + + self::assertNotNull($codChargeMinor, '代金引換が支払選択肢に含まれる'); + self::assertSame(330, $codChargeMinor, '代引手数料が Payment::getCharge() から minor unit (JPY=330) で解決される'); + } + + public function testDeliveryDaysIsMaxAcrossItems(): void + { + $pc2days = $this->firstProductClass('配送2日商品'); + $pc2days->setDeliveryDuration($this->createDuration('お届け2日', 2)); + $pc5days = $this->firstProductClass('配送5日商品'); + $pc5days->setDeliveryDuration($this->createDuration('お届け5日', 5)); + $this->entityManager->flush(); + + $options = $this->mapper->mapForDestination([$pc2days, $pc5days], $this->pref(27), 'JPY'); + + self::assertNotEmpty($options); + self::assertSame(5, $options[0]->estimatedDeliveryDays, '配送日数は明細横断の最大値 (2 と 5 -> 5)'); + } + + public function testBackorderItemYieldsNullDeliveryDays(): void + { + $pcNormal = $this->firstProductClass('通常配送商品'); + $pcNormal->setDeliveryDuration($this->createDuration('お届け3日', 3)); + $pcBackorder = $this->firstProductClass('お取り寄せ商品'); + $pcBackorder->setDeliveryDuration($this->createDuration('お取り寄せ', -1)); + $this->entityManager->flush(); + + $options = $this->mapper->mapForDestination([$pcNormal, $pcBackorder], $this->pref(27), 'JPY'); + + self::assertNotEmpty($options); + self::assertNull($options[0]->estimatedDeliveryDays, 'お取り寄せ (duration<0) が含まれると配送日数は未確定 (null)'); + } + + private function createDuration(string $name, int $duration): DeliveryDuration + { + $DeliveryDuration = new DeliveryDuration(); + $DeliveryDuration->setName($name)->setDuration($duration)->setSortNo($duration + 100); + $this->entityManager->persist($DeliveryDuration); + $this->entityManager->flush(); + + return $DeliveryDuration; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php new file mode 100644 index 00000000000..5dfa73d1c74 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php @@ -0,0 +1,136 @@ +scopeRegistry = new AgentCommerceScopeRegistry(); + } + + /** + * 付与 scope を attributes に載せて UserBadge を返すスタブ handler. + * + * @param array $scopes + */ + private function handlerWithScopes(array $scopes): AccessTokenHandlerInterface + { + return new class($scopes) implements AccessTokenHandlerInterface { + /** @param array $scopes */ + public function __construct(private readonly array $scopes) + { + } + + public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge + { + return new UserBadge('agent-platform', null, ['scopes' => $this->scopes]); + } + }; + } + + /** + * 不正トークンで AuthenticationException を投げるスタブ handler. + */ + private function handlerRejectingToken(): AccessTokenHandlerInterface + { + return new class implements AccessTokenHandlerInterface { + public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge + { + throw new BadCredentialsException('Invalid token.'); + } + }; + } + + public function testValidTokenWithMatchingScopeReturnsUserBadge(): void + { + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $this->handlerWithScopes(['acp:checkout'])); + + $badge = $authenticator->authenticate('valid-token', 'acp', 'checkout'); + + self::assertSame('agent-platform', $badge->getUserIdentifier(), 'valid token + matching scope は UserBadge を返す'); + } + + public function testSpaceDelimitedScopeAttributeIsAccepted(): void + { + $handler = new class implements AccessTokenHandlerInterface { + public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge + { + return new UserBadge('agent-platform', null, ['scope' => 'ucp:catalog ucp:checkout']); + } + }; + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $handler); + + $badge = $authenticator->authenticate('valid-token', 'ucp', 'checkout'); + + self::assertSame('agent-platform', $badge->getUserIdentifier(), 'OAuth2 標準の空白区切り scope 文字列も解釈できる'); + } + + public function testInvalidTokenThrowsAuthenticationException(): void + { + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $this->handlerRejectingToken()); + + $this->expectException(BadCredentialsException::class); + $authenticator->authenticate('invalid-token', 'acp', 'checkout'); + } + + public function testMissingScopeThrowsAccessDenied(): void + { + // catalog scope しか持たないトークンで checkout を要求 -> 403。 + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $this->handlerWithScopes(['acp:catalog'])); + + $this->expectException(AccessDeniedException::class); + $authenticator->authenticate('valid-token', 'acp', 'checkout'); + } + + public function testProtocolCrossoverIsDenied(): void + { + // ucp:checkout を持つトークンで acp:checkout を要求 -> protocol 越境で 403。 + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $this->handlerWithScopes(['ucp:checkout'])); + + $this->expectException(AccessDeniedException::class); + $authenticator->authenticate('valid-token', 'acp', 'checkout'); + } + + public function testHandlerUnavailableThrowsServiceUnavailable(): void + { + // eccube-api4 未導入 = handler が null -> 503。 + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, null); + + self::assertFalse($authenticator->isAvailable(), 'handler 未注入時は isAvailable() が false'); + + $this->expectException(ServiceUnavailableHttpException::class); + $authenticator->authenticate('any-token', 'acp', 'checkout'); + } +} From eda74fe02c89598ba4562aa5450252c53298540a Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 11 Jun 2026 17:12:20 +0900 Subject: [PATCH 13/28] =?UTF-8?q?style(agent-commerce):=20rector=20?= =?UTF-8?q?=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C=20(readonly=20class=20/=20?= =?UTF-8?q?$this=20assert=20/=20null=20=E5=BC=95=E6=95=B0=E9=99=A4?= =?UTF-8?q?=E5=8E=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI の rector ジョブ指摘を反映: - DTO/値オブジェクトを readonly class 化 (ReadOnlyAnonymousClassRector ほか) - テストの self::assert* を $this->assert* に統一 (PreferPHPUnitThisCallRector) - null デフォルト引数への明示 null 渡しを除去 (RemoveNullArgOnNullDefaultParamRector) PHPStan level6 No errors / AgentCommerce 92 tests 0 失敗を維持。 Co-Authored-By: Claude Opus 4.8 --- .../CheckoutSession/AgentCheckoutAddress.php | 24 +++++------ .../CheckoutSession/AgentCheckoutLineItem.php | 6 +-- .../CheckoutSession/AgentCheckoutMessage.php | 6 +-- .../CheckoutSession/AgentCheckoutRequest.php | 12 +++--- .../CheckoutSession/AgentCheckoutResult.php | 6 +-- .../Fulfillment/FulfillmentOption.php | 14 +++---- .../Fulfillment/FulfillmentPaymentOption.php | 10 ++--- .../AgentCommerceOAuth2Authenticator.php | 3 +- .../Tests/Entity/CheckoutSessionTest.php | 40 +++++++++---------- .../AgentCheckoutPurchaseFlowAdapterTest.php | 32 +++++++-------- .../AgentCheckoutCoreConformanceTest.php | 12 +++--- .../StandardFulfillmentOptionMapperTest.php | 24 +++++------ .../AgentCommerceOAuth2AuthenticatorTest.php | 12 +++--- 13 files changed, 101 insertions(+), 100 deletions(-) diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php index a4b81f446ea..584e877bbcb 100644 --- a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php @@ -19,20 +19,20 @@ * `prefId` は EC-CUBE の `mtb_pref.id` (1-47)。プロトコル固有 Mapper が * 共通基盤の `AddressMappingService` を用いて region 名等から解決する。 */ -final class AgentCheckoutAddress +final readonly class AgentCheckoutAddress { public function __construct( - public readonly ?string $name01 = null, - public readonly ?string $name02 = null, - public readonly ?string $kana01 = null, - public readonly ?string $kana02 = null, - public readonly ?string $companyName = null, - public readonly ?string $postalCode = null, - public readonly ?int $prefId = null, - public readonly ?string $addr01 = null, - public readonly ?string $addr02 = null, - public readonly ?string $email = null, - public readonly ?string $phoneNumber = null, + public ?string $name01 = null, + public ?string $name02 = null, + public ?string $kana01 = null, + public ?string $kana02 = null, + public ?string $companyName = null, + public ?string $postalCode = null, + public ?int $prefId = null, + public ?string $addr01 = null, + public ?string $addr02 = null, + public ?string $email = null, + public ?string $phoneNumber = null, ) { } } diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutLineItem.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutLineItem.php index ae0e5efc631..16dfa3ab7c2 100644 --- a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutLineItem.php +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutLineItem.php @@ -19,11 +19,11 @@ * プロトコル固有 Mapper (#6776 ACP / #6574 UCP) が、各プロトコルの variant 識別子を * EC-CUBE の `ProductClass.id` へ解決した上で本 DTO を組み立てる。 */ -final class AgentCheckoutLineItem +final readonly class AgentCheckoutLineItem { public function __construct( - public readonly int $productClassId, - public readonly int $quantity, + public int $productClassId, + public int $quantity, ) { } } diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutMessage.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutMessage.php index 53184939573..5cdefb4302c 100644 --- a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutMessage.php +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutMessage.php @@ -16,11 +16,11 @@ /** * ビジネスロジックの結果メッセージ (中立表現). */ -final class AgentCheckoutMessage +final readonly class AgentCheckoutMessage { public function __construct( - public readonly AgentCheckoutMessageLevel $level, - public readonly string $message, + public AgentCheckoutMessageLevel $level, + public string $message, ) { } } diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php index 8f6087929c2..bf1e63819cc 100644 --- a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php @@ -19,17 +19,17 @@ * 明細と購入者/配送先住所を保持する。`AgentCheckoutPurchaseFlowAdapter` がこれを * EC-CUBE の `Cart` → `Order` へ変換し、shopping flow で税・送料・在庫を再計算する。 */ -final class AgentCheckoutRequest +final readonly class AgentCheckoutRequest { /** * @param array $lineItems */ public function __construct( - public readonly array $lineItems, - public readonly ?AgentCheckoutAddress $buyer = null, - public readonly string $currencyCode = 'JPY', - public readonly ?string $protocol = null, - public readonly ?string $agentId = null, + public array $lineItems, + public ?AgentCheckoutAddress $buyer = null, + public string $currencyCode = 'JPY', + public ?string $protocol = null, + public ?string $agentId = null, ) { } } diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php index 797f583d7d9..040b81e2a6e 100644 --- a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php @@ -21,14 +21,14 @@ * 再計算後の `Order` (税・送料・手数料・合計が確定済) と、ビジネス系メッセージ * (在庫不足・販売停止・配送制限等) を保持する。 */ -final class AgentCheckoutResult +final readonly class AgentCheckoutResult { /** * @param array $messages */ public function __construct( - public readonly Order $order, - public readonly array $messages = [], + public Order $order, + public array $messages = [], ) { } diff --git a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOption.php b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOption.php index 695136c3ac5..386f926a795 100644 --- a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOption.php +++ b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOption.php @@ -20,18 +20,18 @@ * 配送日数 (`estimatedDeliveryDays`)、利用可能な支払方法 (`paymentOptions`) を保持する。 * 金額はすべて minor unit (整数) で表現する。 */ -final class FulfillmentOption +final readonly class FulfillmentOption { /** * @param array $paymentOptions */ public function __construct( - public readonly int $deliveryId, - public readonly string $name, - public readonly int $shippingFeeMinor, - public readonly string $currencyCode, - public readonly ?int $estimatedDeliveryDays, - public readonly array $paymentOptions, + public int $deliveryId, + public string $name, + public int $shippingFeeMinor, + public string $currencyCode, + public ?int $estimatedDeliveryDays, + public array $paymentOptions, ) { } } diff --git a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php index 962afd4398f..3fd7b9bebcb 100644 --- a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php +++ b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php @@ -18,13 +18,13 @@ * * 代引手数料等は `chargeMinor` に minor unit (整数) で保持する。 */ -final class FulfillmentPaymentOption +final readonly class FulfillmentPaymentOption { public function __construct( - public readonly int $paymentId, - public readonly string $method, - public readonly int $chargeMinor, - public readonly string $currencyCode, + public int $paymentId, + public string $method, + public int $chargeMinor, + public string $currencyCode, ) { } } diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php index 602f9226f39..c5c404026c0 100644 --- a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php @@ -15,6 +15,7 @@ use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\Security\Core\Exception\AccessDeniedException; +use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface; use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; @@ -55,7 +56,7 @@ public function isAvailable(): bool * @return UserBadge 検証済みユーザーバッジ (subject 識別子 + attributes) * * @throws ServiceUnavailableHttpException eccube-api4 未導入 (503 相当) - * @throws \Symfony\Component\Security\Core\Exception\AuthenticationException トークン不正 (401 相当) + * @throws AuthenticationException トークン不正 (401 相当) * @throws AccessDeniedException scope 不足・protocol 越境 (403 相当) */ public function authenticate(string $accessToken, string $protocol, string $capability): UserBadge diff --git a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php index adfb5846d56..e951b071624 100644 --- a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php +++ b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php @@ -57,10 +57,10 @@ public function testPersistAssignsIdentifierAndTimestamps(): void { $session = $this->createSession('cs_test_persist'); - self::assertNotNull($session->getId(), '永続化で id が採番される'); - self::assertNotNull($session->getCreateDate(), 'SaveEventSubscriber が create_date を自動付与する'); - self::assertNotNull($session->getUpdateDate(), 'SaveEventSubscriber が update_date を自動付与する'); - self::assertSame(CheckoutSession::STATUS_INCOMPLETE, $session->getStatus(), '初期 status は incomplete'); + $this->assertNotNull($session->getId(), '永続化で id が採番される'); + $this->assertInstanceOf(\DateTime::class, $session->getCreateDate(), 'SaveEventSubscriber が create_date を自動付与する'); + $this->assertInstanceOf(\DateTime::class, $session->getUpdateDate(), 'SaveEventSubscriber が update_date を自動付与する'); + $this->assertSame(CheckoutSession::STATUS_INCOMPLETE, $session->getStatus(), '初期 status は incomplete'); } public function testFindOneBySessionId(): void @@ -70,8 +70,8 @@ public function testFindOneBySessionId(): void $found = $this->checkoutSessionRepository->findOneBySessionId('cs_test_lookup'); - self::assertNotNull($found, 'session_id で取得できる'); - self::assertSame('cs_test_lookup', $found->getSessionId()); + $this->assertInstanceOf(CheckoutSession::class, $found, 'session_id で取得できる'); + $this->assertSame('cs_test_lookup', $found->getSessionId()); } public function testJsonColumnsRoundTrip(): void @@ -83,9 +83,9 @@ public function testJsonColumnsRoundTrip(): void /** @var CheckoutSession $reloaded */ $reloaded = $this->checkoutSessionRepository->find($id); - self::assertSame(['family_name' => '山田', 'given_name' => '太郎'], $reloaded->getBuyerData(), 'json buyer_data がラウンドトリップする'); - self::assertSame(['acp_status' => 'not_ready_for_payment'], $reloaded->getMetadata(), 'json metadata がラウンドトリップする'); - self::assertNull($reloaded->getFulfillmentData(), '未設定の json カラムは null'); + $this->assertSame(['family_name' => '山田', 'given_name' => '太郎'], $reloaded->getBuyerData(), 'json buyer_data がラウンドトリップする'); + $this->assertSame(['acp_status' => 'not_ready_for_payment'], $reloaded->getMetadata(), 'json metadata がラウンドトリップする'); + $this->assertNull($reloaded->getFulfillmentData(), '未設定の json カラムは null'); } public function testStatusTransitions(): void @@ -98,13 +98,13 @@ public function testStatusTransitions(): void ] as $next) { $session->setStatus($next); $this->entityManager->flush(); - self::assertSame($next, $session->getStatus()); + $this->assertSame($next, $session->getStatus()); } // canceled / expired も正規化ステータスとして保持できる。 $session->setStatus(CheckoutSession::STATUS_CANCELED); $this->entityManager->flush(); - self::assertSame('canceled', $session->getStatus(), 'canceled を正規化ステータスとして保持できる'); + $this->assertSame('canceled', $session->getStatus(), 'canceled を正規化ステータスとして保持できる'); } public function testIsExpired(): void @@ -112,13 +112,13 @@ public function testIsExpired(): void $session = $this->createSession('cs_test_expire'); $now = new \DateTime('2026-06-11 12:00:00'); - self::assertFalse($session->isExpired($now), 'expires_at 未設定なら期限切れにならない'); + $this->assertFalse($session->isExpired($now), 'expires_at 未設定なら期限切れにならない'); $session->setExpiresAt(new \DateTime('2026-06-11 11:59:59')); - self::assertTrue($session->isExpired($now), 'expires_at を過ぎていれば期限切れ'); + $this->assertTrue($session->isExpired($now), 'expires_at を過ぎていれば期限切れ'); $session->setExpiresAt(new \DateTime('2026-06-11 12:00:01')); - self::assertFalse($session->isExpired($now), 'expires_at が未来なら期限切れでない'); + $this->assertFalse($session->isExpired($now), 'expires_at が未来なら期限切れでない'); } public function testFindExpiredExcludesTerminalStatuses(): void @@ -136,8 +136,8 @@ public function testFindExpiredExcludesTerminalStatuses(): void $expired = $this->checkoutSessionRepository->findExpired($now); $sessionIds = array_map(static fn (CheckoutSession $s): string => $s->getSessionId(), $expired); - self::assertContains('cs_expired_active', $sessionIds, '期限切れの未完了セッションは対象'); - self::assertNotContains('cs_expired_completed', $sessionIds, '完了済セッションはクリーンアップ対象外'); + $this->assertContains('cs_expired_active', $sessionIds, '期限切れの未完了セッションは対象'); + $this->assertNotContains('cs_expired_completed', $sessionIds, '完了済セッションはクリーンアップ対象外'); } public function testNormalOrderHasNullAgentColumns(): void @@ -145,8 +145,8 @@ public function testNormalOrderHasNullAgentColumns(): void // Order に追加した agent_protocol/agent_id が、通常購入相当の Order で NULL であることの回帰確認。 $order = new Order(); - self::assertNull($order->getAgentProtocol(), '通常購入では agent_protocol は NULL'); - self::assertNull($order->getAgentId(), '通常購入では agent_id は NULL'); + $this->assertNull($order->getAgentProtocol(), '通常購入では agent_protocol は NULL'); + $this->assertNull($order->getAgentId(), '通常購入では agent_id は NULL'); } public function testOrderAgentColumnsAreSettable(): void @@ -154,7 +154,7 @@ public function testOrderAgentColumnsAreSettable(): void $order = new Order(); $order->setAgentProtocol(CheckoutSession::PROTOCOL_ACP)->setAgentId('agent-123'); - self::assertSame('acp', $order->getAgentProtocol()); - self::assertSame('agent-123', $order->getAgentId()); + $this->assertSame('acp', $order->getAgentProtocol()); + $this->assertSame('agent-123', $order->getAgentId()); } } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php index 65b69632cb3..6f6b3757695 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php @@ -15,6 +15,7 @@ namespace Eccube\Tests\Service\AgentCommerce; +use Eccube\Entity\Customer; use Eccube\Entity\ProductClass; use Eccube\Service\AgentCommerce\AgentCheckoutPurchaseFlowAdapter; use Eccube\Service\AgentCommerce\CheckoutSession\AgentCheckoutAddress; @@ -85,19 +86,19 @@ public function testBuildOrderComputesTotalsForGuest(): void $result = $this->adapter->buildOrder($request); $Order = $result->order; - self::assertFalse($result->hasError(), '在庫十分なゲスト注文ではエラーメッセージは出ない'); - self::assertSame('acp', $Order->getAgentProtocol(), 'agent_protocol が Order に刻まれる'); - self::assertSame('agent-xyz', $Order->getAgentId(), 'agent_id が Order に刻まれる'); - self::assertNull($Order->getCustomer(), 'ゲスト購入では Order.Customer は null'); - self::assertSame('山田', $Order->getName01(), 'buyer 住所が Order にコピーされる'); + $this->assertFalse($result->hasError(), '在庫十分なゲスト注文ではエラーメッセージは出ない'); + $this->assertSame('acp', $Order->getAgentProtocol(), 'agent_protocol が Order に刻まれる'); + $this->assertSame('agent-xyz', $Order->getAgentId(), 'agent_id が Order に刻まれる'); + $this->assertNotInstanceOf(Customer::class, $Order->getCustomer(), 'ゲスト購入では Order.Customer は null'); + $this->assertSame('山田', $Order->getName01(), 'buyer 住所が Order にコピーされる'); // 明細が DTO 通り (単価 = price02 / 数量 = 2) に構築されていること。 $productItems = $Order->getProductOrderItems(); - self::assertCount(1, $productItems, '商品明細は 1 行'); - self::assertSame(2, (int) $productItems[0]->getQuantity(), '数量が DTO の通り反映される'); - self::assertSame(0, bccomp((string) $productItems[0]->getPrice(), (string) $ProductClass->getPrice02(), 2), '明細単価は price02'); + $this->assertCount(1, $productItems, '商品明細は 1 行'); + $this->assertSame(2, (int) $productItems[0]->getQuantity(), '数量が DTO の通り反映される'); + $this->assertSame(0, bccomp((string) $productItems[0]->getPrice(), (string) $ProductClass->getPrice02(), 2), '明細単価は price02'); // shopping flow が税・送料・手数料込みの支払総額を計算していること。 - self::assertGreaterThan(0, (int) $Order->getPaymentTotal(), 'shopping flow で支払総額 (税送料込) が計算される'); + $this->assertGreaterThan(0, (int) $Order->getPaymentTotal(), 'shopping flow で支払総額 (税送料込) が計算される'); } public function testCompleteFinalizesOrder(): void @@ -113,8 +114,8 @@ public function testCompleteFinalizesOrder(): void $build = $this->adapter->buildOrder($request); $result = $this->adapter->complete($build->order); - self::assertFalse($result->hasError(), 'complete でエラーが出ない'); - self::assertNotNull($result->order->getOrderNo(), 'complete で受注番号が採番される'); + $this->assertFalse($result->hasError(), 'complete でエラーが出ない'); + $this->assertNotNull($result->order->getOrderNo(), 'complete で受注番号が採番される'); } public function testOverStockSurfacesAsMessageNotException(): void @@ -130,7 +131,7 @@ public function testOverStockSurfacesAsMessageNotException(): void // 在庫超過は例外でなく messages[] (ビジネス系) として返る。 $result = $this->adapter->buildOrder($request); - self::assertNotEmpty($result->messages, '在庫超過は messages[] に反映される (HTTP 200 + messages[] 系統)'); + $this->assertNotEmpty($result->messages, '在庫超過は messages[] に反映される (HTTP 200 + messages[] 系統)'); } public function testEmptyLineItemsThrows(): void @@ -141,7 +142,7 @@ public function testEmptyLineItemsThrows(): void $this->adapter->buildOrder($request); self::fail('空明細は AgentCheckoutException を投げる'); } catch (AgentCheckoutException $e) { - self::assertSame(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, $e->getErrorCode()); + $this->assertSame(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, $e->getErrorCode()); } } @@ -156,7 +157,7 @@ public function testUnknownProductThrows(): void $this->adapter->buildOrder($request); self::fail('未知の商品参照は AgentCheckoutException を投げる'); } catch (AgentCheckoutException $e) { - self::assertSame(AgentCheckoutErrorCode::PRODUCT_NOT_FOUND, $e->getErrorCode()); + $this->assertSame(AgentCheckoutErrorCode::PRODUCT_NOT_FOUND, $e->getErrorCode()); } } @@ -165,14 +166,13 @@ public function testGuestWithoutAddressThrows(): void $ProductClass = $this->createPurchasableProductClass('100'); $request = new AgentCheckoutRequest( lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 1)], - buyer: null, ); try { $this->adapter->buildOrder($request); self::fail('ゲストで住所が無い場合は AgentCheckoutException を投げる'); } catch (AgentCheckoutException $e) { - self::assertSame(AgentCheckoutErrorCode::MISSING_ADDRESS, $e->getErrorCode()); + $this->assertSame(AgentCheckoutErrorCode::MISSING_ADDRESS, $e->getErrorCode()); } } } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php index 6fa5289ef11..40b63b91db1 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php @@ -46,8 +46,8 @@ public function testNormalizedStatusIncludesCanceled(): void CheckoutSession::STATUS_EXPIRED, ]; - self::assertContains('canceled', $statuses, 'MUST: 正規化ステータスは canceled を含む'); - self::assertSame(['incomplete', 'ready', 'completed', 'canceled', 'expired'], $statuses, '正規化ステータスの語彙が仕様どおり'); + $this->assertContains('canceled', $statuses, 'MUST: 正規化ステータスは canceled を含む'); + $this->assertSame(['incomplete', 'ready', 'completed', 'canceled', 'expired'], $statuses, '正規化ステータスの語彙が仕様どおり'); } /** @@ -59,7 +59,7 @@ public function testBusinessMessageLevelsCoverErrorWarningInfo(): void { $levels = array_map(static fn (AgentCheckoutMessageLevel $l): string => $l->value, AgentCheckoutMessageLevel::cases()); - self::assertEqualsCanonicalizing(['error', 'warning', 'info'], $levels, 'MUST: ビジネス系メッセージは error/warning/info の 3 段を持つ'); + $this->assertEqualsCanonicalizing(['error', 'warning', 'info'], $levels, 'MUST: ビジネス系メッセージは error/warning/info の 3 段を持つ'); } /** @@ -68,12 +68,12 @@ public function testBusinessMessageLevelsCoverErrorWarningInfo(): void public function testOrderCarriesAgentAttributionAndDefaultsNull(): void { $normal = new Order(); - self::assertNull($normal->getAgentProtocol(), 'MUST: 通常購入の Order は agent_protocol が NULL'); - self::assertNull($normal->getAgentId(), 'MUST: 通常購入の Order は agent_id が NULL'); + $this->assertNull($normal->getAgentProtocol(), 'MUST: 通常購入の Order は agent_protocol が NULL'); + $this->assertNull($normal->getAgentId(), 'MUST: 通常購入の Order は agent_id が NULL'); $agentOrder = new Order(); $agentOrder->setAgentProtocol(CheckoutSession::PROTOCOL_ACP)->setAgentId('agent-1'); - self::assertSame('acp', $agentOrder->getAgentProtocol(), 'エージェント注文は protocol を保持できる'); + $this->assertSame('acp', $agentOrder->getAgentProtocol(), 'エージェント注文は protocol を保持できる'); } /** diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php index cc28c4f8732..e58e0fd88dd 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php @@ -71,13 +71,13 @@ public function testReturnsOptionsWithShippingFeeAndPaymentOptions(): void $options = $this->mapper->mapForDestination([$ProductClass], $this->pref(27), 'JPY'); - self::assertNotEmpty($options, '利用可能な配送方法が 1 件以上返る'); + $this->assertNotEmpty($options, '利用可能な配送方法が 1 件以上返る'); $option = $options[0]; - self::assertGreaterThan(0, $option->deliveryId, 'deliveryId が解決される'); - self::assertNotSame('', $option->name, '配送方法名が解決される'); - self::assertGreaterThanOrEqual(0, $option->shippingFeeMinor, '送料が minor unit (整数) で解決される'); - self::assertSame('JPY', $option->currencyCode); - self::assertIsArray($option->paymentOptions, '支払方法の選択肢が配列で返る'); + $this->assertGreaterThan(0, $option->deliveryId, 'deliveryId が解決される'); + $this->assertNotSame('', $option->name, '配送方法名が解決される'); + $this->assertGreaterThanOrEqual(0, $option->shippingFeeMinor, '送料が minor unit (整数) で解決される'); + $this->assertSame('JPY', $option->currencyCode); + $this->assertIsArray($option->paymentOptions, '支払方法の選択肢が配列で返る'); } public function testCodChargeResolvedViaPaymentOption(): void @@ -101,8 +101,8 @@ public function testCodChargeResolvedViaPaymentOption(): void } } - self::assertNotNull($codChargeMinor, '代金引換が支払選択肢に含まれる'); - self::assertSame(330, $codChargeMinor, '代引手数料が Payment::getCharge() から minor unit (JPY=330) で解決される'); + $this->assertNotNull($codChargeMinor, '代金引換が支払選択肢に含まれる'); + $this->assertSame(330, $codChargeMinor, '代引手数料が Payment::getCharge() から minor unit (JPY=330) で解決される'); } public function testDeliveryDaysIsMaxAcrossItems(): void @@ -115,8 +115,8 @@ public function testDeliveryDaysIsMaxAcrossItems(): void $options = $this->mapper->mapForDestination([$pc2days, $pc5days], $this->pref(27), 'JPY'); - self::assertNotEmpty($options); - self::assertSame(5, $options[0]->estimatedDeliveryDays, '配送日数は明細横断の最大値 (2 と 5 -> 5)'); + $this->assertNotEmpty($options); + $this->assertSame(5, $options[0]->estimatedDeliveryDays, '配送日数は明細横断の最大値 (2 と 5 -> 5)'); } public function testBackorderItemYieldsNullDeliveryDays(): void @@ -129,8 +129,8 @@ public function testBackorderItemYieldsNullDeliveryDays(): void $options = $this->mapper->mapForDestination([$pcNormal, $pcBackorder], $this->pref(27), 'JPY'); - self::assertNotEmpty($options); - self::assertNull($options[0]->estimatedDeliveryDays, 'お取り寄せ (duration<0) が含まれると配送日数は未確定 (null)'); + $this->assertNotEmpty($options); + $this->assertNull($options[0]->estimatedDeliveryDays, 'お取り寄せ (duration<0) が含まれると配送日数は未確定 (null)'); } private function createDuration(string $name, int $duration): DeliveryDuration diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php index 5dfa73d1c74..368e0a57a65 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php @@ -47,9 +47,9 @@ protected function setUp(): void */ private function handlerWithScopes(array $scopes): AccessTokenHandlerInterface { - return new class($scopes) implements AccessTokenHandlerInterface { + return new readonly class($scopes) implements AccessTokenHandlerInterface { /** @param array $scopes */ - public function __construct(private readonly array $scopes) + public function __construct(private array $scopes) { } @@ -79,7 +79,7 @@ public function testValidTokenWithMatchingScopeReturnsUserBadge(): void $badge = $authenticator->authenticate('valid-token', 'acp', 'checkout'); - self::assertSame('agent-platform', $badge->getUserIdentifier(), 'valid token + matching scope は UserBadge を返す'); + $this->assertSame('agent-platform', $badge->getUserIdentifier(), 'valid token + matching scope は UserBadge を返す'); } public function testSpaceDelimitedScopeAttributeIsAccepted(): void @@ -94,7 +94,7 @@ public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): Us $badge = $authenticator->authenticate('valid-token', 'ucp', 'checkout'); - self::assertSame('agent-platform', $badge->getUserIdentifier(), 'OAuth2 標準の空白区切り scope 文字列も解釈できる'); + $this->assertSame('agent-platform', $badge->getUserIdentifier(), 'OAuth2 標準の空白区切り scope 文字列も解釈できる'); } public function testInvalidTokenThrowsAuthenticationException(): void @@ -126,9 +126,9 @@ public function testProtocolCrossoverIsDenied(): void public function testHandlerUnavailableThrowsServiceUnavailable(): void { // eccube-api4 未導入 = handler が null -> 503。 - $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, null); + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry); - self::assertFalse($authenticator->isAvailable(), 'handler 未注入時は isAvailable() が false'); + $this->assertFalse($authenticator->isAvailable(), 'handler 未注入時は isAvailable() が false'); $this->expectException(ServiceUnavailableHttpException::class); $authenticator->authenticate('any-token', 'acp', 'checkout'); From c305412228403c4b29ce722954068f1083996a1f Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 11 Jun 2026 17:50:19 +0900 Subject: [PATCH 14/28] =?UTF-8?q?refactor(agent-commerce):=20status/protoc?= =?UTF-8?q?ol=20=E3=82=92=E3=83=9E=E3=82=B9=E3=82=BF=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=83=96=E3=83=AB=E5=8C=96=20(=E6=96=87=E5=AD=97=E5=88=97?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E3=82=92=E5=BB=83=E6=AD=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckoutSession.status / protocol および Order.agent_protocol を、文字列カラム + クラス文字列定数から、Order/OrderStatus と同じ「マスタ + INTEGER ManyToOne」方式へ変更。 区分値を文字列で保存しない EC-CUBE コア慣習に統一する (protocol は CheckoutSession と Order の双方で参照するため特にマスタ化が必須)。 - 新規マスタ mtb_checkout_session_status (CheckoutSessionStatus・INCOMPLETE=1..EXPIRED=5) - 新規マスタ mtb_agent_protocol (AgentProtocol・ACP=1/UCP=2) - 各 Repository、import_csv (ja/en・name は正準値で同一)、definition.yml 登録 - 既存インストール向け backfill の INSERT migration (冪等・down 非対応) - CheckoutSession.status/protocol、Order.agent_protocol を ManyToOne 関連へ - AgentCheckoutRequest.protocol(string) → protocolId(int)、Adapter で AgentProtocol 解決 - テストをマスタ参照へ更新 (status()/PHPUnit final 衝突回避で findStatus にリネーム) PHPStan level6 No errors / AgentCommerce 93 tests 0 失敗 / Order・PurchaseFlow 回帰 green。 Co-Authored-By: Claude Opus 4.8 --- .../Version20260611120000.php | 63 +++++++++++++++++++ src/Eccube/Entity/CheckoutSession.php | 51 ++++++--------- src/Eccube/Entity/Master/AgentProtocol.php | 42 +++++++++++++ .../Entity/Master/CheckoutSessionStatus.php | 50 +++++++++++++++ src/Eccube/Entity/Order.php | 18 +++--- .../Repository/CheckoutSessionRepository.php | 12 ++-- .../Master/AgentProtocolRepository.php | 29 +++++++++ .../CheckoutSessionStatusRepository.php | 29 +++++++++ .../doctrine/import_csv/en/definition.yml | 2 + .../import_csv/en/mtb_agent_protocol.csv | 3 + .../en/mtb_checkout_session_status.csv | 6 ++ .../doctrine/import_csv/ja/definition.yml | 2 + .../import_csv/ja/mtb_agent_protocol.csv | 3 + .../ja/mtb_checkout_session_status.csv | 6 ++ .../AgentCheckoutPurchaseFlowAdapter.php | 6 +- .../CheckoutSession/AgentCheckoutRequest.php | 3 +- .../Tests/Entity/CheckoutSessionTest.php | 63 ++++++++++++++----- .../AgentCheckoutPurchaseFlowAdapterTest.php | 9 +-- .../AgentCheckoutCoreConformanceTest.php | 31 ++++----- 19 files changed, 348 insertions(+), 80 deletions(-) create mode 100644 app/DoctrineMigrations/Version20260611120000.php create mode 100644 src/Eccube/Entity/Master/AgentProtocol.php create mode 100644 src/Eccube/Entity/Master/CheckoutSessionStatus.php create mode 100644 src/Eccube/Repository/Master/AgentProtocolRepository.php create mode 100644 src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php create mode 100644 src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv create mode 100644 src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv create mode 100644 src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv create mode 100644 src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv diff --git a/app/DoctrineMigrations/Version20260611120000.php b/app/DoctrineMigrations/Version20260611120000.php new file mode 100644 index 00000000000..5f1d4269b0a --- /dev/null +++ b/app/DoctrineMigrations/Version20260611120000.php @@ -0,0 +1,63 @@ + + */ + private const INSERTS = [ + 'mtb_checkout_session_status' => "INSERT INTO mtb_checkout_session_status (id, name, sort_no, discriminator_type) VALUES (1, 'incomplete', 1, 'checkoutsessionstatus'), (2, 'ready', 2, 'checkoutsessionstatus'), (3, 'completed', 3, 'checkoutsessionstatus'), (4, 'canceled', 4, 'checkoutsessionstatus'), (5, 'expired', 5, 'checkoutsessionstatus')", + 'mtb_agent_protocol' => "INSERT INTO mtb_agent_protocol (id, name, sort_no, discriminator_type) VALUES (1, 'acp', 1, 'agentprotocol'), (2, 'ucp', 2, 'agentprotocol')", + ]; + + public function up(Schema $schema): void + { + foreach (self::INSERTS as $table => $sql) { + if (!$schema->hasTable($table)) { + continue; + } + + // 既にデータがある場合 (新規インストールの fixtures 投入済み等) は二重投入しない. + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM '.$table); + if ($count > 0) { + continue; + } + + $this->addSql($sql); + } + } + + public function down(Schema $schema): void + { + // 新規インストールでは up() が no-op (import_csv 投入済み) のため、 + // 一律 DELETE すると import_csv 由来の初期データまで巻き添えで消える。 + // マスタ初期データは不可逆として扱い、ロールバックでは何もしない。 + $this->throwIrreversibleMigrationException('エージェントコマースの区分値マスタはマスタ初期データのため down は非対応です.'); + } +} diff --git a/src/Eccube/Entity/CheckoutSession.php b/src/Eccube/Entity/CheckoutSession.php index f5ccebbf870..3e17c62364c 100644 --- a/src/Eccube/Entity/CheckoutSession.php +++ b/src/Eccube/Entity/CheckoutSession.php @@ -15,6 +15,8 @@ use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Eccube\Entity\Master\AgentProtocol; +use Eccube\Entity\Master\CheckoutSessionStatus; use Eccube\Repository\CheckoutSessionRepository; if (!class_exists(CheckoutSession::class)) { @@ -40,25 +42,6 @@ #[ORM\Entity(repositoryClass: CheckoutSessionRepository::class)] class CheckoutSession extends AbstractEntity { - /** 未完了 (作成直後・住所/配送/支払が未確定). */ - public const STATUS_INCOMPLETE = 'incomplete'; - - /** 確定可能 (必須項目が揃い complete を実行できる). */ - public const STATUS_READY = 'ready'; - - /** 完了 (Order 生成済). */ - public const STATUS_COMPLETED = 'completed'; - - /** 取消. */ - public const STATUS_CANCELED = 'canceled'; - - /** 期限切れ. */ - public const STATUS_EXPIRED = 'expired'; - - public const PROTOCOL_ACP = 'acp'; - - public const PROTOCOL_UCP = 'ucp'; - #[ORM\Column(name: 'id', type: Types::INTEGER, options: ['unsigned' => true])] #[ORM\Id] #[ORM\GeneratedValue(strategy: 'IDENTITY')] @@ -73,10 +56,11 @@ class CheckoutSession extends AbstractEntity private string $session_id = ''; /** - * プロトコル名 (`acp` / `ucp`). + * プロトコル種別 (ACP / UCP) マスタへの参照. */ - #[ORM\Column(name: 'protocol', type: Types::STRING, length: 255)] - private string $protocol = ''; + #[ORM\ManyToOne(targetEntity: AgentProtocol::class)] + #[ORM\JoinColumn(name: 'agent_protocol_id', referencedColumnName: 'id')] + private ?AgentProtocol $Protocol = null; /** * セッションを開始したエージェントの識別子. @@ -85,10 +69,11 @@ class CheckoutSession extends AbstractEntity private ?string $agent_id = null; /** - * 正規化ステータス (`incomplete`/`ready`/`completed`/`canceled`/`expired`). + * 正規化ステータス (incomplete/ready/completed/canceled/expired) マスタへの参照. */ - #[ORM\Column(name: 'status', type: Types::STRING, length: 255, options: ['default' => self::STATUS_INCOMPLETE])] - private string $status = self::STATUS_INCOMPLETE; + #[ORM\ManyToOne(targetEntity: CheckoutSessionStatus::class)] + #[ORM\JoinColumn(name: 'checkout_session_status_id', referencedColumnName: 'id')] + private ?CheckoutSessionStatus $Status = null; /** * 通貨コード (ISO 4217 alpha-3). @@ -178,14 +163,14 @@ public function setSessionId(string $session_id): CheckoutSession return $this; } - public function getProtocol(): string + public function getProtocol(): ?AgentProtocol { - return $this->protocol; + return $this->Protocol; } - public function setProtocol(string $protocol): CheckoutSession + public function setProtocol(?AgentProtocol $Protocol = null): CheckoutSession { - $this->protocol = $protocol; + $this->Protocol = $Protocol; return $this; } @@ -202,14 +187,14 @@ public function setAgentId(?string $agent_id = null): CheckoutSession return $this; } - public function getStatus(): string + public function getStatus(): ?CheckoutSessionStatus { - return $this->status; + return $this->Status; } - public function setStatus(string $status): CheckoutSession + public function setStatus(?CheckoutSessionStatus $Status = null): CheckoutSession { - $this->status = $status; + $this->Status = $Status; return $this; } diff --git a/src/Eccube/Entity/Master/AgentProtocol.php b/src/Eccube/Entity/Master/AgentProtocol.php new file mode 100644 index 00000000000..16292b49ce5 --- /dev/null +++ b/src/Eccube/Entity/Master/AgentProtocol.php @@ -0,0 +1,42 @@ +agent_protocol; + return $this->AgentProtocol; } - public function setAgentProtocol(?string $agent_protocol = null): Order + public function setAgentProtocol(?AgentProtocol $AgentProtocol = null): Order { - $this->agent_protocol = $agent_protocol; + $this->AgentProtocol = $AgentProtocol; return $this; } diff --git a/src/Eccube/Repository/CheckoutSessionRepository.php b/src/Eccube/Repository/CheckoutSessionRepository.php index c89fdd26da4..75400f0deb9 100644 --- a/src/Eccube/Repository/CheckoutSessionRepository.php +++ b/src/Eccube/Repository/CheckoutSessionRepository.php @@ -15,6 +15,7 @@ use Doctrine\Persistence\ManagerRegistry as RegistryInterface; use Eccube\Entity\CheckoutSession; +use Eccube\Entity\Master\CheckoutSessionStatus; /** * CheckoutSessionRepository @@ -51,12 +52,15 @@ public function findExpired(\DateTime $now): array $result = $qb ->where('cs.expires_at IS NOT NULL') ->andWhere('cs.expires_at < :now') - ->andWhere($qb->expr()->notIn('cs.status', ':terminalStatuses')) + ->andWhere($qb->expr()->orX( + 'cs.Status IS NULL', + $qb->expr()->notIn('IDENTITY(cs.Status)', ':terminalStatuses') + )) ->setParameter('now', $now) ->setParameter('terminalStatuses', [ - CheckoutSession::STATUS_COMPLETED, - CheckoutSession::STATUS_CANCELED, - CheckoutSession::STATUS_EXPIRED, + CheckoutSessionStatus::COMPLETED, + CheckoutSessionStatus::CANCELED, + CheckoutSessionStatus::EXPIRED, ]) ->getQuery() ->getResult(); diff --git a/src/Eccube/Repository/Master/AgentProtocolRepository.php b/src/Eccube/Repository/Master/AgentProtocolRepository.php new file mode 100644 index 00000000000..d61f64cd6cc --- /dev/null +++ b/src/Eccube/Repository/Master/AgentProtocolRepository.php @@ -0,0 +1,29 @@ + + */ +class AgentProtocolRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, AgentProtocol::class); + } +} diff --git a/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php b/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php new file mode 100644 index 00000000000..5cd6f8e6bbc --- /dev/null +++ b/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php @@ -0,0 +1,29 @@ + + */ +class CheckoutSessionStatusRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CheckoutSessionStatus::class); + } +} diff --git a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml index 6d81b1d63e9..588b3553fd8 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml @@ -1,4 +1,6 @@ +- mtb_agent_protocol.csv - mtb_authority.csv +- mtb_checkout_session_status.csv - mtb_country.csv - mtb_country_iso_code.csv - mtb_csv_type.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv new file mode 100644 index 00000000000..575027df7fe --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv @@ -0,0 +1,3 @@ +id,name,sort_no,discriminator_type +"1","acp","1","agentprotocol" +"2","ucp","2","agentprotocol" diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv new file mode 100644 index 00000000000..95eb316bf6d --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv @@ -0,0 +1,6 @@ +id,name,sort_no,discriminator_type +"1","incomplete","1","checkoutsessionstatus" +"2","ready","2","checkoutsessionstatus" +"3","completed","3","checkoutsessionstatus" +"4","canceled","4","checkoutsessionstatus" +"5","expired","5","checkoutsessionstatus" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml index 6d81b1d63e9..588b3553fd8 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml @@ -1,4 +1,6 @@ +- mtb_agent_protocol.csv - mtb_authority.csv +- mtb_checkout_session_status.csv - mtb_country.csv - mtb_country_iso_code.csv - mtb_csv_type.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv new file mode 100644 index 00000000000..575027df7fe --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv @@ -0,0 +1,3 @@ +id,name,sort_no,discriminator_type +"1","acp","1","agentprotocol" +"2","ucp","2","agentprotocol" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv new file mode 100644 index 00000000000..95eb316bf6d --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv @@ -0,0 +1,6 @@ +id,name,sort_no,discriminator_type +"1","incomplete","1","checkoutsessionstatus" +"2","ready","2","checkoutsessionstatus" +"3","completed","3","checkoutsessionstatus" +"4","canceled","4","checkoutsessionstatus" +"5","expired","5","checkoutsessionstatus" diff --git a/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php index 55a0c144491..65a27800455 100644 --- a/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php +++ b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php @@ -18,6 +18,7 @@ use Eccube\Entity\CartItem; use Eccube\Entity\Customer; use Eccube\Entity\Order; +use Eccube\Repository\Master\AgentProtocolRepository; use Eccube\Repository\Master\PrefRepository; use Eccube\Repository\ProductClassRepository; use Eccube\Service\AgentCommerce\CheckoutSession\AgentCheckoutAddress; @@ -53,6 +54,7 @@ public function __construct( private readonly OrderHelper $orderHelper, private readonly ProductClassRepository $productClassRepository, private readonly PrefRepository $prefRepository, + private readonly AgentProtocolRepository $agentProtocolRepository, private readonly PurchaseFlow $shoppingPurchaseFlow, ) { } @@ -76,8 +78,8 @@ public function buildOrder(AgentCheckoutRequest $request, ?Customer $member = nu $Order = $this->orderHelper->createPurchaseProcessingOrder($Cart, $Customer); $Cart->setPreOrderId($Order->getPreOrderId()); - if ($request->protocol !== null) { - $Order->setAgentProtocol($request->protocol); + if ($request->protocolId !== null) { + $Order->setAgentProtocol($this->agentProtocolRepository->find($request->protocolId)); } if ($request->agentId !== null) { $Order->setAgentId($request->agentId); diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php index bf1e63819cc..0d1506e7870 100644 --- a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutRequest.php @@ -23,12 +23,13 @@ { /** * @param array $lineItems + * @param int|null $protocolId プロトコル種別マスタ (`AgentProtocol::ACP` 等) の id */ public function __construct( public array $lineItems, public ?AgentCheckoutAddress $buyer = null, public string $currencyCode = 'JPY', - public ?string $protocol = null, + public ?int $protocolId = null, public ?string $agentId = null, ) { } diff --git a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php index e951b071624..b19ef8f8dad 100644 --- a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php +++ b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php @@ -16,14 +16,19 @@ namespace Eccube\Tests\Entity; use Eccube\Entity\CheckoutSession; +use Eccube\Entity\Master\AgentProtocol; +use Eccube\Entity\Master\CheckoutSessionStatus; use Eccube\Entity\Order; use Eccube\Repository\CheckoutSessionRepository; +use Eccube\Repository\Master\AgentProtocolRepository; +use Eccube\Repository\Master\CheckoutSessionStatusRepository; use Eccube\Tests\EccubeTestCase; /** - * Layer 2 (Doctrine) tests for CheckoutSession + Order の agent カラム. + * Layer 2 (Doctrine) tests for CheckoutSession + Order の agent 区分値. * * - CheckoutSession の永続化・取得・状態遷移 (incomplete -> ready -> completed / canceled / expired)。 + * - status / protocol が文字列でなくマスタ (CheckoutSessionStatus / AgentProtocol) への FK であること。 * - SaveEventSubscriber による create_date/update_date 自動付与。 * - json カラム (buyer_data 等) のラウンドトリップ。 * - 通常購入の Order で agent_protocol/agent_id が NULL である回帰確認。 @@ -32,10 +37,24 @@ final class CheckoutSessionTest extends EccubeTestCase { private ?CheckoutSessionRepository $checkoutSessionRepository = null; + private ?CheckoutSessionStatusRepository $statusRepository = null; + + private ?AgentProtocolRepository $protocolRepository = null; + protected function setUp(): void { parent::setUp(); $this->checkoutSessionRepository = self::getContainer()->get(CheckoutSessionRepository::class); + $this->statusRepository = self::getContainer()->get(CheckoutSessionStatusRepository::class); + $this->protocolRepository = self::getContainer()->get(AgentProtocolRepository::class); + } + + private function findStatus(int $id): CheckoutSessionStatus + { + /** @var CheckoutSessionStatus $status */ + $status = $this->statusRepository->find($id); + + return $status; } private function createSession(string $sessionId): CheckoutSession @@ -43,7 +62,8 @@ private function createSession(string $sessionId): CheckoutSession $session = new CheckoutSession(); $session ->setSessionId($sessionId) - ->setProtocol(CheckoutSession::PROTOCOL_ACP) + ->setProtocol($this->protocolRepository->find(AgentProtocol::ACP)) + ->setStatus($this->findStatus(CheckoutSessionStatus::INCOMPLETE)) ->setCurrencyCode('JPY') ->setBuyerData(['family_name' => '山田', 'given_name' => '太郎']) ->setMetadata(['acp_status' => 'not_ready_for_payment']); @@ -60,7 +80,22 @@ public function testPersistAssignsIdentifierAndTimestamps(): void $this->assertNotNull($session->getId(), '永続化で id が採番される'); $this->assertInstanceOf(\DateTime::class, $session->getCreateDate(), 'SaveEventSubscriber が create_date を自動付与する'); $this->assertInstanceOf(\DateTime::class, $session->getUpdateDate(), 'SaveEventSubscriber が update_date を自動付与する'); - $this->assertSame(CheckoutSession::STATUS_INCOMPLETE, $session->getStatus(), '初期 status は incomplete'); + $this->assertSame(CheckoutSessionStatus::INCOMPLETE, $session->getStatus()?->getId(), '初期 status は incomplete マスタ'); + } + + public function testProtocolAndStatusAreMasterRelations(): void + { + $session = $this->createSession('cs_test_master'); + $id = $session->getId(); + $this->entityManager->clear(); + + /** @var CheckoutSession $reloaded */ + $reloaded = $this->checkoutSessionRepository->find($id); + + $this->assertInstanceOf(AgentProtocol::class, $reloaded->getProtocol(), 'protocol はマスタ AgentProtocol への FK'); + $this->assertSame('acp', $reloaded->getProtocol()?->getName(), 'AgentProtocol の正準名は acp'); + $this->assertInstanceOf(CheckoutSessionStatus::class, $reloaded->getStatus(), 'status はマスタ CheckoutSessionStatus への FK'); + $this->assertSame('incomplete', $reloaded->getStatus()?->getName(), 'CheckoutSessionStatus の正準名は incomplete'); } public function testFindOneBySessionId(): void @@ -93,18 +128,18 @@ public function testStatusTransitions(): void $session = $this->createSession('cs_test_status'); foreach ([ - CheckoutSession::STATUS_READY, - CheckoutSession::STATUS_COMPLETED, + CheckoutSessionStatus::READY, + CheckoutSessionStatus::COMPLETED, ] as $next) { - $session->setStatus($next); + $session->setStatus($this->findStatus($next)); $this->entityManager->flush(); - $this->assertSame($next, $session->getStatus()); + $this->assertSame($next, $session->getStatus()?->getId()); } - // canceled / expired も正規化ステータスとして保持できる。 - $session->setStatus(CheckoutSession::STATUS_CANCELED); + // canceled も正規化ステータスマスタとして保持できる。 + $session->setStatus($this->findStatus(CheckoutSessionStatus::CANCELED)); $this->entityManager->flush(); - $this->assertSame('canceled', $session->getStatus(), 'canceled を正規化ステータスとして保持できる'); + $this->assertSame('canceled', $session->getStatus()?->getName(), 'canceled を正規化ステータスとして保持できる'); } public function testIsExpired(): void @@ -130,7 +165,7 @@ public function testFindExpiredExcludesTerminalStatuses(): void $active->setExpiresAt($past); $completed = $this->createSession('cs_expired_completed'); - $completed->setExpiresAt($past)->setStatus(CheckoutSession::STATUS_COMPLETED); + $completed->setExpiresAt($past)->setStatus($this->findStatus(CheckoutSessionStatus::COMPLETED)); $this->entityManager->flush(); $expired = $this->checkoutSessionRepository->findExpired($now); @@ -145,16 +180,16 @@ public function testNormalOrderHasNullAgentColumns(): void // Order に追加した agent_protocol/agent_id が、通常購入相当の Order で NULL であることの回帰確認。 $order = new Order(); - $this->assertNull($order->getAgentProtocol(), '通常購入では agent_protocol は NULL'); + $this->assertNotInstanceOf(AgentProtocol::class, $order->getAgentProtocol(), '通常購入では agent_protocol は NULL'); $this->assertNull($order->getAgentId(), '通常購入では agent_id は NULL'); } public function testOrderAgentColumnsAreSettable(): void { $order = new Order(); - $order->setAgentProtocol(CheckoutSession::PROTOCOL_ACP)->setAgentId('agent-123'); + $order->setAgentProtocol($this->protocolRepository->find(AgentProtocol::ACP))->setAgentId('agent-123'); - $this->assertSame('acp', $order->getAgentProtocol()); + $this->assertSame('acp', $order->getAgentProtocol()?->getName()); $this->assertSame('agent-123', $order->getAgentId()); } } diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php index 6f6b3757695..72463726d50 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php @@ -16,6 +16,7 @@ namespace Eccube\Tests\Service\AgentCommerce; use Eccube\Entity\Customer; +use Eccube\Entity\Master\AgentProtocol; use Eccube\Entity\ProductClass; use Eccube\Service\AgentCommerce\AgentCheckoutPurchaseFlowAdapter; use Eccube\Service\AgentCommerce\CheckoutSession\AgentCheckoutAddress; @@ -79,7 +80,7 @@ public function testBuildOrderComputesTotalsForGuest(): void lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 2)], buyer: $this->guestAddress(), currencyCode: 'JPY', - protocol: 'acp', + protocolId: AgentProtocol::ACP, agentId: 'agent-xyz', ); @@ -87,7 +88,7 @@ public function testBuildOrderComputesTotalsForGuest(): void $Order = $result->order; $this->assertFalse($result->hasError(), '在庫十分なゲスト注文ではエラーメッセージは出ない'); - $this->assertSame('acp', $Order->getAgentProtocol(), 'agent_protocol が Order に刻まれる'); + $this->assertSame('acp', $Order->getAgentProtocol()?->getName(), 'agent_protocol マスタが Order に刻まれる'); $this->assertSame('agent-xyz', $Order->getAgentId(), 'agent_id が Order に刻まれる'); $this->assertNotInstanceOf(Customer::class, $Order->getCustomer(), 'ゲスト購入では Order.Customer は null'); $this->assertSame('山田', $Order->getName01(), 'buyer 住所が Order にコピーされる'); @@ -108,7 +109,7 @@ public function testCompleteFinalizesOrder(): void $request = new AgentCheckoutRequest( lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 1)], buyer: $this->guestAddress(), - protocol: 'acp', + protocolId: AgentProtocol::ACP, ); $build = $this->adapter->buildOrder($request); @@ -125,7 +126,7 @@ public function testOverStockSurfacesAsMessageNotException(): void $request = new AgentCheckoutRequest( lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 5)], buyer: $this->guestAddress(), - protocol: 'acp', + protocolId: AgentProtocol::ACP, ); // 在庫超過は例外でなく messages[] (ビジネス系) として返る。 diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php index 40b63b91db1..f31d3b863a4 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php @@ -15,7 +15,8 @@ namespace Eccube\Tests\Service\AgentCommerce\Conformance; -use Eccube\Entity\CheckoutSession; +use Eccube\Entity\Master\AgentProtocol; +use Eccube\Entity\Master\CheckoutSessionStatus; use Eccube\Entity\Order; use Eccube\Service\AgentCommerce\CheckoutSession\AgentCheckoutMessageLevel; use PHPUnit\Framework\TestCase; @@ -32,22 +33,23 @@ final class AgentCheckoutCoreConformanceTest extends TestCase { /** - * CheckoutSession.status は ACP/UCP 横断の正規化ステータスであり、`canceled` を含む. + * CheckoutSession.status は ACP/UCP 横断の正規化ステータスマスタであり、`canceled` を含む. * - * (incomplete / ready / completed / canceled / expired) + * (incomplete=1 / ready=2 / completed=3 / canceled=4 / expired=5) + * 正準名 (`canceled` 等) は import_csv で seed され、DB 上の値は Layer 2 で検証する。 */ public function testNormalizedStatusIncludesCanceled(): void { - $statuses = [ - CheckoutSession::STATUS_INCOMPLETE, - CheckoutSession::STATUS_READY, - CheckoutSession::STATUS_COMPLETED, - CheckoutSession::STATUS_CANCELED, - CheckoutSession::STATUS_EXPIRED, + $ids = [ + CheckoutSessionStatus::INCOMPLETE, + CheckoutSessionStatus::READY, + CheckoutSessionStatus::COMPLETED, + CheckoutSessionStatus::CANCELED, + CheckoutSessionStatus::EXPIRED, ]; - $this->assertContains('canceled', $statuses, 'MUST: 正規化ステータスは canceled を含む'); - $this->assertSame(['incomplete', 'ready', 'completed', 'canceled', 'expired'], $statuses, '正規化ステータスの語彙が仕様どおり'); + $this->assertContains(CheckoutSessionStatus::CANCELED, $ids, 'MUST: 正規化ステータスマスタは canceled を含む'); + $this->assertSame([1, 2, 3, 4, 5], $ids, '正規化ステータスマスタの定数が 5 段そろう'); } /** @@ -68,12 +70,13 @@ public function testBusinessMessageLevelsCoverErrorWarningInfo(): void public function testOrderCarriesAgentAttributionAndDefaultsNull(): void { $normal = new Order(); - $this->assertNull($normal->getAgentProtocol(), 'MUST: 通常購入の Order は agent_protocol が NULL'); + $this->assertNotInstanceOf(AgentProtocol::class, $normal->getAgentProtocol(), 'MUST: 通常購入の Order は agent_protocol が NULL'); $this->assertNull($normal->getAgentId(), 'MUST: 通常購入の Order は agent_id が NULL'); + $protocol = new AgentProtocol(); $agentOrder = new Order(); - $agentOrder->setAgentProtocol(CheckoutSession::PROTOCOL_ACP)->setAgentId('agent-1'); - $this->assertSame('acp', $agentOrder->getAgentProtocol(), 'エージェント注文は protocol を保持できる'); + $agentOrder->setAgentProtocol($protocol)->setAgentId('agent-1'); + $this->assertSame($protocol, $agentOrder->getAgentProtocol(), 'エージェント注文は protocol マスタを保持できる'); } /** From 31ea72da82c8f8386de6e96b670ac21f1dd88985 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Thu, 11 Jun 2026 18:03:30 +0900 Subject: [PATCH 15/28] =?UTF-8?q?fix(agent-commerce):=20=E3=82=A8=E3=83=BC?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88=E6=89=80=E6=9C=89=E3=82=AB?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=82=92=20Web=20=E3=82=B9=E3=83=88=E3=82=A2?= =?UTF-8?q?=E3=83=95=E3=83=AD=E3=83=B3=E3=83=88=E3=81=8B=E3=82=89=E9=9A=94?= =?UTF-8?q?=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckoutSession が参照する Cart が、ログイン会員の Web カート解決 (CartService::getPersistedCarts は customer_id で全カートを取得) に混入し、 マージ・再計算・購入完了時の削除等で操作されてしまう問題を防ぐ。 Web 側の Cart 再生成に CheckoutSession を追従させるのは侵襲的で脆いため、 エージェント所有カートを Web から不可視・操作不可に隔離する方針とする。 - Cart に agent_owned (boolean, default false) を追加 - CartService::getPersistedCarts/getSessionCarts の検索条件に agent_owned=false を追加し、 エージェント所有カートを Web カート解決から除外 (既存カートは全て false で無影響) - Adapter はカートに agent_owned=true をセットし、customer_id は常に NULL に保つ (会員帰属は Order 側に持たせ、会員 ID 連携時もカート混入を防ぐ多層防御) - agent_owned カラム追加は schema:update 方式 (migration 不要) - 隔離の回帰テストを追加 PHPStan level6 No errors / AgentCommerce 85 tests 0 失敗 / CartService 回帰 green。 Co-Authored-By: Claude Opus 4.8 --- src/Eccube/Entity/Cart.php | 22 +++++++++++++ .../AgentCheckoutPurchaseFlowAdapter.php | 11 ++++--- src/Eccube/Service/CartService.php | 6 ++-- .../AgentCheckoutPurchaseFlowAdapterTest.php | 31 +++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/Eccube/Entity/Cart.php b/src/Eccube/Entity/Cart.php index ce85c4b14d3..a19ba10e541 100644 --- a/src/Eccube/Entity/Cart.php +++ b/src/Eccube/Entity/Cart.php @@ -45,6 +45,16 @@ class Cart extends AbstractEntity implements PurchaseInterface, ItemHolderInterf #[ORM\Column(name: 'cart_key', type: Types::STRING, nullable: true)] private ?string $cart_key = null; + /** + * エージェントコマース (CheckoutSession) が所有するカートか. + * + * true のカートは Web ストアフロント (CartService) のカート解決から除外され、 + * ログイン会員のカートマージ・再計算・購入完了等で操作されない。 + * 通常購入のカートは常に false。 + */ + #[ORM\Column(name: 'agent_owned', type: Types::BOOLEAN, options: ['default' => false])] + private bool $agent_owned = false; + #[ORM\ManyToOne(targetEntity: Customer::class)] #[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id')] private ?Customer $Customer = null; @@ -125,6 +135,18 @@ public function setCartKey(string $cartKey): Cart return $this; } + public function isAgentOwned(): bool + { + return $this->agent_owned; + } + + public function setAgentOwned(bool $agentOwned): Cart + { + $this->agent_owned = $agentOwned; + + return $this; + } + /** * @deprecated 使用しないので削除予定 */ diff --git a/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php index 65a27800455..bec8cab17e1 100644 --- a/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php +++ b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php @@ -73,7 +73,7 @@ public function buildOrder(AgentCheckoutRequest $request, ?Customer $member = nu $Customer = $member ?? $this->buildGuestCustomer($request->buyer); - $Cart = $this->buildCart($request, $Customer); + $Cart = $this->buildCart($request); $Order = $this->orderHelper->createPurchaseProcessingOrder($Cart, $Customer); $Cart->setPreOrderId($Order->getPreOrderId()); @@ -141,15 +141,16 @@ private function runFlow(callable $flow, Order $Order, ?Customer $member): array /** * 中立明細から永続化された `Cart` を構築する. */ - private function buildCart(AgentCheckoutRequest $request, Customer $Customer): Cart + private function buildCart(AgentCheckoutRequest $request): Cart { $Cart = new Cart(); // 合計は Order 側の shopping flow で再計算されるため、ここでは NOT NULL 制約を満たす初期値のみ設定する。 $Cart->setTotalPrice('0'); $Cart->setDeliveryFeeTotal('0'); - if ($Customer->getId()) { - $Cart->setCustomer($Customer); - } + // エージェント所有カートは Web ストアフロント (CartService) の解決対象から除外する。 + // 会員帰属は Order 側 (OrderHelper) に持たせ、Cart の customer_id は常に NULL に保つ + // ことで、ログイン会員の Web カートに混入・操作されないようにする。 + $Cart->setAgentOwned(true); foreach ($request->lineItems as $lineItem) { if ($lineItem->quantity < 1) { diff --git a/src/Eccube/Service/CartService.php b/src/Eccube/Service/CartService.php index b529cc2c116..80e3b54bd9c 100644 --- a/src/Eccube/Service/CartService.php +++ b/src/Eccube/Service/CartService.php @@ -98,7 +98,9 @@ public function getCarts(bool $empty_delete = false): array */ public function getPersistedCarts(): array { - return $this->cartRepository->findBy(['Customer' => $this->getUser()]); + // agent_owned (エージェントコマースの CheckoutSession 所有) のカートは + // Web ストアフロントの操作対象から除外する。 + return $this->cartRepository->findBy(['Customer' => $this->getUser(), 'agent_owned' => false]); } /** @@ -114,7 +116,7 @@ public function getSessionCarts(): array return []; } - return $this->cartRepository->findBy(['cart_key' => $cartKeys], ['id' => 'ASC']); + return $this->cartRepository->findBy(['cart_key' => $cartKeys, 'agent_owned' => false], ['id' => 'ASC']); } /** diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php index 72463726d50..88aea024a8a 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php @@ -15,9 +15,11 @@ namespace Eccube\Tests\Service\AgentCommerce; +use Eccube\Entity\Cart; use Eccube\Entity\Customer; use Eccube\Entity\Master\AgentProtocol; use Eccube\Entity\ProductClass; +use Eccube\Repository\CartRepository; use Eccube\Service\AgentCommerce\AgentCheckoutPurchaseFlowAdapter; use Eccube\Service\AgentCommerce\CheckoutSession\AgentCheckoutAddress; use Eccube\Service\AgentCommerce\CheckoutSession\AgentCheckoutLineItem; @@ -119,6 +121,35 @@ public function testCompleteFinalizesOrder(): void $this->assertNotNull($result->order->getOrderNo(), 'complete で受注番号が採番される'); } + public function testAgentCartIsIsolatedFromWebStorefront(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 1)], + buyer: $this->guestAddress(), + protocolId: AgentProtocol::ACP, + ); + + $this->adapter->buildOrder($request); + // buildOrder が生成した agent_owned カートを cartRepository から特定する。 + $cartRepository = self::getContainer()->get(CartRepository::class); + + $agentCarts = $cartRepository->findBy(['agent_owned' => true]); + $this->assertNotEmpty($agentCarts, 'エージェント生成カートは agent_owned=true で保存される'); + foreach ($agentCarts as $agentCart) { + $this->assertTrue($agentCart->isAgentOwned(), 'agent_owned フラグが立つ'); + $this->assertNull($agentCart->getCustomer(), 'エージェントカートの customer_id は常に NULL (会員帰属は Order 側)'); + } + + // CartService が Web カート解決に用いる検索条件 (agent_owned=false) では拾われないこと。 + $webVisible = $cartRepository->findBy(['agent_owned' => false]); + $webVisibleIds = array_map(static fn (Cart $c): ?int => $c->getId(), $webVisible); + foreach ($agentCarts as $agentCart) { + $this->assertNotContains($agentCart->getId(), $webVisibleIds, 'エージェントカートは Web 解決条件 (agent_owned=false) に含まれない'); + } + } + public function testOverStockSurfacesAsMessageNotException(): void { $ProductClass = $this->createPurchasableProductClass('1'); From 875720aad38d4c9177ca9ee29b02a609c3b89106 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Fri, 12 Jun 2026 15:08:34 +0900 Subject: [PATCH 16/28] =?UTF-8?q?fix(agent-commerce):=20CheckoutSession=20?= =?UTF-8?q?=E3=81=AE=E6=9C=89=E5=8A=B9=E6=9C=9F=E9=99=90=E5=A2=83=E7=95=8C?= =?UTF-8?q?=E3=82=92=E6=9C=9F=E9=99=90=E5=88=87=E3=82=8C=E6=89=B1=E3=81=84?= =?UTF-8?q?=E3=81=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit expires_at と現在時刻が同値の瞬間だけ未失効になっていた `<` 判定を `<=` に変更し、境界を期限切れ扱いにする。境界値の回帰テストを追加。 CodeRabbit レビュー指摘 (PR #6825) 対応。 Co-Authored-By: Claude Opus 4.8 --- src/Eccube/Entity/CheckoutSession.php | 2 +- tests/Eccube/Tests/Entity/CheckoutSessionTest.php | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Eccube/Entity/CheckoutSession.php b/src/Eccube/Entity/CheckoutSession.php index 3e17c62364c..626836db8cf 100644 --- a/src/Eccube/Entity/CheckoutSession.php +++ b/src/Eccube/Entity/CheckoutSession.php @@ -360,7 +360,7 @@ public function setUpdateDate(\DateTime $update_date): CheckoutSession */ public function isExpired(\DateTime $now): bool { - return $this->expires_at !== null && $this->expires_at < $now; + return $this->expires_at !== null && $this->expires_at <= $now; } } } diff --git a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php index b19ef8f8dad..7942ef2e485 100644 --- a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php +++ b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php @@ -154,6 +154,9 @@ public function testIsExpired(): void $session->setExpiresAt(new \DateTime('2026-06-11 12:00:01')); $this->assertFalse($session->isExpired($now), 'expires_at が未来なら期限切れでない'); + + $session->setExpiresAt(new \DateTime('2026-06-11 12:00:00')); + $this->assertTrue($session->isExpired($now), 'expires_at と現在時刻が同値の境界は期限切れ扱い (<=)'); } public function testFindExpiredExcludesTerminalStatuses(): void From 6704a12193594030a5fef1fdb54b9663f1f61a6c Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Mon, 15 Jun 2026 13:09:36 +0900 Subject: [PATCH 17/28] =?UTF-8?q?fix(agent-commerce):=20PHP=208.2=20?= =?UTF-8?q?=E3=81=A7=20parse=20error=20=E3=81=AB=E3=81=AA=E3=82=8B?= =?UTF-8?q?=E5=8C=BF=E5=90=8D=20readonly=20=E3=82=AF=E3=83=A9=E3=82=B9?= =?UTF-8?q?=E3=82=92=E9=99=A4=E5=8E=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth2 Authenticator テストのスタブ handler が、rector の ReadOnlyAnonymousClassRector により `new readonly class` (匿名 readonly クラス) へ変換されていた。匿名 readonly クラスは PHP 8.3+ 専用で、 サポート下限 (PHP 8.2) の CI で `syntax error, unexpected token "readonly"` となり PHPUnit が失敗していた。 名前付き `final readonly class ScopeStubAccessTokenHandler` (8.2 で有効・ rector の匿名クラスルール対象外) へ切り出して恒久的に解消する。 Co-Authored-By: Claude Opus 4.8 --- .../AgentCommerceOAuth2AuthenticatorTest.php | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php index 368e0a57a65..ef9e643e0fe 100644 --- a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php @@ -47,17 +47,8 @@ protected function setUp(): void */ private function handlerWithScopes(array $scopes): AccessTokenHandlerInterface { - return new readonly class($scopes) implements AccessTokenHandlerInterface { - /** @param array $scopes */ - public function __construct(private array $scopes) - { - } - - public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge - { - return new UserBadge('agent-platform', null, ['scopes' => $this->scopes]); - } - }; + // 匿名 readonly クラスは PHP 8.3+ 専用のため (サポート下限 8.2)、名前付きクラスへ切り出す。 + return new ScopeStubAccessTokenHandler($scopes); } /** @@ -134,3 +125,22 @@ public function testHandlerUnavailableThrowsServiceUnavailable(): void $authenticator->authenticate('any-token', 'acp', 'checkout'); } } + +/** + * 付与 scope を attributes に載せて UserBadge を返すスタブ handler. + * + * 名前付き readonly クラス (PHP 8.2 で有効) として定義する。匿名 readonly クラスは + * PHP 8.3+ 専用で、サポート下限 (8.2) の CI で parse error になるため使用しない。 + */ +final readonly class ScopeStubAccessTokenHandler implements AccessTokenHandlerInterface +{ + /** @param array $scopes */ + public function __construct(private array $scopes) + { + } + + public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge + { + return new UserBadge('agent-platform', null, ['scopes' => $this->scopes]); + } +} From 2c0bbf9119762d1d90e3cf09b9976ee027bec122 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Mon, 15 Jun 2026 13:22:51 +0900 Subject: [PATCH 18/28] =?UTF-8?q?fix(agent-commerce):=20MySQL=20=E3=81=AE?= =?UTF-8?q?=20JSON=20=E3=82=AD=E3=83=BC=E9=A0=86=E5=BA=8F=E9=9D=9E?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=E3=81=AB=E5=AF=BE=E5=BF=9C=20(=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckoutSessionTest::testJsonColumnsRoundTrip が MySQL CI で失敗していた。 MySQL のネイティブ JSON 型は格納時にオブジェクトのキー順序を保持しない (PostgreSQL/SQLite は保持) ため、多キーの buyer_data に対する assertSame (順序厳密) が順序差で落ちていた。buyer_data はキーで参照する map で順序に 意味はないため、assertEqualsCanonicalizing で順序非依存に検証する。 Co-Authored-By: Claude Opus 4.8 --- tests/Eccube/Tests/Entity/CheckoutSessionTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php index 7942ef2e485..e3e60436696 100644 --- a/tests/Eccube/Tests/Entity/CheckoutSessionTest.php +++ b/tests/Eccube/Tests/Entity/CheckoutSessionTest.php @@ -118,7 +118,10 @@ public function testJsonColumnsRoundTrip(): void /** @var CheckoutSession $reloaded */ $reloaded = $this->checkoutSessionRepository->find($id); - $this->assertSame(['family_name' => '山田', 'given_name' => '太郎'], $reloaded->getBuyerData(), 'json buyer_data がラウンドトリップする'); + // MySQL のネイティブ JSON 型は格納時にオブジェクトのキー順序を保持しない + // (PostgreSQL/SQLite は保持)。buyer_data はキーで参照する map で順序に意味は + // ないため、順序非依存 (canonicalizing) でラウンドトリップを検証する。 + $this->assertEqualsCanonicalizing(['family_name' => '山田', 'given_name' => '太郎'], $reloaded->getBuyerData(), 'json buyer_data がラウンドトリップする'); $this->assertSame(['acp_status' => 'not_ready_for_payment'], $reloaded->getMetadata(), 'json metadata がラウンドトリップする'); $this->assertNull($reloaded->getFulfillmentData(), '未設定の json カラムは null'); } From ea02e06efa1017946a1146f0c6f76435f73c9755 Mon Sep 17 00:00:00 2001 From: Kentaro Ohkouchi Date: Mon, 15 Jun 2026 16:13:39 +0900 Subject: [PATCH 19/28] =?UTF-8?q?feat(agent-commerce):=20UCP=20checkout=20?= =?UTF-8?q?(#6574)=20=E3=81=AE=E3=82=B3=E3=82=A2=E5=AE=9F=E8=A3=85?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UCP (Universal Commerce Protocol v2026-04-08) の checkout capability を EC-CUBE 本体へ実装する。Phase 1b 共通基盤 (CheckoutSession/AgentCheckoutPurchaseFlowAdapter) の上に積み、通常購入と同一の shopping flow を再利用する。 - UcpCheckoutController: 5 エンドポイント (create/get/update/complete/cancel)。 プロトコル系=HTTP 4xx/5xx + Error、ビジネス系=HTTP 200 + messages[] の 2 系統エラー。 session 所有チェック・ucp_checkout_enabled ゲート (無効時 404)。 - インバウンド認証 = RFC 9421 HTTP Message Signatures (api4 非依存)。 UcpRequestSignatureVerifier (ES256/EC P-256・Content-Digest・ドメイン許可リスト)、 UcpProfileFetcher (HTTPS・3xx 追従禁止で signing_keys[] 取得)、署名サブスクライバ。 - マッピング: UcpCheckoutSessionMapper (totals 符号制約・代引 fee・links・暫定見積)、 UcpStatusMapper (ready↔ready_for_complete)、UcpMessageMapper (severity)。 - core 共通: AgentCheckoutPaymentHandlerRegistry + UcpPaymentHandlerInterface (具象は決済プラグインが tag で寄与)、AgentCheckoutIdempotencyStore (replay/409)。 - AgentCheckoutPurchaseFlowAdapter.complete を明示トランザクションで囲む (StockReduceProcessor の悲観ロック対応)、kana 未指定を空文字へ正規化。 検証: AgentCommerce 135 tests / 0 失敗 (incomplete 6 は意図的)、PurchaseFlow/OrderHelper 265 tests 回帰 green、PHPStan level 6 No errors、php-cs-fixer 0 件。 Co-Authored-By: Claude Opus 4.8 --- app/config/eccube/services.yaml | 24 ++ app/config/eccube/services_test.yaml | 34 ++ composer.json | 1 + composer.lock | 185 +++++++- .../AgentCommerce/UcpCheckoutController.php | 407 ++++++++++++++++++ .../AgentCommerce/AddressMappingService.php | 18 + .../AgentCheckoutPurchaseFlowAdapter.php | 37 +- .../IdempotencyConflictException.php | 26 ++ .../Exception/UcpSignatureException.php | 26 ++ .../AgentCheckoutIdempotencyStore.php | 100 +++++ .../AgentCheckoutPaymentHandlerRegistry.php | 80 ++++ .../Payment/UcpPaymentHandlerInterface.php | 47 ++ .../AgentCommerce/StorefrontUrlResolver.php | 60 +++ .../Signature/Rfc9421SignatureBaseBuilder.php | 97 +++++ .../Ucp/Signature/UcpAgentHeader.php | 62 +++ .../Ucp/Signature/UcpProfileFetcher.php | 105 +++++ .../Signature/UcpRequestSignatureVerifier.php | 266 ++++++++++++ .../Ucp/Signature/UcpSignatureSubscriber.php | 85 ++++ .../Ucp/UcpCheckoutSessionMapper.php | 387 +++++++++++++++++ .../AgentCommerce/Ucp/UcpMessageMapper.php | 89 ++++ .../AgentCommerce/Ucp/UcpStatusMapper.php | 46 ++ .../UcpCheckoutConformanceTest.php | 134 ++++++ .../AgentCheckoutIdempotencyStoreTest.php | 90 ++++ ...gentCheckoutPaymentHandlerRegistryTest.php | 100 +++++ .../Rfc9421SignatureBaseBuilderTest.php | 80 ++++ .../Ucp/Signature/UcpAgentHeaderTest.php | 50 +++ .../UcpRequestSignatureVerifierTest.php | 181 ++++++++ .../Ucp/UcpMessageMapperTest.php | 69 +++ .../AgentCommerce/Ucp/UcpStatusMapperTest.php | 69 +++ .../UcpCheckoutControllerTest.php | 184 ++++++++ 30 files changed, 3134 insertions(+), 5 deletions(-) create mode 100644 src/Eccube/Controller/AgentCommerce/UcpCheckoutController.php create mode 100644 src/Eccube/Service/AgentCommerce/Exception/IdempotencyConflictException.php create mode 100644 src/Eccube/Service/AgentCommerce/Exception/UcpSignatureException.php create mode 100644 src/Eccube/Service/AgentCommerce/Idempotency/AgentCheckoutIdempotencyStore.php create mode 100644 src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerRegistry.php create mode 100644 src/Eccube/Service/AgentCommerce/Payment/UcpPaymentHandlerInterface.php create mode 100644 src/Eccube/Service/AgentCommerce/StorefrontUrlResolver.php create mode 100644 src/Eccube/Service/AgentCommerce/Ucp/Signature/Rfc9421SignatureBaseBuilder.php create mode 100644 src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpAgentHeader.php create mode 100644 src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpProfileFetcher.php create mode 100644 src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpRequestSignatureVerifier.php create mode 100644 src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpSignatureSubscriber.php create mode 100644 src/Eccube/Service/AgentCommerce/Ucp/UcpCheckoutSessionMapper.php create mode 100644 src/Eccube/Service/AgentCommerce/Ucp/UcpMessageMapper.php create mode 100644 src/Eccube/Service/AgentCommerce/Ucp/UcpStatusMapper.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Conformance/UcpCheckoutConformanceTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Idempotency/AgentCheckoutIdempotencyStoreTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerRegistryTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Ucp/Signature/Rfc9421SignatureBaseBuilderTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Ucp/Signature/UcpAgentHeaderTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Ucp/Signature/UcpRequestSignatureVerifierTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Ucp/UcpMessageMapperTest.php create mode 100644 tests/Eccube/Tests/Service/AgentCommerce/Ucp/UcpStatusMapperTest.php create mode 100644 tests/Eccube/Tests/Web/AgentCommerce/UcpCheckoutControllerTest.php diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index 4cbc1796ae1..ab72dbd89ce 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -263,3 +263,27 @@ services: Eccube\Service\AgentCommerce\Security\AgentCommerceOAuth2Authenticator: arguments: $accessTokenHandler: '@?Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface' + + # Agent Commerce 決済ハンドラ (#6574 UCP / #6776 ACP) + # 具象ハンドラは決済プラグインが agent_commerce.payment_handler タグで寄与する。 + _instanceof: + Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerInterface: + tags: ['agent_commerce.payment_handler'] + + Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerRegistry: + arguments: + $handlers: !tagged_iterator agent_commerce.payment_handler + + # 冪等性記録は標準キャッシュ (FilesystemAdapter フォールバック成立) に保管する。 + Eccube\Service\AgentCommerce\Idempotency\AgentCheckoutIdempotencyStore: + arguments: + $cache: '@cache.app' + + # UCP インバウンド署名検証 (RFC 9421・api4 非依存)。許可ドメイン/必須化は運用で設定する。 + Eccube\Service\AgentCommerce\Ucp\Signature\UcpRequestSignatureVerifier: + arguments: + $allowedDomains: [] + + Eccube\Service\AgentCommerce\Ucp\Signature\UcpSignatureSubscriber: + arguments: + $requireSignature: false diff --git a/app/config/eccube/services_test.yaml b/app/config/eccube/services_test.yaml index 9a8c18be6b5..d13ce9ddda8 100644 --- a/app/config/eccube/services_test.yaml +++ b/app/config/eccube/services_test.yaml @@ -95,3 +95,37 @@ services: Eccube\Service\AgentCommerce\CheckoutSession\GuestCustomerResolver: autowire: true public: true + # UCP checkout (#6574) サービス群。テストからコンテナ取得するため public 化する。 + Eccube\Service\AgentCommerce\Ucp\UcpCheckoutSessionMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\UcpMessageMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\UcpStatusMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\Signature\Rfc9421SignatureBaseBuilder: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\Signature\UcpProfileFetcher: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\Signature\UcpRequestSignatureVerifier: + autowire: true + public: true + arguments: + $allowedDomains: [] + Eccube\Service\AgentCommerce\Idempotency\AgentCheckoutIdempotencyStore: + autowire: true + public: true + arguments: + $cache: '@cache.app' + Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerRegistry: + autowire: true + public: true + arguments: + $handlers: !tagged_iterator agent_commerce.payment_handler + Eccube\Service\AgentCommerce\StorefrontUrlResolver: + autowire: true + public: true diff --git a/composer.json b/composer.json index 164da6f4c8c..951c670b2e6 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ "symfony/flex": "^2.7", "symfony/form": "^7.4", "symfony/framework-bundle": "^7.4", + "symfony/http-client": "^7.4", "symfony/http-foundation": "^7.4", "symfony/http-kernel": "^7.4", "symfony/intl": "^7.4", diff --git a/composer.lock b/composer.lock index da58ccaadef..3be1bc8f1d3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "aa5a33881c14453ea071a6d0c574e9f2", + "content-hash": "76e63ee4dd513de6bb09e6d61cab8c7e", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -7539,6 +7539,189 @@ ], "time": "2026-03-30T12:55:43+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T09:57:54+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:50+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.4.13", diff --git a/src/Eccube/Controller/AgentCommerce/UcpCheckoutController.php b/src/Eccube/Controller/AgentCommerce/UcpCheckoutController.php new file mode 100644 index 00000000000..73011efb610 --- /dev/null +++ b/src/Eccube/Controller/AgentCommerce/UcpCheckoutController.php @@ -0,0 +1,407 @@ + 201 + * - GET /ucp/checkout-sessions/{id} (get) -> 200 + * - PUT /ucp/checkout-sessions/{id} (update) -> 200 (全置換) + * - POST /ucp/checkout-sessions/{id}/complete -> 200 (order) + * - POST /ucp/checkout-sessions/{id}/cancel -> 200 (canceled) + * + * 設計: + * - セッションレスなエージェント向けに {@link CheckoutSession} で Cart/Order を束ねる。 + * - 見積/確定は通常購入と同一の shopping flow を {@link AgentCheckoutPurchaseFlowAdapter} 経由で再利用。 + * - **2 系統エラー**: プロトコル系は HTTP 4xx/5xx + Error、ビジネス系は HTTP 200 + messages[]。 + * - インバウンド認証は RFC 9421 署名 (UcpSignatureSubscriber が適用、api4 非依存)。 + * - `ucp_checkout_enabled` が false のときは NotFoundHttpException (ルートは削除しない既存パターン)。 + * + * @see https://github.com/EC-CUBE/ec-cube/issues/6574 + * @see https://github.com/Universal-Commerce-Protocol/ucp UCP checkout-rest.md + */ +#[Route(path: '/ucp')] +class UcpCheckoutController extends AbstractController +{ + public function __construct( + private readonly BaseInfoRepository $baseInfoRepository, + private readonly CheckoutSessionRepository $checkoutSessionRepository, + private readonly AgentProtocolRepository $agentProtocolRepository, + private readonly CheckoutSessionStatusRepository $statusRepository, + private readonly AgentCheckoutPurchaseFlowAdapter $purchaseFlowAdapter, + private readonly UcpCheckoutSessionMapper $mapper, + private readonly UcpMessageMapper $messageMapper, + private readonly AgentCheckoutIdempotencyStore $idempotencyStore, + private readonly AgentCheckoutPaymentHandlerRegistry $paymentHandlerRegistry, + private readonly CustomerResolverInterface $customerResolver, + ) { + } + + #[Route(path: '/checkout-sessions', name: 'ucp_checkout_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $this->assertEnabled(); + + return $this->withIdempotency($request, function () use ($request): array { + try { + $payload = $this->decodeBody($request); + $checkoutRequest = $this->mapper->toCheckoutRequest($payload, $this->resolveAgentId($request)); + + $session = (new CheckoutSession()) + ->setSessionId($this->generateSessionId()) + ->setProtocol($this->agentProtocolRepository->find(AgentProtocol::UCP)) + ->setStatus($this->findStatus(CheckoutSessionStatus::INCOMPLETE)) + ->setCurrencyCode($checkoutRequest->currencyCode) + ->setMetadata(['line_items' => $this->lineItemsMetadata($checkoutRequest)]); + + return ['status' => 201, 'body' => $this->buildAndPersist($session, $checkoutRequest)]; + } catch (AgentCheckoutException $e) { + return $this->protocolError(422, $e->getErrorCode()->value, $e->getMessage()); + } + }); + } + + #[Route(path: '/checkout-sessions/{sessionId}', name: 'ucp_checkout_get', methods: ['GET'])] + public function get(string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($sessionId); + + $order = $session->getOrder(); + if ($order !== null) { + return new JsonResponse($this->mapper->buildResponseFromOrder($session, $order, []), 200); + } + + return new JsonResponse($this->mapper->buildProvisionalResponse($session, $this->provisionalRequestFromSession($session), []), 200); + } + + #[Route(path: '/checkout-sessions/{sessionId}', name: 'ucp_checkout_update', methods: ['PUT'])] + public function update(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($sessionId); + + return $this->withIdempotency($request, function () use ($request, $session): array { + try { + $payload = $this->decodeBody($request); + $checkoutRequest = $this->mapper->toCheckoutRequest($payload, $this->resolveAgentId($request)); + + // 全置換セマンティクス: 明細スナップショットを差し替える。 + $session->setMetadata(array_merge($session->getMetadata() ?? [], ['line_items' => $this->lineItemsMetadata($checkoutRequest)])); + + return ['status' => 200, 'body' => $this->buildAndPersist($session, $checkoutRequest)]; + } catch (AgentCheckoutException $e) { + return $this->protocolError(422, $e->getErrorCode()->value, $e->getMessage()); + } + }); + } + + #[Route(path: '/checkout-sessions/{sessionId}/complete', name: 'ucp_checkout_complete', methods: ['POST'])] + public function complete(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($sessionId); + + return $this->withIdempotency($request, function () use ($request, $session): array { + $order = $session->getOrder(); + if ($order === null || $session->getStatus()?->getId() !== CheckoutSessionStatus::READY) { + // 確定不能な状態はプロトコル系エラー (4xx)。 + return $this->protocolError(422, 'invalid_session_state', 'The checkout session is not ready for completion.'); + } + + try { + $payload = $this->decodeBody($request); + $this->redeemPayment($order, $payload); + } catch (AgentCheckoutException $e) { + return $this->protocolError(422, $e->getErrorCode()->value, $e->getMessage()); + } + + $result = $this->purchaseFlowAdapter->complete($order, $this->customerResolver->resolve($session)); + $ucpMessages = $this->messageMapper->toUcpMessages($result->messages); + + if ($result->hasError()) { + // ビジネス系エラー: 確定せず HTTP 200 + messages[]。 + $this->entityManager->flush(); + + return ['status' => 200, 'body' => $this->mapper->buildResponseFromOrder($session, $order, $ucpMessages)]; + } + + $session->setStatus($this->findStatus(CheckoutSessionStatus::COMPLETED)); + $this->entityManager->flush(); + + return ['status' => 200, 'body' => $this->mapper->buildResponseFromOrder($session, $order, $ucpMessages)]; + }); + } + + #[Route(path: '/checkout-sessions/{sessionId}/cancel', name: 'ucp_checkout_cancel', methods: ['POST'])] + public function cancel(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($sessionId); + + return $this->withIdempotency($request, function () use ($session): array { + if ($session->getStatus()?->getId() === CheckoutSessionStatus::COMPLETED) { + return $this->protocolError(422, 'invalid_session_state', 'A completed checkout session cannot be canceled.'); + } + + $session->setStatus($this->findStatus(CheckoutSessionStatus::CANCELED)); + $this->entityManager->flush(); + + $order = $session->getOrder(); + $body = $order !== null + ? $this->mapper->buildResponseFromOrder($session, $order, []) + : $this->mapper->buildProvisionalResponse($session, $this->provisionalRequestFromSession($session), []); + + return ['status' => 200, 'body' => $body]; + }); + } + + /** + * 中立リクエストからセッションを確定 (見積) し、レスポンスボディを返す. + * + * 住所未確定なら暫定見積 + incomplete、住所ありなら shopping flow で再計算し ready/incomplete。 + * + * @return array + */ + private function buildAndPersist(CheckoutSession $session, AgentCheckoutRequest $checkoutRequest): array + { + if ($checkoutRequest->buyer === null) { + $session->setStatus($this->findStatus(CheckoutSessionStatus::INCOMPLETE)); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + $messages = [[ + 'type' => 'error', + 'severity' => 'recoverable', + 'content' => 'Shipping address is required to calculate shipping and complete checkout.', + 'content_type' => 'plain', + ]]; + + return $this->mapper->buildProvisionalResponse($session, $checkoutRequest, $messages); + } + + $result = $this->purchaseFlowAdapter->buildOrder($checkoutRequest, $this->customerResolver->resolve($session)); + $order = $result->order; + + $session + ->setOrder($order) + ->setBuyerData($this->buyerData($checkoutRequest->buyer)) + ->setStatus($this->findStatus($result->hasError() ? CheckoutSessionStatus::INCOMPLETE : CheckoutSessionStatus::READY)); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + return $this->mapper->buildResponseFromOrder($session, $order, $this->messageMapper->toUcpMessages($result->messages)); + } + + /** + * complete 時の支払トークン交換 + 与信/売上. + * + * payment.instruments[] と一致する UCP ハンドラが登録されていれば exchange→authorize→capture を実行する。 + * ハンドラ未登録 (代引・無償等) の場合は何もしない。具象ハンドラは決済プラグインが寄与する。 + * + * @param array $payload + */ + private function redeemPayment(\Eccube\Entity\Order $order, array $payload): void + { + $instrument = $payload['payment']['instruments'][0] ?? null; + if (!is_array($instrument)) { + return; + } + + $handlerId = is_string($instrument['handler_id'] ?? null) ? $instrument['handler_id'] : null; + if ($handlerId === null) { + return; + } + + $handler = $this->paymentHandlerRegistry->resolveUcpByHandlerId($handlerId); + if ($handler === null) { + return; + } + + $credential = is_array($instrument['credential'] ?? null) ? $instrument['credential'] : []; + $paymentData = $handler->exchangePaymentToken($credential); + $handler->authorize($order, $paymentData); + $handler->capture($order, $paymentData); + } + + /** + * Idempotency-Key を考慮してハンドラを実行し JsonResponse を返す. + * + * @param callable(): array{status: int, body: array} $handler + */ + private function withIdempotency(Request $request, callable $handler): JsonResponse + { + $key = $request->headers->get('Idempotency-Key'); + $requestHash = $this->idempotencyStore->hashRequest([ + 'path' => $request->getPathInfo(), + 'method' => $request->getMethod(), + 'body' => $request->getContent(), + ]); + + try { + $result = $this->idempotencyStore->execute($key, $requestHash, $handler); + } catch (IdempotencyConflictException $e) { + return new JsonResponse(['code' => 'idempotency_conflict', 'content' => $e->getMessage()], 409); + } + + return new JsonResponse($result['body'], $result['status']); + } + + /** + * @return array{status: int, body: array} + */ + private function protocolError(int $status, string $code, string $content): array + { + return ['status' => $status, 'body' => ['code' => $code, 'content' => $content]]; + } + + private function assertEnabled(): void + { + if (!$this->baseInfoRepository->get()->isUcpCheckoutEnabled()) { + throw new NotFoundHttpException('UCP checkout is not enabled.'); + } + } + + private function findSession(string $sessionId): CheckoutSession + { + $session = $this->checkoutSessionRepository->findOneBySessionId($sessionId); + if ($session === null || $session->getProtocol()?->getId() !== AgentProtocol::UCP) { + // 他プロトコル/他マーチャントのセッションへの越境アクセスを遮断する。 + throw new NotFoundHttpException(sprintf('Checkout session "%s" was not found.', $sessionId)); + } + + return $session; + } + + private function findStatus(int $id): CheckoutSessionStatus + { + /** @var CheckoutSessionStatus $status */ + $status = $this->statusRepository->find($id); + + return $status; + } + + /** + * @return array + */ + private function decodeBody(Request $request): array + { + $content = $request->getContent(); + if ($content === '') { + return []; + } + + try { + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, 'Malformed JSON request body.', $e); + } + + if (!is_array($decoded)) { + return []; + } + + // JSON オブジェクトのキーを文字列に正規化する (トップレベルが配列のとき int キーになり得る)。 + $normalized = []; + foreach ($decoded as $key => $value) { + $normalized[(string) $key] = $value; + } + + return $normalized; + } + + private function generateSessionId(): string + { + return 'ucp_cs_'.bin2hex(random_bytes(16)); + } + + private function resolveAgentId(Request $request): ?string + { + // 署名検証済みのエージェント profile URL があれば agent_id として用いる。 + $verified = $request->attributes->get('ucp_agent_profile'); + + return is_string($verified) ? $verified : $request->headers->get('UCP-Agent'); + } + + /** + * @return list + */ + private function lineItemsMetadata(AgentCheckoutRequest $checkoutRequest): array + { + return array_map( + static fn (AgentCheckoutLineItem $item): array => ['pc' => $item->productClassId, 'qty' => $item->quantity], + $checkoutRequest->lineItems + ); + } + + /** + * GET/cancel で Order 未生成のセッションを暫定表示するため、metadata から明細を復元する. + */ + private function provisionalRequestFromSession(CheckoutSession $session): AgentCheckoutRequest + { + $lineItems = []; + $stored = $session->getMetadata()['line_items'] ?? []; + if (is_array($stored)) { + foreach ($stored as $entry) { + if (is_array($entry) && isset($entry['pc'], $entry['qty'])) { + $lineItems[] = new AgentCheckoutLineItem((int) $entry['pc'], (int) $entry['qty']); + } + } + } + + return new AgentCheckoutRequest($lineItems, null, $session->getCurrencyCode(), AgentProtocol::UCP); + } + + /** + * 中立住所 DTO を buyer_data (echo 用) へ写す. + * + * @return array + */ + private function buyerData(AgentCheckoutAddress $address): array + { + return array_filter([ + 'family_name' => $address->name01, + 'given_name' => $address->name02, + 'email' => $address->email, + 'phone' => $address->phoneNumber, + ], static fn ($value): bool => $value !== null); + } +} diff --git a/src/Eccube/Service/AgentCommerce/AddressMappingService.php b/src/Eccube/Service/AgentCommerce/AddressMappingService.php index c286d14d76c..11c85d810aa 100644 --- a/src/Eccube/Service/AgentCommerce/AddressMappingService.php +++ b/src/Eccube/Service/AgentCommerce/AddressMappingService.php @@ -19,6 +19,7 @@ use Eccube\Entity\Master\Pref; use Eccube\Entity\Shipping; use Eccube\Repository\Master\CountryIsoCodeRepository; +use Eccube\Repository\Master\PrefRepository; /** * EC-CUBE の住所系エンティティ (Customer / CustomerAddress / Shipping) を @@ -33,6 +34,7 @@ class AddressMappingService { public function __construct( private readonly CountryIsoCodeRepository $countryIsoCodeRepository, + private readonly PrefRepository $prefRepository, ) { } @@ -61,6 +63,22 @@ public function getRegionFromPref(?Pref $pref): ?string return $pref->getName(); } + /** + * region 文字列 (都道府県名) から Pref を逆引きする。 + * + * UCP の postal address `region` は EC-CUBE では Pref 名 (例: 東京都) に対応する。 + * ISO 3166-2:JP コード (JP-13 等) での指定は標準では解決せず、app/Customize での + * 拡張余地とする (本サービスを decoration して解決ロジックを差し替え可能)。 + */ + public function getPrefFromRegion(?string $region): ?Pref + { + if ($region === null || $region === '') { + return null; + } + + return $this->prefRepository->findOneBy(['name' => $region]); + } + /** * 住所系エンティティを ACP / UCP の住所 DTO 相当の配列へ写す。 * diff --git a/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php index bec8cab17e1..a2d053f49ad 100644 --- a/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php +++ b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php @@ -100,11 +100,37 @@ public function buildOrder(AgentCheckoutRequest $request, ?Customer $member = nu */ public function complete(Order $Order, ?Customer $member = null): AgentCheckoutResult { - $messages = $this->runFlow(function (PurchaseContext $context) use ($Order): PurchaseFlowResult { + $connection = $this->entityManager->getConnection(); + + $messages = $this->runFlow(function (PurchaseContext $context) use ($Order, $connection): PurchaseFlowResult { $result = $this->shoppingPurchaseFlow->validate($Order, $context); - if (!$result->hasError()) { + if ($result->hasError()) { + return $result; + } + + // PurchaseFlow::prepare()/commit() 内の StockReduceProcessor が + // EntityManager::lock() (悲観ロック) を使うためトランザクションが必須。 + // 通常購入 (ShoppingController) と同様に、未開始なら自前で開始・コミットする。 + $startedTransaction = false; + if (!$connection->isTransactionActive()) { + $this->entityManager->beginTransaction(); + $startedTransaction = true; + } + + try { $this->shoppingPurchaseFlow->prepare($Order, $context); $this->shoppingPurchaseFlow->commit($Order, $context); + $this->entityManager->flush(); + + if ($startedTransaction) { + $this->entityManager->commit(); + } + } catch (\Throwable $e) { + if ($startedTransaction) { + $this->entityManager->rollback(); + } + + throw $e; } return $result; @@ -192,8 +218,11 @@ private function buildGuestCustomer(?AgentCheckoutAddress $address): Customer $Customer ->setName01((string) $address->name01) ->setName02((string) $address->name02) - ->setKana01($address->kana01) - ->setKana02($address->kana02) + // kana は UCP 等カナを持たないプロトコルでは null になり得る。OrderHelper が + // Shipping へコピーする際に Shipping::setKana01() が非 null string を要求するため、 + // null は空文字へ正規化する (カナ提供時はその値を使う)。 + ->setKana01($address->kana01 ?? '') + ->setKana02($address->kana02 ?? '') ->setCompanyName($address->companyName) ->setEmail($address->email) ->setPhonenumber($address->phoneNumber) diff --git a/src/Eccube/Service/AgentCommerce/Exception/IdempotencyConflictException.php b/src/Eccube/Service/AgentCommerce/Exception/IdempotencyConflictException.php new file mode 100644 index 00000000000..ab1d9e4d9e7 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Exception/IdempotencyConflictException.php @@ -0,0 +1,26 @@ += 24h) + */ +class AgentCheckoutIdempotencyStore +{ + /** + * @param CacheItemPoolInterface $cache 冪等性記録の保管先 (既定は cache.app) + * @param int $ttl 保管期間 (秒)。UCP は 24h 以上を要求するため既定 86400 + */ + public function __construct( + private readonly CacheItemPoolInterface $cache, + private readonly int $ttl = 86400, + ) { + } + + /** + * Idempotency-Key を考慮してハンドラを実行する. + * + * @param string|null $key Idempotency-Key ヘッダ値 (null/空ならキー無しとして都度実行) + * @param string $requestHash リクエスト内容のハッシュ (異パラメータ再利用検知用) + * @param callable(): array{status: int, body: array} $compute 初回に実行するハンドラ + * + * @return array{status: int, body: array, replayed: bool} + * + * @throws IdempotencyConflictException 同一キーが異なる requestHash で再利用された場合 + */ + public function execute(?string $key, string $requestHash, callable $compute): array + { + if ($key === null || $key === '') { + $result = $compute(); + + return ['status' => $result['status'], 'body' => $result['body'], 'replayed' => false]; + } + + $item = $this->cache->getItem($this->normalizeKey($key)); + if ($item->isHit()) { + /** @var array{requestHash: string, status: int, body: array} $stored */ + $stored = $item->get(); + if ($stored['requestHash'] !== $requestHash) { + throw new IdempotencyConflictException(sprintf('Idempotency-Key "%s" was reused with different request parameters.', $key)); + } + + return ['status' => $stored['status'], 'body' => $stored['body'], 'replayed' => true]; + } + + $result = $compute(); + + $item->set(['requestHash' => $requestHash, 'status' => $result['status'], 'body' => $result['body']]); + $item->expiresAfter($this->ttl); + $this->cache->save($item); + + return ['status' => $result['status'], 'body' => $result['body'], 'replayed' => false]; + } + + /** + * リクエスト内容から安定したハッシュを生成する (キー再利用検知用). + * + * @param array $payload + */ + public function hashRequest(array $payload): string + { + return hash('sha256', (string) json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * PSR-6 で予約されている文字 ({}()/\@:) を避けるためキーをハッシュ化する. + */ + private function normalizeKey(string $key): string + { + return 'agent_commerce_idempotency.'.hash('sha256', $key); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerRegistry.php b/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerRegistry.php new file mode 100644 index 00000000000..b7a3d22b3ca --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerRegistry.php @@ -0,0 +1,80 @@ + + */ + private readonly array $handlers; + + /** + * @param iterable $handlers タグ付きハンドラ群 + */ + public function __construct(iterable $handlers) + { + $this->handlers = is_array($handlers) ? array_values($handlers) : iterator_to_array($handlers, false); + } + + /** + * Order の支払方法を扱えるハンドラを返す (なければ null). + */ + public function resolveForOrder(Order $order): ?AgentCheckoutPaymentHandlerInterface + { + foreach ($this->handlers as $handler) { + if ($handler->supports($order)) { + return $handler; + } + } + + return null; + } + + /** + * UCP の handler_id に一致する UCP ハンドラを返す (なければ null). + */ + public function resolveUcpByHandlerId(string $handlerId): ?UcpPaymentHandlerInterface + { + foreach ($this->handlers as $handler) { + if ($handler instanceof UcpPaymentHandlerInterface && $handler->getHandlerId() === $handlerId) { + return $handler; + } + } + + return null; + } + + /** + * 登録済みの UCP ハンドラを返す (discovery の payment_handlers 広告に使用). + * + * @return list + */ + public function ucpHandlers(): array + { + return array_values(array_filter( + $this->handlers, + static fn (AgentCheckoutPaymentHandlerInterface $handler): bool => $handler instanceof UcpPaymentHandlerInterface + )); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Payment/UcpPaymentHandlerInterface.php b/src/Eccube/Service/AgentCommerce/Payment/UcpPaymentHandlerInterface.php new file mode 100644 index 00000000000..3c931d126b0 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Payment/UcpPaymentHandlerInterface.php @@ -0,0 +1,47 @@ + $credential payment.instruments[].credential の中立表現 (type/token 等) + * + * @return array 後続の authorize()/capture() へ渡す中立な支払データ + */ + public function exchangePaymentToken(array $credential): array; +} diff --git a/src/Eccube/Service/AgentCommerce/StorefrontUrlResolver.php b/src/Eccube/Service/AgentCommerce/StorefrontUrlResolver.php new file mode 100644 index 00000000000..ea56695b35e --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/StorefrontUrlResolver.php @@ -0,0 +1,60 @@ +urlGenerator->generate('help_privacy', [], UrlGeneratorInterface::ABSOLUTE_URL); + } + + /** + * 利用規約ページの絶対 URL. + */ + public function termsOfServiceUrl(): string + { + return $this->urlGenerator->generate('help_agreement', [], UrlGeneratorInterface::ABSOLUTE_URL); + } + + /** + * 注文の参照先 (permalink) 絶対 URL. + * + * UCP complete レスポンスの `order.permalink_url` に用いる。標準ではマイページの注文履歴 + * (`mypage_history`) を指す。ゲスト注文は会員ログインへ誘導されるが URL 自体は絶対 URL として有効。 + * 独自の注文確認ページへ差し替えたい場合は本サービスを decoration する。 + */ + public function orderPermalinkUrl(string $orderNo): string + { + return $this->urlGenerator->generate('mypage_history', ['order_no' => $orderNo], UrlGeneratorInterface::ABSOLUTE_URL); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Ucp/Signature/Rfc9421SignatureBaseBuilder.php b/src/Eccube/Service/AgentCommerce/Ucp/Signature/Rfc9421SignatureBaseBuilder.php new file mode 100644 index 00000000000..ea29e5781ca --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Ucp/Signature/Rfc9421SignatureBaseBuilder.php @@ -0,0 +1,97 @@ +": ` 行へ展開し、最後に `"@signature-params": ` 行を付す。 + * UCP checkout が署名対象とするコンポーネント (@method/@authority/@path、および + * ucp-agent/idempotency-key/content-digest/content-type ヘッダ) を解決できる。 + * + * @see https://www.rfc-editor.org/rfc/rfc9421 RFC 9421 Section 2.5 (Creating the Signature Base) + * @see https://github.com/Universal-Commerce-Protocol/ucp UCP signatures.md + */ +class Rfc9421SignatureBaseBuilder +{ + /** + * signature base を構築する. + * + * @param Request $request 署名対象のリクエスト + * @param list $coveredComponents covered component 識別子 (小文字・派生は先頭 @) + * @param string $signatureParams `@signature-params` の値 (例: `("@method" "@path");created=...;keyid="..."`) + * + * @throws \InvalidArgumentException 解決できない component が含まれる場合 + */ + public function build(Request $request, array $coveredComponents, string $signatureParams): string + { + $lines = []; + foreach ($coveredComponents as $component) { + $value = $this->resolveComponent($request, $component); + $lines[] = sprintf('"%s": %s', $component, $value); + } + + $lines[] = sprintf('"@signature-params": %s', $signatureParams); + + return implode("\n", $lines); + } + + /** + * covered component の値を解決する. + */ + private function resolveComponent(Request $request, string $component): string + { + return match ($component) { + '@method' => strtoupper($request->getMethod()), + '@authority' => strtolower($request->getHttpHost()), + '@path' => $this->path($request), + '@query' => '?'.($request->getQueryString() ?? ''), + '@scheme' => strtolower($request->getScheme()), + '@target-uri' => $request->getUri(), + default => $this->resolveHeader($request, $component), + }; + } + + /** + * 派生コンポーネント以外はヘッダ値として解決する (RFC 9421 では小文字のフィールド名). + */ + private function resolveHeader(Request $request, string $component): string + { + if (str_starts_with($component, '@')) { + throw new \InvalidArgumentException(sprintf('Unsupported derived component "%s".', $component)); + } + + $value = $request->headers->get($component); + if ($value === null) { + throw new \InvalidArgumentException(sprintf('Signed header "%s" is missing from the request.', $component)); + } + + // RFC 9421 Section 2.1: 先頭末尾の OWS を除去する (obs-fold は HTTP/1.1 で既に除去済み想定)。 + return trim($value); + } + + /** + * リクエストターゲットの絶対パス部分 (@path) を返す. + */ + private function path(Request $request): string + { + $requestUri = $request->getRequestUri(); + $queryPos = strpos($requestUri, '?'); + + return $queryPos === false ? $requestUri : substr($requestUri, 0, $queryPos); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpAgentHeader.php b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpAgentHeader.php new file mode 100644 index 00000000000..8fd0035d859 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpAgentHeader.php @@ -0,0 +1,62 @@ +profileUrl, PHP_URL_HOST); + + return is_string($host) ? strtolower($host) : ''; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpProfileFetcher.php b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpProfileFetcher.php new file mode 100644 index 00000000000..6e1bd5bd9ba --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpProfileFetcher.php @@ -0,0 +1,105 @@ +> 公開鍵 JWK の配列 + * + * @throws UcpSignatureException 取得失敗 / 文書不正 / 鍵が無い場合 + */ + public function fetchSigningKeys(string $profileUrl): array + { + if (parse_url($profileUrl, PHP_URL_SCHEME) !== 'https') { + throw new UcpSignatureException('Agent profile URL must be HTTPS.'); + } + + try { + $response = $this->httpClient->request('GET', $profileUrl, [ + // 鍵探索ではリダイレクトを追従しない (なりすまし防止)。 + 'max_redirects' => 0, + 'headers' => ['Accept' => 'application/json'], + ]); + + $statusCode = $response->getStatusCode(); + if ($statusCode !== 200) { + throw new UcpSignatureException(sprintf('Agent profile fetch returned HTTP %d (redirects are not followed).', $statusCode)); + } + + /** @var array $document */ + $document = $response->toArray(false); + } catch (UcpSignatureException $e) { + throw $e; + } catch (HttpClientExceptionInterface|\JsonException $e) { + throw new UcpSignatureException('Failed to fetch or parse the agent profile.', 0, $e); + } + + return $this->extractSigningKeys($document); + } + + /** + * プロファイル文書から signing_keys[] を抽出する. + * + * @param array $document + * + * @return list> + */ + private function extractSigningKeys(array $document): array + { + // signing_keys はラッパー直下、または ucp オブジェクト配下のいずれもあり得るため両対応。 + $signingKeys = $document['signing_keys'] ?? null; + if (!is_array($signingKeys) && isset($document['ucp']) && is_array($document['ucp'])) { + $signingKeys = $document['ucp']['signing_keys'] ?? null; + } + + if (!is_array($signingKeys) || $signingKeys === []) { + throw new UcpSignatureException('Agent profile does not advertise any signing_keys.'); + } + + $keys = []; + foreach ($signingKeys as $key) { + if (is_array($key) && isset($key['kty']) && $key['kty'] === 'EC') { + /** @var array $key */ + $keys[] = $key; + } + } + + if ($keys === []) { + throw new UcpSignatureException('Agent profile has no EC public keys in signing_keys.'); + } + + return $keys; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpRequestSignatureVerifier.php b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpRequestSignatureVerifier.php new file mode 100644 index 00000000000..0eba36e613c --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpRequestSignatureVerifier.php @@ -0,0 +1,266 @@ + 許可するエージェント profile ドメイン (空なら HTTPS である限り制限しない) + */ + private readonly array $allowedDomains; + + /** + * @param UcpProfileFetcher $profileFetcher エージェント公開鍵の取得元 + * @param Rfc9421SignatureBaseBuilder $signatureBaseBuilder signature base 構築 + * @param list $allowedDomains 許可ドメインのホスト名リスト + */ + public function __construct( + private readonly UcpProfileFetcher $profileFetcher, + private readonly Rfc9421SignatureBaseBuilder $signatureBaseBuilder, + array $allowedDomains = [], + ) { + $this->allowedDomains = array_map('strtolower', $allowedDomains); + } + + /** + * リクエストが RFC 9421 署名を提示しているか. + */ + public function isSigned(Request $request): bool + { + return $request->headers->has('Signature') && $request->headers->has('Signature-Input'); + } + + /** + * リクエストの署名を検証する. 失敗時は例外を投げる. + * + * @return UcpAgentHeader 検証済みのエージェント情報 + * + * @throws UcpSignatureException 署名欠落・不正・鍵不一致・Content-Digest 不一致・許可外ドメイン + */ + public function verify(Request $request): UcpAgentHeader + { + $ucpAgentValue = $request->headers->get('UCP-Agent'); + if ($ucpAgentValue === null || $ucpAgentValue === '') { + throw new UcpSignatureException('UCP-Agent header is required.'); + } + + try { + $agent = UcpAgentHeader::parse($ucpAgentValue); + } catch (\InvalidArgumentException $e) { + throw new UcpSignatureException($e->getMessage(), 0, $e); + } + + $this->assertDomainAllowed($agent->host()); + + if (!$this->isSigned($request)) { + throw new UcpSignatureException('Signature and Signature-Input headers are required.'); + } + + $this->verifyContentDigest($request); + + [$components, $signatureParams, $keyId] = $this->parseSignatureInput((string) $request->headers->get('Signature-Input')); + $rawSignature = $this->parseSignature((string) $request->headers->get('Signature')); + + try { + $signatureBase = $this->signatureBaseBuilder->build($request, $components, $signatureParams); + } catch (\InvalidArgumentException $e) { + throw new UcpSignatureException($e->getMessage(), 0, $e); + } + + $jwks = $this->profileFetcher->fetchSigningKeys($agent->profileUrl); + if (!$this->verifyAgainstKeys($signatureBase, $rawSignature, $jwks, $keyId)) { + throw new UcpSignatureException('RFC 9421 signature verification failed.'); + } + + return $agent; + } + + private function assertDomainAllowed(string $host): void + { + if ($this->allowedDomains === []) { + return; + } + + if (!in_array($host, $this->allowedDomains, true)) { + throw new UcpSignatureException(sprintf('Agent domain "%s" is not allowed.', $host)); + } + } + + /** + * Content-Digest を生ボディの SHA-256 と照合する (ボディが無ければスキップ). + */ + private function verifyContentDigest(Request $request): void + { + $body = $request->getContent(); + $header = $request->headers->get('Content-Digest'); + + if ($body === '' && $header === null) { + return; + } + + if ($header === null) { + throw new UcpSignatureException('Content-Digest header is required for requests with a body.'); + } + + // 形式: sha-256=:: + if (!preg_match('/sha-256=:([^:]+):/', $header, $matches)) { + throw new UcpSignatureException('Content-Digest must use the sha-256 algorithm.'); + } + + $expected = base64_encode(hash('sha256', $body, true)); + if (!hash_equals($expected, $matches[1])) { + throw new UcpSignatureException('Content-Digest does not match the request body.'); + } + } + + /** + * Signature-Input をパースして covered components / signature-params / keyid を返す. + * + * @return array{0: list, 1: string, 2: ?string} + */ + private function parseSignatureInput(string $headerValue): array + { + // 形式: