From 61ce94bc6f7818095efec591ea8c1ddd1c6303ab Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Mon, 18 May 2026 20:34:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20map=20eBay=20aspects=20to=20Article=20f?= =?UTF-8?q?ields=20(Marke=E2=86=92manufacturer,=20PN=E2=86=92modelNumber)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an 'Artikelfeld' action in the aspect import UI alongside skip/match/create. Aspects like 'Marke' and 'Herstellernummer' auto-detect to manufacturer/modelNumber via ARTICLE_FIELD_ALIASES. Mappings are persisted as a JSON column on ArticleType. EbayAdapter.buildAspects() now reads these mappings and populates them from the article's direct fields when building eBay listing aspects. Co-Authored-By: Claude Sonnet 4.6 --- migrations/Version20260520090000.php | 26 ++++++ src/Domain/Article/ArticleType.php | 19 ++++ .../Channel/Ebay/EbayAdapter.php | 11 +++ .../Admin/EbayAspectImportController.php | 91 ++++++++++++++++--- templates/admin/ebay/aspect_import.html.twig | 22 +++-- 5 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 migrations/Version20260520090000.php diff --git a/migrations/Version20260520090000.php b/migrations/Version20260520090000.php new file mode 100644 index 0000000..2572cc3 --- /dev/null +++ b/migrations/Version20260520090000.php @@ -0,0 +1,26 @@ +addSql("ALTER TABLE app.article_types ADD COLUMN ebay_aspect_field_mappings JSON DEFAULT NULL"); + } + + public function down(Schema $schema): void + { + $this->addSql("ALTER TABLE app.article_types DROP COLUMN ebay_aspect_field_mappings"); + } +} diff --git a/src/Domain/Article/ArticleType.php b/src/Domain/Article/ArticleType.php index c4a148b..7907b04 100644 --- a/src/Domain/Article/ArticleType.php +++ b/src/Domain/Article/ArticleType.php @@ -23,6 +23,13 @@ class ArticleType #[ORM\Column(type: 'string', length: 50, nullable: true)] private ?string $ebayCategoryId = null; + /** + * Maps eBay aspect name → Article field name (e.g. 'Marke' => 'manufacturer'). + * @var array + */ + #[ORM\Column(type: 'json', nullable: true)] + private array $ebayAspectFieldMappings = []; + /** @var Collection */ #[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $attributeAssignments; @@ -70,6 +77,18 @@ class ArticleType $this->ebayCategoryId = $id; } + /** @return array */ + public function getEbayAspectFieldMappings(): array + { + return $this->ebayAspectFieldMappings; + } + + /** @param array $mappings */ + public function setEbayAspectFieldMappings(array $mappings): void + { + $this->ebayAspectFieldMappings = $mappings; + } + /** @return Collection */ public function getAttributeAssignments(): Collection { diff --git a/src/Infrastructure/Channel/Ebay/EbayAdapter.php b/src/Infrastructure/Channel/Ebay/EbayAdapter.php index 5266234..bbeae33 100644 --- a/src/Infrastructure/Channel/Ebay/EbayAdapter.php +++ b/src/Infrastructure/Channel/Ebay/EbayAdapter.php @@ -116,6 +116,17 @@ final class EbayAdapter implements ChannelAdapterInterface $aspects[$name] = [$value->getValue()]; } + foreach ($article->getArticleType()->getEbayAspectFieldMappings() as $ebayName => $fieldKey) { + $getter = 'get'.ucfirst($fieldKey); + if (!method_exists($article, $getter)) { + continue; + } + $fieldValue = $article->$getter(); + if (null !== $fieldValue && '' !== $fieldValue) { + $aspects[$ebayName] = [(string) $fieldValue]; + } + } + return $aspects; } diff --git a/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php b/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php index 944de44..a92c392 100644 --- a/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php +++ b/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php @@ -47,6 +47,29 @@ final class EbayAspectImportController extends AbstractController return $this->json(array_slice($results, 0, 15)); } + /** + * Article fields that can be mapped directly from eBay aspects. + * key = Article getter suffix / property name, value = German UI label. + */ + private const ARTICLE_FIELDS = [ + 'manufacturer' => 'Hersteller (Marke)', + 'modelNumber' => 'Herstellernummer (PN / MPN)', + 'modelName' => 'Modellname', + 'serialNumber' => 'Seriennummer', + ]; + + /** eBay aspect names (lowercase) that auto-match to article fields. */ + private const ARTICLE_FIELD_ALIASES = [ + 'marke' => 'manufacturer', + 'brand' => 'manufacturer', + 'hersteller' => 'manufacturer', + 'herstellernummer' => 'modelNumber', + 'mpn' => 'modelNumber', + 'teilenummer' => 'modelNumber', + 'modell' => 'modelName', + 'seriennummer' => 'serialNumber', + ]; + #[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')] #[IsGranted('ROLE_USER')] public function __invoke(string $id, Request $request): Response @@ -56,7 +79,6 @@ final class EbayAspectImportController extends AbstractController throw $this->createNotFoundException(); } - // Save category selection (step 1 of the 2-step flow) if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) { return $this->handleSetCategory($request, $articleType); } @@ -91,12 +113,13 @@ final class EbayAspectImportController extends AbstractController } return $this->render('admin/ebay/aspect_import.html.twig', [ - 'articleType' => $articleType, - 'rows' => $rows, - 'allDefs' => $allDefs, - 'counts' => $counts, - 'categoryId' => $categoryId, - 'searchUrl' => $this->generateUrl('admin_ebay_category_search'), + 'articleType' => $articleType, + 'rows' => $rows, + 'allDefs' => $allDefs, + 'articleFields' => self::ARTICLE_FIELDS, + 'counts' => $counts, + 'categoryId' => $categoryId, + 'searchUrl' => $this->generateUrl('admin_ebay_category_search'), ]); } @@ -121,6 +144,7 @@ final class EbayAspectImportController extends AbstractController $requiredDefs = $articleType->getRequiredAttributeDefs()->toArray(); $optionalDefs = $articleType->getOptionalAttributeDefs()->toArray(); + $fieldMappings = $articleType->getEbayAspectFieldMappings(); $imported = 0; @@ -128,7 +152,6 @@ final class EbayAspectImportController extends AbstractController if (!\is_array($rawData)) { continue; } - // HTTP POST values are always strings; cast once to satisfy PHPStan level 9 /** @var array $data */ $data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData); @@ -137,6 +160,20 @@ final class EbayAspectImportController extends AbstractController continue; } + // Aspect maps to a direct Article field (manufacturer, modelNumber, …) + if ('article_field' === $action) { + $fieldKey = $data['articleField'] ?? ''; + if ('' === $fieldKey || !isset(self::ARTICLE_FIELDS[$fieldKey])) { + continue; + } + $ebayName = $data['ebayName'] ?? ''; + if ('' !== $ebayName) { + $fieldMappings[$ebayName] = $fieldKey; + } + ++$imported; + continue; + } + $markRequired = ($data['required'] ?? '0') === '1'; if ('match' === $action) { @@ -196,6 +233,7 @@ final class EbayAspectImportController extends AbstractController $articleType->setRequiredAttributeDefs($dedupRequired); $articleType->setOptionalAttributeDefs($dedupOptional); + $articleType->setEbayAspectFieldMappings($fieldMappings); $articleType->applyAttributeAssignments(); $this->em->flush(); @@ -213,29 +251,57 @@ final class EbayAspectImportController extends AbstractController * @param list}> $aspects * @param list $allDefs * - * @return list}, action: string, preMatchId: string|null, suggestedType: string}> + * @return list}, action: string, preMatchId: string|null, preFieldKey: string|null, alreadyAssigned: bool, suggestedType: string}> */ private function buildRows(array $aspects, array $allDefs, ArticleType $articleType): array { - // Build name → def map for auto-matching $defsByName = []; foreach ($allDefs as $def) { $defsByName[mb_strtolower(trim($def->getName()))] = $def; } - // Track which defs are already assigned to this article type $assignedDefIds = []; foreach ($articleType->getAttributeAssignments() as $assignment) { $assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true; } + $existingFieldMappings = $articleType->getEbayAspectFieldMappings(); + $rows = []; foreach ($aspects as $aspect) { $normalized = mb_strtolower(trim($aspect['name'])); + + // Highest priority: already stored as an article field mapping + if (isset($existingFieldMappings[$aspect['name']])) { + $rows[] = [ + 'aspect' => $aspect, + 'action' => 'article_field', + 'preMatchId' => null, + 'preFieldKey' => $existingFieldMappings[$aspect['name']], + 'alreadyAssigned' => true, + 'suggestedType' => 'string', + ]; + continue; + } + + // Auto-detect article field by alias + $autoField = self::ARTICLE_FIELD_ALIASES[$normalized] ?? null; + if (null !== $autoField) { + $rows[] = [ + 'aspect' => $aspect, + 'action' => 'article_field', + 'preMatchId' => null, + 'preFieldKey' => $autoField, + 'alreadyAssigned' => false, + 'suggestedType' => 'string', + ]; + continue; + } + $match = $defsByName[$normalized] ?? null; $alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]); - if ($match !== null) { + if (null !== $match) { $action = 'match'; $preMatchId = $match->getId()->toRfc4122(); } elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) { @@ -254,6 +320,7 @@ final class EbayAspectImportController extends AbstractController 'aspect' => $aspect, 'action' => $action, 'preMatchId' => $preMatchId, + 'preFieldKey' => null, 'alreadyAssigned' => $alreadyAssigned, 'suggestedType' => $suggestedType, ]; diff --git a/templates/admin/ebay/aspect_import.html.twig b/templates/admin/ebay/aspect_import.html.twig index bde5f26..ac3d6c0 100644 --- a/templates/admin/ebay/aspect_import.html.twig +++ b/templates/admin/ebay/aspect_import.html.twig @@ -119,9 +119,10 @@ @@ -129,6 +130,15 @@ +
+ +
{ + ['article_field', 'match', 'create', 'skip'].forEach(s => { const el = document.querySelector(`.section-${s}-${index}`); if (el) el.style.display = s === action ? 'block' : 'none'; }); const req = document.querySelector(`.section-req-${index}`); - if (req) req.style.display = action !== 'skip' ? 'block' : 'none'; + if (req) req.style.display = (action !== 'skip' && action !== 'article_field') ? 'block' : 'none'; } document.querySelectorAll('.aspect-action').forEach(sel => {