From bf1af0a0bf5863925a7259e57b1d6dc7f3489bc8 Mon Sep 17 00:00:00 2001 From: Simon Kuehn Date: Mon, 18 May 2026 20:52:25 +0000 Subject: [PATCH] feat: replace JSON ebay mappings with ArticleTypeEbayMapping entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a proper key-value table (article_type_ebay_mappings) that explicitly maps each eBay aspect name to either an Article field (manufacturer, modelNumber, …) or an AttributeDefinition, with a required flag per mapping entry. - New entity ArticleTypeEbayMapping with SOURCE_ARTICLE_FIELD / SOURCE_ATTRIBUTE - ArticleType gains OneToMany ebayMappings collection with upsertEbayMapping() - EbayAdapter.buildAspects() reads from the mapping table instead of implicit name-matching - Import controller persists mappings via upsertEbayMapping() and syncs required attribute assignments - Template shows active mappings card and article_field action option - Migration 20260520100000 creates the new table, drops old JSON column Co-Authored-By: Claude Sonnet 4.6 --- migrations/Version20260520100000.php | 44 ++++++++++ src/Domain/Article/ArticleType.php | 31 ++++--- src/Domain/Article/ArticleTypeEbayMapping.php | 83 +++++++++++++++++++ .../Channel/Ebay/EbayAdapter.php | 35 +++++--- .../Admin/EbayAspectImportController.php | 61 ++++++++------ templates/admin/ebay/aspect_import.html.twig | 35 ++++++++ 6 files changed, 242 insertions(+), 47 deletions(-) create mode 100644 migrations/Version20260520100000.php create mode 100644 src/Domain/Article/ArticleTypeEbayMapping.php diff --git a/migrations/Version20260520100000.php b/migrations/Version20260520100000.php new file mode 100644 index 0000000..b5ec118 --- /dev/null +++ b/migrations/Version20260520100000.php @@ -0,0 +1,44 @@ +addSql(" + CREATE TABLE app.article_type_ebay_mappings ( + id UUID NOT NULL, + article_type_id UUID NOT NULL, + ebay_aspect_name VARCHAR(255) NOT NULL, + source_type VARCHAR(30) NOT NULL, + article_field_key VARCHAR(100) DEFAULT NULL, + attribute_definition_id UUID DEFAULT NULL, + required BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (id), + UNIQUE (article_type_id, ebay_aspect_name), + CONSTRAINT fk_etm_article_type FOREIGN KEY (article_type_id) + REFERENCES app.article_types (id) ON DELETE CASCADE, + CONSTRAINT fk_etm_attr_def FOREIGN KEY (attribute_definition_id) + REFERENCES app.attribute_definitions (id) ON DELETE SET NULL + ) + "); + $this->addSql("ALTER TABLE app.article_types DROP COLUMN IF EXISTS ebay_aspect_field_mappings"); + } + + public function down(Schema $schema): void + { + $this->addSql("DROP TABLE app.article_type_ebay_mappings"); + $this->addSql("ALTER TABLE app.article_types ADD COLUMN ebay_aspect_field_mappings JSON DEFAULT NULL"); + } +} diff --git a/src/Domain/Article/ArticleType.php b/src/Domain/Article/ArticleType.php index bf3a1f7..b1edb85 100644 --- a/src/Domain/Article/ArticleType.php +++ b/src/Domain/Article/ArticleType.php @@ -23,17 +23,14 @@ 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|null - */ - #[ORM\Column(type: 'json', nullable: true)] - private ?array $ebayAspectFieldMappings = null; - /** @var Collection */ #[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)] private Collection $attributeAssignments; + /** @var Collection */ + #[ORM\OneToMany(targetEntity: ArticleTypeEbayMapping::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true, indexBy: 'ebayAspectName')] + private Collection $ebayMappings; + /** @var list|null pending from form — applied by applyAttributeAssignments() */ private ?array $pendingRequired = null; @@ -45,6 +42,7 @@ class ArticleType $this->id = Uuid::v7(); $this->name = $name; $this->attributeAssignments = new ArrayCollection(); + $this->ebayMappings = new ArrayCollection(); } public function __toString(): string @@ -77,16 +75,23 @@ class ArticleType $this->ebayCategoryId = $id; } - /** @return array */ - public function getEbayAspectFieldMappings(): array + /** @return Collection */ + public function getEbayMappings(): Collection { - return $this->ebayAspectFieldMappings ?? []; + return $this->ebayMappings; } - /** @param array $mappings */ - public function setEbayAspectFieldMappings(array $mappings): void + public function upsertEbayMapping(ArticleTypeEbayMapping $mapping): void { - $this->ebayAspectFieldMappings = $mappings; + foreach ($this->ebayMappings as $existing) { + if ($existing->getEbayAspectName() === $mapping->getEbayAspectName()) { + $existing->setArticleFieldKey($mapping->getArticleFieldKey()); + $existing->setAttributeDefinition($mapping->getAttributeDefinition()); + $existing->setRequired($mapping->isRequired()); + return; + } + } + $this->ebayMappings->add($mapping); } /** @return Collection */ diff --git a/src/Domain/Article/ArticleTypeEbayMapping.php b/src/Domain/Article/ArticleTypeEbayMapping.php new file mode 100644 index 0000000..5f468fd --- /dev/null +++ b/src/Domain/Article/ArticleTypeEbayMapping.php @@ -0,0 +1,83 @@ +id = Uuid::v7(); + $this->articleType = $articleType; + $this->ebayAspectName = $ebayAspectName; + $this->sourceType = $sourceType; + } + + public function getId(): Uuid { return $this->id; } + + public function getArticleType(): ArticleType { return $this->articleType; } + + public function getEbayAspectName(): string { return $this->ebayAspectName; } + + public function getSourceType(): string { return $this->sourceType; } + + public function getArticleFieldKey(): ?string { return $this->articleFieldKey; } + + public function setArticleFieldKey(?string $key): void { $this->articleFieldKey = $key; } + + public function getAttributeDefinition(): ?AttributeDefinition { return $this->attributeDefinition; } + + public function setAttributeDefinition(?AttributeDefinition $def): void { $this->attributeDefinition = $def; } + + public function isRequired(): bool { return $this->required; } + + public function setRequired(bool $required): void { $this->required = $required; } + + public function getSourceLabel(): string + { + if (self::SOURCE_ARTICLE_FIELD === $this->sourceType) { + return 'Artikelfeld: '.($this->articleFieldKey ?? '?'); + } + + return 'Attribut: '.($this->attributeDefinition?->getName() ?? '?'); + } +} diff --git a/src/Infrastructure/Channel/Ebay/EbayAdapter.php b/src/Infrastructure/Channel/Ebay/EbayAdapter.php index bbeae33..5e4b4a7 100644 --- a/src/Infrastructure/Channel/Ebay/EbayAdapter.php +++ b/src/Infrastructure/Channel/Ebay/EbayAdapter.php @@ -111,19 +111,34 @@ final class EbayAdapter implements ChannelAdapterInterface private function buildAspects(Article $article): array { $aspects = []; + + // Index attribute values by definition ID for O(1) lookup + $valuesByDefId = []; foreach ($article->getAttributeValues() as $value) { - $name = $value->getAttributeDefinition()->getName(); - $aspects[$name] = [$value->getValue()]; + $valuesByDefId[$value->getAttributeDefinition()->getId()->toRfc4122()] = $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]; + foreach ($article->getArticleType()->getEbayMappings() as $mapping) { + $ebayName = $mapping->getEbayAspectName(); + + if ($mapping->getSourceType() === \App\Domain\Article\ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD) { + $getter = 'get'.ucfirst((string) $mapping->getArticleFieldKey()); + if (!method_exists($article, $getter)) { + continue; + } + $fieldValue = $article->$getter(); + if (null !== $fieldValue && '' !== (string) $fieldValue) { + $aspects[$ebayName] = [(string) $fieldValue]; + } + } else { + $def = $mapping->getAttributeDefinition(); + if (null === $def) { + continue; + } + $val = $valuesByDefId[$def->getId()->toRfc4122()] ?? null; + if (null !== $val) { + $aspects[$ebayName] = [$val]; + } } } diff --git a/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php b/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php index 7ce4eed..32e7036 100644 --- a/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php +++ b/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Infrastructure\Http\Controller\Admin; use App\Domain\Article\ArticleType; +use App\Domain\Article\ArticleTypeEbayMapping; use App\Domain\Article\AttributeDefinition; use App\Domain\Article\AttributeType; use App\Domain\Article\Repository\ArticleTypeRepositoryInterface; @@ -115,13 +116,14 @@ final class EbayAspectImportController extends AbstractController } return $this->render('admin/ebay/aspect_import.html.twig', [ - 'articleType' => $articleType, - 'rows' => $rows, - 'allDefs' => $allDefs, + 'articleType' => $articleType, + 'rows' => $rows, + 'allDefs' => $allDefs, 'articleFields' => self::ARTICLE_FIELDS, - 'counts' => $counts, - 'categoryId' => $categoryId, - 'searchUrl' => $this->generateUrl('admin_ebay_category_search'), + 'counts' => $counts, + 'categoryId' => $categoryId, + 'searchUrl' => $this->generateUrl('admin_ebay_category_search'), + 'existingMappings' => $articleType->getEbayMappings(), ]); } @@ -146,7 +148,6 @@ final class EbayAspectImportController extends AbstractController $requiredDefs = $articleType->getRequiredAttributeDefs()->toArray(); $optionalDefs = $articleType->getOptionalAttributeDefs()->toArray(); - $fieldMappings = $articleType->getEbayAspectFieldMappings(); $imported = 0; @@ -157,26 +158,28 @@ final class EbayAspectImportController extends AbstractController /** @var array $data */ $data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData); - $action = $data['action'] ?? 'skip'; - if ('skip' === $action) { + $action = $data['action'] ?? 'skip'; + $ebayName = $data['ebayName'] ?? ''; + $isRequired = ($data['ebayRequired'] ?? '0') === '1'; + + if ('skip' === $action || '' === $ebayName) { 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; - } + $mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD); + $mapping->setArticleFieldKey($fieldKey); + $mapping->setRequired($isRequired); + $articleType->upsertEbayMapping($mapping); ++$imported; continue; } - $markRequired = ($data['required'] ?? '0') === '1'; + $markRequired = $isRequired || ($data['required'] ?? '0') === '1'; if ('match' === $action) { $def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? ''); @@ -206,6 +209,11 @@ final class EbayAspectImportController extends AbstractController $this->em->persist($def); } + $mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ATTRIBUTE); + $mapping->setAttributeDefinition($def); + $mapping->setRequired($markRequired); + $articleType->upsertEbayMapping($mapping); + if ($markRequired) { $requiredDefs[] = $def; } else { @@ -235,7 +243,6 @@ final class EbayAspectImportController extends AbstractController $articleType->setRequiredAttributeDefs($dedupRequired); $articleType->setOptionalAttributeDefs($dedupOptional); - $articleType->setEbayAspectFieldMappings($fieldMappings); $articleType->applyAttributeAssignments(); $this->em->flush(); @@ -267,21 +274,27 @@ final class EbayAspectImportController extends AbstractController $assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true; } - $existingFieldMappings = $articleType->getEbayAspectFieldMappings(); + // Index existing mappings by eBay aspect name for O(1) lookup + $existingMappings = []; + foreach ($articleType->getEbayMappings() as $m) { + $existingMappings[$m->getEbayAspectName()] = $m; + } $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']])) { + // Highest priority: already stored mapping + if (isset($existingMappings[$aspect['name']])) { + $existing = $existingMappings[$aspect['name']]; + $isFieldMapping = ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD === $existing->getSourceType(); $rows[] = [ - 'aspect' => $aspect, - 'action' => 'article_field', - 'preMatchId' => null, - 'preFieldKey' => $existingFieldMappings[$aspect['name']], + 'aspect' => $aspect, + 'action' => $isFieldMapping ? 'article_field' : 'match', + 'preMatchId' => $isFieldMapping ? null : $existing->getAttributeDefinition()?->getId()->toRfc4122(), + 'preFieldKey' => $isFieldMapping ? $existing->getArticleFieldKey() : null, 'alreadyAssigned' => true, - 'suggestedType' => 'string', + 'suggestedType' => 'string', ]; continue; } diff --git a/templates/admin/ebay/aspect_import.html.twig b/templates/admin/ebay/aspect_import.html.twig index ac3d6c0..5c20523 100644 --- a/templates/admin/ebay/aspect_import.html.twig +++ b/templates/admin/ebay/aspect_import.html.twig @@ -43,6 +43,41 @@ {% if categoryId %} +{# ── Existing mappings table ──────────────────────────────────────── #} +{% if existingMappings|length > 0 %} +
+
+
Aktive Mappings {{ existingMappings|length }}
+
+
+ + + + + + + + + + {% for mapping in existingMappings %} + + + + + + {% endfor %} + +
eBay-Aspekt (Ziel)QuellePflicht?
{{ mapping.ebayAspectName }}{{ mapping.sourceLabel }} + {% if mapping.required %} + Ja + {% else %} + + {% endif %} +
+
+
+{% endif %} + {# ── Summary bar ──────────────────────────────────────────────────── #}