diff --git a/config/services.yaml b/config/services.yaml index 5f68083..f7a7d3d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -120,6 +120,8 @@ services: $adapters: !tagged_iterator app.channel_adapter App\Infrastructure\Channel\Ebay\EbayAdapter: + arguments: + $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' tags: ['app.channel_adapter'] App\Infrastructure\Channel\Ebay\EbayOAuthClient: @@ -134,6 +136,15 @@ services: $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' + App\Infrastructure\Channel\Ebay\EbayAccountApiClient: + arguments: + $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' + $marketplaceId: '%env(EBAY_MARKETPLACE_ID)%' + + App\Infrastructure\Channel\Ebay\EbayPolicyProvider: + arguments: + $cache: '@cache.app' + App\Infrastructure\Channel\Ebay\EbayTaxonomyService: arguments: $apiBaseUrl: '%env(EBAY_API_BASE_URL)%' diff --git a/migrations/Version20260519070206.php b/migrations/Version20260519070206.php new file mode 100644 index 0000000..df48523 --- /dev/null +++ b/migrations/Version20260519070206.php @@ -0,0 +1,42 @@ +addSql('ALTER TABLE app.article_type_ebay_mappings ALTER required DROP DEFAULT'); + $this->addSql('ALTER INDEX app.article_type_ebay_mappings_article_type_id_ebay_aspect_name_key RENAME TO uq_ebay_mapping'); + $this->addSql('ALTER TABLE app.article_type_platform_configs ADD fulfillment_policy_id VARCHAR(100) DEFAULT NULL'); + $this->addSql('ALTER TABLE app.article_type_platform_configs ADD payment_policy_id VARCHAR(100) DEFAULT NULL'); + $this->addSql('ALTER TABLE app.article_type_platform_configs ADD return_policy_id VARCHAR(100) DEFAULT NULL'); + $this->addSql('ALTER TABLE app.article_type_platform_configs ADD merchant_location_key VARCHAR(100) DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA logs_archive'); + $this->addSql('ALTER TABLE app.article_type_ebay_mappings ALTER required SET DEFAULT false'); + $this->addSql('ALTER INDEX app.uq_ebay_mapping RENAME TO article_type_ebay_mappings_article_type_id_ebay_aspect_name_key'); + $this->addSql('ALTER TABLE app.article_type_platform_configs DROP fulfillment_policy_id'); + $this->addSql('ALTER TABLE app.article_type_platform_configs DROP payment_policy_id'); + $this->addSql('ALTER TABLE app.article_type_platform_configs DROP return_policy_id'); + $this->addSql('ALTER TABLE app.article_type_platform_configs DROP merchant_location_key'); + } +} diff --git a/src/Domain/Channel/ArticleTypePlatformConfig.php b/src/Domain/Channel/ArticleTypePlatformConfig.php index e65bd33..a91b12c 100644 --- a/src/Domain/Channel/ArticleTypePlatformConfig.php +++ b/src/Domain/Channel/ArticleTypePlatformConfig.php @@ -30,6 +30,18 @@ class ArticleTypePlatformConfig #[ORM\Column(type: 'string', length: 255)] private string $categoryId; + #[ORM\Column(type: 'string', length: 100, nullable: true)] + private ?string $fulfillmentPolicyId = null; + + #[ORM\Column(type: 'string', length: 100, nullable: true)] + private ?string $paymentPolicyId = null; + + #[ORM\Column(type: 'string', length: 100, nullable: true)] + private ?string $returnPolicyId = null; + + #[ORM\Column(type: 'string', length: 100, nullable: true)] + private ?string $merchantLocationKey = null; + /** @var Collection */ #[ORM\OneToMany(mappedBy: 'platformConfig', targetEntity: AttributeMapping::class, cascade: ['persist', 'remove'])] private Collection $attributeMappings; @@ -68,6 +80,46 @@ class ArticleTypePlatformConfig $this->categoryId = $categoryId; } + public function getFulfillmentPolicyId(): ?string + { + return $this->fulfillmentPolicyId; + } + + public function setFulfillmentPolicyId(?string $fulfillmentPolicyId): void + { + $this->fulfillmentPolicyId = $fulfillmentPolicyId; + } + + public function getPaymentPolicyId(): ?string + { + return $this->paymentPolicyId; + } + + public function setPaymentPolicyId(?string $paymentPolicyId): void + { + $this->paymentPolicyId = $paymentPolicyId; + } + + public function getReturnPolicyId(): ?string + { + return $this->returnPolicyId; + } + + public function setReturnPolicyId(?string $returnPolicyId): void + { + $this->returnPolicyId = $returnPolicyId; + } + + public function getMerchantLocationKey(): ?string + { + return $this->merchantLocationKey; + } + + public function setMerchantLocationKey(?string $merchantLocationKey): void + { + $this->merchantLocationKey = $merchantLocationKey; + } + /** @return Collection */ public function getAttributeMappings(): Collection { diff --git a/src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php b/src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php index e20f96f..0a31063 100644 --- a/src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php +++ b/src/Domain/Channel/Repository/ArticleTypePlatformConfigRepositoryInterface.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Domain\Channel\Repository; +use App\Domain\Article\ArticleType; use App\Domain\Channel\ArticleTypePlatformConfig; use Symfony\Component\Uid\Uuid; @@ -16,6 +17,8 @@ interface ArticleTypePlatformConfigRepositoryInterface /** @return list */ public function findByArticleType(Uuid $articleTypeId): array; + public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig; + public function save(ArticleTypePlatformConfig $config): void; public function remove(ArticleTypePlatformConfig $config): void; diff --git a/src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php b/src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php new file mode 100644 index 0000000..8c12f2e --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayAccountApiClient.php @@ -0,0 +1,80 @@ + + */ + public function getFulfillmentPolicies(): array + { + $data = $this->request('GET', self::ACCOUNT_BASE.'/fulfillment_policy?marketplace_id='.$this->marketplaceId); + + /** @var list */ + return $data['fulfillmentPolicies'] ?? []; + } + + /** + * @return list + */ + public function getPaymentPolicies(): array + { + $data = $this->request('GET', self::ACCOUNT_BASE.'/payment_policy?marketplace_id='.$this->marketplaceId); + + /** @var list */ + return $data['paymentPolicies'] ?? []; + } + + /** + * @return list + */ + public function getReturnPolicies(): array + { + $data = $this->request('GET', self::ACCOUNT_BASE.'/return_policy?marketplace_id='.$this->marketplaceId); + + /** @var list */ + return $data['returnPolicies'] ?? []; + } + + /** + * @return array + */ + private function request(string $method, string $path): array + { + $token = $this->oauthClient->getAccessToken(); + + $response = $this->httpClient->request($method, $this->apiBaseUrl.$path, [ + 'headers' => [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'X-EBAY-C-MARKETPLACE-ID' => $this->marketplaceId, + ], + ]); + + $statusCode = $response->getStatusCode(); + if ($statusCode >= 400) { + $content = $response->getContent(false); + throw new \RuntimeException("eBay Account API error {$statusCode}: {$content}"); + } + + /** @var array $data */ + $data = $response->toArray(); + + return $data; + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayAdapter.php b/src/Infrastructure/Channel/Ebay/EbayAdapter.php index 5e4b4a7..1760e42 100644 --- a/src/Infrastructure/Channel/Ebay/EbayAdapter.php +++ b/src/Infrastructure/Channel/Ebay/EbayAdapter.php @@ -7,12 +7,16 @@ namespace App\Infrastructure\Channel\Ebay; use App\Application\Channel\ChannelAdapterInterface; use App\Domain\Article\Article; use App\Domain\Article\ArticleCondition; +use App\Domain\Article\ArticleTypeEbayMapping; +use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface; use App\Domain\Order\Order; final class EbayAdapter implements ChannelAdapterInterface { public function __construct( private readonly EbayInventoryApiClient $apiClient, + private readonly ArticleTypePlatformConfigRepositoryInterface $platformConfigRepository, + private readonly string $marketplaceId, ) { } @@ -24,6 +28,14 @@ final class EbayAdapter implements ChannelAdapterInterface public function publishListing(Article $article): string { $sku = $article->getSku(); + $config = $this->platformConfigRepository->findByArticleTypeAndPlatformType( + $article->getArticleType(), + 'ebay' + ); + + if (null === $config) { + throw new \RuntimeException(\sprintf('No eBay platform config found for ArticleType "%s"', $article->getArticleType()->getName())); + } $this->apiClient->upsertInventoryItem($sku, [ 'availability' => [ @@ -40,11 +52,18 @@ final class EbayAdapter implements ChannelAdapterInterface ], ]); - $offerId = $this->apiClient->createOffer([ + $listingPolicies = array_filter([ + 'fulfillmentPolicyId' => $config->getFulfillmentPolicyId(), + 'paymentPolicyId' => $config->getPaymentPolicyId(), + 'returnPolicyId' => $config->getReturnPolicyId(), + ]); + + $offerBody = [ 'sku' => $sku, - 'marketplaceId' => 'EBAY_DE', + 'marketplaceId' => $this->marketplaceId, 'format' => 'FIXED_PRICE', 'availableQuantity' => $article->getStock(), + 'categoryId' => $config->getCategoryId(), 'pricingSummary' => [ 'price' => [ 'currency' => 'EUR', @@ -52,8 +71,17 @@ final class EbayAdapter implements ChannelAdapterInterface ], ], 'listingDescription' => $article->getEbayDescription() ?? '', - 'categoryId' => $this->getCategoryId(), - ]); + ]; + + if ([] !== $listingPolicies) { + $offerBody['listingPolicies'] = $listingPolicies; + } + + if (null !== $config->getMerchantLocationKey()) { + $offerBody['merchantLocationKey'] = $config->getMerchantLocationKey(); + } + + $offerId = $this->apiClient->createOffer($offerBody); return $this->apiClient->publishOffer($offerId); } @@ -112,7 +140,6 @@ final class EbayAdapter implements ChannelAdapterInterface { $aspects = []; - // Index attribute values by definition ID for O(1) lookup $valuesByDefId = []; foreach ($article->getAttributeValues() as $value) { $valuesByDefId[$value->getAttributeDefinition()->getId()->toRfc4122()] = $value->getValue(); @@ -121,7 +148,7 @@ final class EbayAdapter implements ChannelAdapterInterface foreach ($article->getArticleType()->getEbayMappings() as $mapping) { $ebayName = $mapping->getEbayAspectName(); - if ($mapping->getSourceType() === \App\Domain\Article\ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD) { + if (ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD === $mapping->getSourceType()) { $getter = 'get'.ucfirst((string) $mapping->getArticleFieldKey()); if (!method_exists($article, $getter)) { continue; @@ -144,9 +171,4 @@ final class EbayAdapter implements ChannelAdapterInterface return $aspects; } - - private function getCategoryId(): string - { - return '177'; - } } diff --git a/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php b/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php index e667bc8..333b40e 100644 --- a/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php +++ b/src/Infrastructure/Channel/Ebay/EbayInventoryApiClient.php @@ -74,6 +74,17 @@ class EbayInventoryApiClient $this->request('POST', self::INVENTORY_BASE.'/bulk_update_price_quantity', $quantityUpdate); } + /** + * @return list + */ + public function getLocations(): array + { + $data = $this->request('GET', self::INVENTORY_BASE.'/location', []); + + /** @var list */ + return $data['locations'] ?? []; + } + /** * Adds tracking info to an order. * diff --git a/src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php b/src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php new file mode 100644 index 0000000..33bd9ba --- /dev/null +++ b/src/Infrastructure/Channel/Ebay/EbayPolicyProvider.php @@ -0,0 +1,104 @@ + "Name (ID)" => policyId + */ + public function getFulfillmentChoices(): array + { + return $this->cached('ebay_policy_fulfillment', function (): array { + $choices = []; + foreach ($this->accountClient->getFulfillmentPolicies() as $policy) { + $choices[$policy['name'].' ('.$policy['fulfillmentPolicyId'].')'] = $policy['fulfillmentPolicyId']; + } + + return $choices; + }); + } + + /** + * @return array "Name (ID)" => policyId + */ + public function getPaymentChoices(): array + { + return $this->cached('ebay_policy_payment', function (): array { + $choices = []; + foreach ($this->accountClient->getPaymentPolicies() as $policy) { + $choices[$policy['name'].' ('.$policy['paymentPolicyId'].')'] = $policy['paymentPolicyId']; + } + + return $choices; + }); + } + + /** + * @return array "Name (ID)" => policyId + */ + public function getReturnChoices(): array + { + return $this->cached('ebay_policy_return', function (): array { + $choices = []; + foreach ($this->accountClient->getReturnPolicies() as $policy) { + $choices[$policy['name'].' ('.$policy['returnPolicyId'].')'] = $policy['returnPolicyId']; + } + + return $choices; + }); + } + + /** + * @return array "Name (key)" => merchantLocationKey + */ + public function getLocationChoices(): array + { + return $this->cached('ebay_policy_location', function (): array { + $choices = []; + foreach ($this->inventoryClient->getLocations() as $location) { + $choices[$location['name'].' ('.$location['merchantLocationKey'].')'] = $location['merchantLocationKey']; + } + + return $choices; + }); + } + + /** + * @param callable(): array $loader + * + * @return array + */ + private function cached(string $key, callable $loader): array + { + try { + $item = $this->cache->getItem($key); + if ($item->isHit()) { + /** @var array */ + return $item->get(); + } + + $choices = $loader(); + $item->set($choices); + $item->expiresAfter(self::TTL); + $this->cache->save($item); + + return $choices; + } catch (\Throwable) { + return []; + } + } +} diff --git a/src/Infrastructure/Http/Controller/Admin/DashboardController.php b/src/Infrastructure/Http/Controller/Admin/DashboardController.php index ab2772a..d8614e3 100644 --- a/src/Infrastructure/Http/Controller/Admin/DashboardController.php +++ b/src/Infrastructure/Http/Controller/Admin/DashboardController.php @@ -71,6 +71,9 @@ final class DashboardController extends AbstractDashboardController yield MenuItem::linkTo(TranslationCrudController::class, $t('menu.translations'), 'fa fa-language'); yield MenuItem::linkTo(UserCrudController::class, $t('menu.users'), 'fa fa-users'); yield MenuItem::linkTo(LogEntryCrudController::class, $t('menu.logs'), 'fa fa-list'); + yield MenuItem::subMenu($t('menu.section_ebay'), 'fa fa-store')->setSubItems([ + MenuItem::linkTo(EbayArticleTypePlatformConfigCrudController::class, $t('menu.ebay_platform_configs'), 'fa fa-sliders'), + ]); yield MenuItem::section($t('menu.section_sales')); yield MenuItem::linkTo(OrderCrudController::class, $t('menu.orders'), 'fa fa-shopping-cart'); yield MenuItem::linkTo(CustomerCrudController::class, $t('menu.customers'), 'fa fa-users'); diff --git a/src/Infrastructure/Http/Controller/Admin/EbayArticleTypePlatformConfigCrudController.php b/src/Infrastructure/Http/Controller/Admin/EbayArticleTypePlatformConfigCrudController.php new file mode 100644 index 0000000..b3d9bd2 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/EbayArticleTypePlatformConfigCrudController.php @@ -0,0 +1,100 @@ + */ +final class EbayArticleTypePlatformConfigCrudController extends AbstractCrudController +{ + public function __construct(private readonly EbayPolicyProvider $policyProvider) + { + } + + public static function getEntityFqcn(): string + { + return ArticleTypePlatformConfig::class; + } + + public function configureCrud(Crud $crud): Crud + { + return $crud + ->setEntityLabelInSingular('eBay Kategorie-Konfiguration') + ->setEntityLabelInPlural('eBay Kategorie-Konfigurationen') + ->setDefaultSort(['id' => 'ASC']); + } + + public function createIndexQueryBuilder(SearchDto $searchDto, EntityDto $entityDto, FieldCollection $fields, FilterCollection $filters): QueryBuilder + { + $qb = $this->container->get(EntityRepository::class)->createQueryBuilder($searchDto, $entityDto, $fields, $filters); + $qb->join(\sprintf('%s.platform', $qb->getRootAliases()[0]), 'ebay_platform') + ->andWhere('ebay_platform.type = :platformType') + ->setParameter('platformType', 'ebay'); + + return $qb; + } + + public function configureFields(string $pageName): iterable + { + yield AssociationField::new('articleType', 'Artikel-Typ'); + yield TextField::new('categoryId', 'eBay Kategorie-ID'); + + $fulfillmentChoices = $this->policyProvider->getFulfillmentChoices(); + if ([] !== $fulfillmentChoices) { + yield ChoiceField::new('fulfillmentPolicyId', 'Versand-Policy') + ->setChoices($fulfillmentChoices) + ->setRequired(false); + } else { + yield TextField::new('fulfillmentPolicyId', 'Versand-Policy (ID)') + ->setHelp('eBay-API nicht erreichbar — ID manuell eingeben') + ->setRequired(false); + } + + $paymentChoices = $this->policyProvider->getPaymentChoices(); + if ([] !== $paymentChoices) { + yield ChoiceField::new('paymentPolicyId', 'Zahlungs-Policy') + ->setChoices($paymentChoices) + ->setRequired(false); + } else { + yield TextField::new('paymentPolicyId', 'Zahlungs-Policy (ID)') + ->setHelp('eBay-API nicht erreichbar — ID manuell eingeben') + ->setRequired(false); + } + + $returnChoices = $this->policyProvider->getReturnChoices(); + if ([] !== $returnChoices) { + yield ChoiceField::new('returnPolicyId', 'Rückgabe-Policy') + ->setChoices($returnChoices) + ->setRequired(false); + } else { + yield TextField::new('returnPolicyId', 'Rückgabe-Policy (ID)') + ->setHelp('eBay-API nicht erreichbar — ID manuell eingeben') + ->setRequired(false); + } + + $locationChoices = $this->policyProvider->getLocationChoices(); + if ([] !== $locationChoices) { + yield ChoiceField::new('merchantLocationKey', 'Lagerstandort') + ->setChoices($locationChoices) + ->setRequired(false); + } else { + yield TextField::new('merchantLocationKey', 'Lagerstandort (Key)') + ->setHelp('eBay-API nicht erreichbar — Key manuell eingeben') + ->setRequired(false); + } + } +} diff --git a/src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php b/src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php index ddbd462..8b4a4d7 100644 --- a/src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php +++ b/src/Infrastructure/Persistence/Repository/DoctrineArticleTypePlatformConfigRepository.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Infrastructure\Persistence\Repository; +use App\Domain\Article\ArticleType; use App\Domain\Channel\ArticleTypePlatformConfig; use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface; use Doctrine\ORM\EntityManagerInterface; @@ -46,6 +47,21 @@ final class DoctrineArticleTypePlatformConfigRepository implements ArticleTypePl ->getResult(); } + public function findByArticleTypeAndPlatformType(ArticleType $articleType, string $platformType): ?ArticleTypePlatformConfig + { + /** @var ArticleTypePlatformConfig|null */ + return $this->em->getRepository(ArticleTypePlatformConfig::class) + ->createQueryBuilder('c') + ->join('c.platform', 'p') + ->where('c.articleType = :articleType') + ->andWhere('p.type = :platformType') + ->setParameter('articleType', $articleType) + ->setParameter('platformType', $platformType) + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + public function save(ArticleTypePlatformConfig $config): void { $this->em->persist($config); diff --git a/tests/Integration/Infrastructure/Channel/Ebay/EbayAdapterIntegrationTest.php b/tests/Integration/Infrastructure/Channel/Ebay/EbayAdapterIntegrationTest.php index 1fb86f4..c53144e 100644 --- a/tests/Integration/Infrastructure/Channel/Ebay/EbayAdapterIntegrationTest.php +++ b/tests/Integration/Infrastructure/Channel/Ebay/EbayAdapterIntegrationTest.php @@ -7,6 +7,9 @@ namespace App\Tests\Integration\Infrastructure\Channel\Ebay; use App\Domain\Article\Article; use App\Domain\Article\ArticleCondition; use App\Domain\Article\ArticleType; +use App\Domain\Channel\ArticleTypePlatformConfig; +use App\Domain\Channel\Platform; +use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface; use App\Infrastructure\Channel\Ebay\EbayAdapter; use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient; use App\Infrastructure\Channel\Ebay\EbayOAuthClient; @@ -35,45 +38,47 @@ final class EbayAdapterIntegrationTest extends TestCase private string $createdListingId = ''; private bool $userTokenAvailable = false; + private static function env(string $key, string $default = ''): string + { + $val = $_SERVER[$key] ?? getenv($key); + + return \is_string($val) ? $val : $default; + } + protected function setUp(): void { - $clientId = $_SERVER['EBAY_CLIENT_ID'] ?? getenv('EBAY_CLIENT_ID'); - $clientSecret = $_SERVER['EBAY_CLIENT_SECRET'] ?? getenv('EBAY_CLIENT_SECRET'); - $apiBaseUrl = $_SERVER['EBAY_API_BASE_URL'] ?? getenv('EBAY_API_BASE_URL'); - $oauthBaseUrl = $_SERVER['EBAY_OAUTH_BASE_URL'] ?? getenv('EBAY_OAUTH_BASE_URL'); - $marketplaceId = $_SERVER['EBAY_MARKETPLACE_ID'] ?? getenv('EBAY_MARKETPLACE_ID') ?: 'EBAY_DE'; + $clientId = self::env('EBAY_CLIENT_ID'); + $clientSecret = self::env('EBAY_CLIENT_SECRET'); + $apiBaseUrl = self::env('EBAY_API_BASE_URL'); + $oauthBaseUrl = self::env('EBAY_OAUTH_BASE_URL'); + $marketplaceId = self::env('EBAY_MARKETPLACE_ID', 'EBAY_DE'); - if (!$clientId || !$clientSecret || !$apiBaseUrl || !$oauthBaseUrl) { + if ('' === $clientId || '' === $clientSecret || '' === $apiBaseUrl || '' === $oauthBaseUrl) { $this->markTestSkipped('EBAY_* env vars not set'); } $http = HttpClient::create(); $cache = new ArrayAdapter(); - $oauth = new EbayOAuthClient( - $http, - $cache, - (string) $clientId, - (string) $clientSecret, - (string) $oauthBaseUrl, - ); + $oauth = new EbayOAuthClient($http, $cache, $clientId, $clientSecret, $oauthBaseUrl); - $this->apiClient = new EbayInventoryApiClient( - $http, - $oauth, - (string) $apiBaseUrl, - (string) $marketplaceId, - ); + $this->apiClient = new EbayInventoryApiClient($http, $oauth, $apiBaseUrl, $marketplaceId); - $this->adapter = new EbayAdapter($this->apiClient); + $articleType = new ArticleType('Notebook'); + $platform = new Platform('ebay', 'eBay'); + // Sandbox category 177 = Notebooks; no policies needed for sandbox withdraw/stock tests + $platformConfig = new ArticleTypePlatformConfig($articleType, $platform, '177'); - $userToken = $_SERVER['EBAY_USER_TOKEN'] ?? getenv('EBAY_USER_TOKEN'); - $this->userTokenAvailable = (bool) $userToken; + $configRepo = $this->createStub(ArticleTypePlatformConfigRepositoryInterface::class); + $configRepo->method('findByArticleTypeAndPlatformType')->willReturn($platformConfig); - // Build a realistic sandbox article for listing tests - $type = new ArticleType('Notebook'); + $this->adapter = new EbayAdapter($this->apiClient, $configRepo, $marketplaceId); + + $this->userTokenAvailable = '' !== self::env('EBAY_USER_TOKEN'); + + // Build a realistic sandbox article for listing tests (reuse same $articleType so config matches) $this->article = new Article( - $type, + $articleType, 'SS3K-TEST-'.time(), 'INV-TEST-001', 1, @@ -98,7 +103,7 @@ final class EbayAdapterIntegrationTest extends TestCase } } - public function test_publish_listing_creates_live_sandbox_listing(): void + public function testPublishListingCreatesLiveSandboxListing(): void { if (!$this->userTokenAvailable) { $this->markTestSkipped( @@ -116,7 +121,7 @@ final class EbayAdapterIntegrationTest extends TestCase $this->article->setEbayListingId($listingId); } - public function test_update_stock_changes_quantity(): void + public function testUpdateStockChangesQuantity(): void { if (!$this->userTokenAvailable) { $this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing'); @@ -131,7 +136,7 @@ final class EbayAdapterIntegrationTest extends TestCase $this->addToAssertionCount(1); } - public function test_deactivate_listing_withdraws_offer(): void + public function testDeactivateListingWithdrawsOffer(): void { if (!$this->userTokenAvailable) { $this->markTestSkipped('EBAY_USER_TOKEN not set — see test_publish_listing_creates_live_sandbox_listing'); @@ -146,7 +151,7 @@ final class EbayAdapterIntegrationTest extends TestCase $this->addToAssertionCount(1); } - public function test_deactivate_listing_is_noop_when_no_listing_id(): void + public function testDeactivateListingIsNoopWhenNoListingId(): void { // No listing ID set — should silently return, no API call $this->adapter->deactivateListing($this->article); diff --git a/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php b/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php index 01b60e9..106e48c 100644 --- a/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php +++ b/tests/Unit/Infrastructure/Channel/Ebay/EbayAdapterTest.php @@ -7,6 +7,9 @@ namespace App\Tests\Unit\Infrastructure\Channel\Ebay; use App\Domain\Article\Article; use App\Domain\Article\ArticleCondition; use App\Domain\Article\ArticleType; +use App\Domain\Channel\ArticleTypePlatformConfig; +use App\Domain\Channel\Platform; +use App\Domain\Channel\Repository\ArticleTypePlatformConfigRepositoryInterface; use App\Infrastructure\Channel\Ebay\EbayAdapter; use App\Infrastructure\Channel\Ebay\EbayInventoryApiClient; use PHPUnit\Framework\MockObject\MockObject; @@ -15,25 +18,35 @@ use PHPUnit\Framework\TestCase; final class EbayAdapterTest extends TestCase { private EbayInventoryApiClient&MockObject $apiClient; + private ArticleTypePlatformConfigRepositoryInterface&MockObject $configRepo; private EbayAdapter $adapter; private Article $article; + private ArticleTypePlatformConfig $platformConfig; protected function setUp(): void { $this->apiClient = $this->createMock(EbayInventoryApiClient::class); - $this->adapter = new EbayAdapter($this->apiClient); - $this->article = new Article(new ArticleType('Notebook'), 'NB-001', 'INV-001', 1, ArticleCondition::Good); + $this->configRepo = $this->createMock(ArticleTypePlatformConfigRepositoryInterface::class); + $this->adapter = new EbayAdapter($this->apiClient, $this->configRepo, 'EBAY_DE'); + + $articleType = new ArticleType('Notebook'); + $platform = new Platform('ebay', 'eBay'); + $this->platformConfig = new ArticleTypePlatformConfig($articleType, $platform, '177'); + + $this->article = new Article($articleType, 'NB-001', 'INV-001', 1, ArticleCondition::Good); $this->article->setEbayTitle('Dell Latitude 5520'); $this->article->setListingPrice('299.00'); } - public function test_get_type_returns_ebay(): void + public function testGetTypeReturnsEbay(): void { $this->assertSame('ebay', $this->adapter->getType()); } - public function test_publish_listing_calls_upsert_create_and_publish(): void + public function testPublishListingCallsUpsertCreateAndPublish(): void { + $this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig); + $this->apiClient->expects($this->once())->method('upsertInventoryItem'); $this->apiClient->expects($this->once())->method('createOffer')->willReturn('offer-123'); $this->apiClient->expects($this->once())->method('publishOffer')->with('offer-123')->willReturn('listing-456'); @@ -43,7 +56,57 @@ final class EbayAdapterTest extends TestCase $this->assertSame('listing-456', $listingId); } - public function test_deactivate_listing_withdraws_offer(): void + public function testPublishListingThrowsWhenNoPlatformConfig(): void + { + $this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn(null); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No eBay platform config found for ArticleType'); + + $this->adapter->publishListing($this->article); + } + + public function testPublishListingIncludesPoliciesWhenSet(): void + { + $this->platformConfig->setFulfillmentPolicyId('FP-123'); + $this->platformConfig->setPaymentPolicyId('PP-456'); + $this->platformConfig->setReturnPolicyId('RP-789'); + $this->platformConfig->setMerchantLocationKey('WAREHOUSE-1'); + $this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig); + + $this->apiClient->expects($this->once())->method('upsertInventoryItem'); + $this->apiClient->expects($this->once()) + ->method('createOffer') + ->with($this->callback(static function (array $body): bool { + return isset($body['listingPolicies']['fulfillmentPolicyId']) + && 'FP-123' === $body['listingPolicies']['fulfillmentPolicyId'] + && 'PP-456' === $body['listingPolicies']['paymentPolicyId'] + && 'RP-789' === $body['listingPolicies']['returnPolicyId'] + && 'WAREHOUSE-1' === $body['merchantLocationKey']; + })) + ->willReturn('offer-123'); + $this->apiClient->method('publishOffer')->willReturn('listing-456'); + + $this->adapter->publishListing($this->article); + } + + public function testPublishListingOmitsPoliciesWhenNotSet(): void + { + $this->configRepo->method('findByArticleTypeAndPlatformType')->willReturn($this->platformConfig); + + $this->apiClient->expects($this->once())->method('upsertInventoryItem'); + $this->apiClient->expects($this->once()) + ->method('createOffer') + ->with($this->callback(static function (array $body): bool { + return !isset($body['listingPolicies']) && !isset($body['merchantLocationKey']); + })) + ->willReturn('offer-123'); + $this->apiClient->method('publishOffer')->willReturn('listing-456'); + + $this->adapter->publishListing($this->article); + } + + public function testDeactivateListingWithdrawsOffer(): void { $this->article->setEbayListingId('offer-123'); @@ -52,7 +115,7 @@ final class EbayAdapterTest extends TestCase $this->adapter->deactivateListing($this->article); } - public function test_deactivate_listing_is_noop_when_no_listing_id(): void + public function testDeactivateListingIsNoopWhenNoListingId(): void { $this->apiClient->expects($this->never())->method('withdrawOffer'); diff --git a/translations/admin.de.yaml b/translations/admin.de.yaml index 0e1d236..a64b834 100644 --- a/translations/admin.de.yaml +++ b/translations/admin.de.yaml @@ -10,6 +10,8 @@ menu.ai_prompts: 'KI-Prompts' menu.translations: Übersetzungen menu.users: Benutzer menu.logs: Logs +menu.section_ebay: eBay +menu.ebay_platform_configs: 'Kategorie-Konfigurationen' menu.section_sales: Verkauf menu.orders: Bestellungen menu.customers: Kunden diff --git a/translations/admin.en.yaml b/translations/admin.en.yaml index ba588a1..b86571b 100644 --- a/translations/admin.en.yaml +++ b/translations/admin.en.yaml @@ -10,6 +10,8 @@ menu.ai_prompts: 'AI Prompts' menu.translations: Translations menu.users: Users menu.logs: Logs +menu.section_ebay: eBay +menu.ebay_platform_configs: 'Category Configurations' menu.section_sales: Sales menu.orders: Orders menu.customers: Customers