feat: replace JSON ebay mappings with ArticleTypeEbayMapping entity

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 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 20:52:25 +00:00
parent 9259b99e7d
commit bf1af0a0bf
6 changed files with 242 additions and 47 deletions

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260520100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace ebay_aspect_field_mappings JSON with article_type_ebay_mappings table';
}
public function up(Schema $schema): void
{
$this->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");
}
}

View file

@ -23,17 +23,14 @@ class ArticleType
#[ORM\Column(type: 'string', length: 50, nullable: true)] #[ORM\Column(type: 'string', length: 50, nullable: true)]
private ?string $ebayCategoryId = null; private ?string $ebayCategoryId = null;
/**
* Maps eBay aspect name Article field name (e.g. 'Marke' => 'manufacturer').
* @var array<string, string>|null
*/
#[ORM\Column(type: 'json', nullable: true)]
private ?array $ebayAspectFieldMappings = null;
/** @var Collection<int, ArticleTypeAttribute> */ /** @var Collection<int, ArticleTypeAttribute> */
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $attributeAssignments; private Collection $attributeAssignments;
/** @var Collection<int, ArticleTypeEbayMapping> */
#[ORM\OneToMany(targetEntity: ArticleTypeEbayMapping::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true, indexBy: 'ebayAspectName')]
private Collection $ebayMappings;
/** @var list<AttributeDefinition>|null pending from form — applied by applyAttributeAssignments() */ /** @var list<AttributeDefinition>|null pending from form — applied by applyAttributeAssignments() */
private ?array $pendingRequired = null; private ?array $pendingRequired = null;
@ -45,6 +42,7 @@ class ArticleType
$this->id = Uuid::v7(); $this->id = Uuid::v7();
$this->name = $name; $this->name = $name;
$this->attributeAssignments = new ArrayCollection(); $this->attributeAssignments = new ArrayCollection();
$this->ebayMappings = new ArrayCollection();
} }
public function __toString(): string public function __toString(): string
@ -77,16 +75,23 @@ class ArticleType
$this->ebayCategoryId = $id; $this->ebayCategoryId = $id;
} }
/** @return array<string, string> */ /** @return Collection<int, ArticleTypeEbayMapping> */
public function getEbayAspectFieldMappings(): array public function getEbayMappings(): Collection
{ {
return $this->ebayAspectFieldMappings ?? []; return $this->ebayMappings;
} }
/** @param array<string, string> $mappings */ public function upsertEbayMapping(ArticleTypeEbayMapping $mapping): void
public function setEbayAspectFieldMappings(array $mappings): 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<int, ArticleTypeAttribute> */ /** @return Collection<int, ArticleTypeAttribute> */

View file

@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Domain\Article;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
/**
* Explicit mapping: eBay aspect name source field for a given ArticleType.
* sourceType 'article_field' reads a direct Article getter (manufacturer, modelNumber, ).
* sourceType 'attribute' reads an AttributeValue linked to an AttributeDefinition.
*/
#[ORM\Entity]
#[ORM\Table(name: 'article_type_ebay_mappings', schema: 'app')]
#[ORM\UniqueConstraint(name: 'uq_ebay_mapping', columns: ['article_type_id', 'ebay_aspect_name'])]
class ArticleTypeEbayMapping
{
public const SOURCE_ARTICLE_FIELD = 'article_field';
public const SOURCE_ATTRIBUTE = 'attribute';
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: ArticleType::class, inversedBy: 'ebayMappings')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ArticleType $articleType;
#[ORM\Column(type: 'string', length: 255)]
private string $ebayAspectName;
#[ORM\Column(type: 'string', length: 30)]
private string $sourceType;
#[ORM\Column(type: 'string', length: 100, nullable: true)]
private ?string $articleFieldKey = null;
#[ORM\ManyToOne(targetEntity: AttributeDefinition::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?AttributeDefinition $attributeDefinition = null;
#[ORM\Column(type: 'boolean')]
private bool $required = false;
public function __construct(ArticleType $articleType, string $ebayAspectName, string $sourceType)
{
$this->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() ?? '?');
}
}

View file

@ -111,20 +111,35 @@ final class EbayAdapter implements ChannelAdapterInterface
private function buildAspects(Article $article): array private function buildAspects(Article $article): array
{ {
$aspects = []; $aspects = [];
// Index attribute values by definition ID for O(1) lookup
$valuesByDefId = [];
foreach ($article->getAttributeValues() as $value) { foreach ($article->getAttributeValues() as $value) {
$name = $value->getAttributeDefinition()->getName(); $valuesByDefId[$value->getAttributeDefinition()->getId()->toRfc4122()] = $value->getValue();
$aspects[$name] = [$value->getValue()];
} }
foreach ($article->getArticleType()->getEbayAspectFieldMappings() as $ebayName => $fieldKey) { foreach ($article->getArticleType()->getEbayMappings() as $mapping) {
$getter = 'get'.ucfirst($fieldKey); $ebayName = $mapping->getEbayAspectName();
if ($mapping->getSourceType() === \App\Domain\Article\ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD) {
$getter = 'get'.ucfirst((string) $mapping->getArticleFieldKey());
if (!method_exists($article, $getter)) { if (!method_exists($article, $getter)) {
continue; continue;
} }
$fieldValue = $article->$getter(); $fieldValue = $article->$getter();
if (null !== $fieldValue && '' !== $fieldValue) { if (null !== $fieldValue && '' !== (string) $fieldValue) {
$aspects[$ebayName] = [(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];
}
}
} }
return $aspects; return $aspects;

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin; namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Article\ArticleType; use App\Domain\Article\ArticleType;
use App\Domain\Article\ArticleTypeEbayMapping;
use App\Domain\Article\AttributeDefinition; use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType; use App\Domain\Article\AttributeType;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface; use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
@ -122,6 +123,7 @@ final class EbayAspectImportController extends AbstractController
'counts' => $counts, 'counts' => $counts,
'categoryId' => $categoryId, 'categoryId' => $categoryId,
'searchUrl' => $this->generateUrl('admin_ebay_category_search'), 'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
'existingMappings' => $articleType->getEbayMappings(),
]); ]);
} }
@ -146,7 +148,6 @@ final class EbayAspectImportController extends AbstractController
$requiredDefs = $articleType->getRequiredAttributeDefs()->toArray(); $requiredDefs = $articleType->getRequiredAttributeDefs()->toArray();
$optionalDefs = $articleType->getOptionalAttributeDefs()->toArray(); $optionalDefs = $articleType->getOptionalAttributeDefs()->toArray();
$fieldMappings = $articleType->getEbayAspectFieldMappings();
$imported = 0; $imported = 0;
@ -158,25 +159,27 @@ final class EbayAspectImportController extends AbstractController
$data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData); $data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData);
$action = $data['action'] ?? 'skip'; $action = $data['action'] ?? 'skip';
if ('skip' === $action) { $ebayName = $data['ebayName'] ?? '';
$isRequired = ($data['ebayRequired'] ?? '0') === '1';
if ('skip' === $action || '' === $ebayName) {
continue; continue;
} }
// Aspect maps to a direct Article field (manufacturer, modelNumber, …)
if ('article_field' === $action) { if ('article_field' === $action) {
$fieldKey = $data['articleField'] ?? ''; $fieldKey = $data['articleField'] ?? '';
if ('' === $fieldKey || !isset(self::ARTICLE_FIELDS[$fieldKey])) { if ('' === $fieldKey || !isset(self::ARTICLE_FIELDS[$fieldKey])) {
continue; continue;
} }
$ebayName = $data['ebayName'] ?? ''; $mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD);
if ('' !== $ebayName) { $mapping->setArticleFieldKey($fieldKey);
$fieldMappings[$ebayName] = $fieldKey; $mapping->setRequired($isRequired);
} $articleType->upsertEbayMapping($mapping);
++$imported; ++$imported;
continue; continue;
} }
$markRequired = ($data['required'] ?? '0') === '1'; $markRequired = $isRequired || ($data['required'] ?? '0') === '1';
if ('match' === $action) { if ('match' === $action) {
$def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? ''); $def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? '');
@ -206,6 +209,11 @@ final class EbayAspectImportController extends AbstractController
$this->em->persist($def); $this->em->persist($def);
} }
$mapping = new ArticleTypeEbayMapping($articleType, $ebayName, ArticleTypeEbayMapping::SOURCE_ATTRIBUTE);
$mapping->setAttributeDefinition($def);
$mapping->setRequired($markRequired);
$articleType->upsertEbayMapping($mapping);
if ($markRequired) { if ($markRequired) {
$requiredDefs[] = $def; $requiredDefs[] = $def;
} else { } else {
@ -235,7 +243,6 @@ final class EbayAspectImportController extends AbstractController
$articleType->setRequiredAttributeDefs($dedupRequired); $articleType->setRequiredAttributeDefs($dedupRequired);
$articleType->setOptionalAttributeDefs($dedupOptional); $articleType->setOptionalAttributeDefs($dedupOptional);
$articleType->setEbayAspectFieldMappings($fieldMappings);
$articleType->applyAttributeAssignments(); $articleType->applyAttributeAssignments();
$this->em->flush(); $this->em->flush();
@ -267,19 +274,25 @@ final class EbayAspectImportController extends AbstractController
$assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true; $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 = []; $rows = [];
foreach ($aspects as $aspect) { foreach ($aspects as $aspect) {
$normalized = mb_strtolower(trim($aspect['name'])); $normalized = mb_strtolower(trim($aspect['name']));
// Highest priority: already stored as an article field mapping // Highest priority: already stored mapping
if (isset($existingFieldMappings[$aspect['name']])) { if (isset($existingMappings[$aspect['name']])) {
$existing = $existingMappings[$aspect['name']];
$isFieldMapping = ArticleTypeEbayMapping::SOURCE_ARTICLE_FIELD === $existing->getSourceType();
$rows[] = [ $rows[] = [
'aspect' => $aspect, 'aspect' => $aspect,
'action' => 'article_field', 'action' => $isFieldMapping ? 'article_field' : 'match',
'preMatchId' => null, 'preMatchId' => $isFieldMapping ? null : $existing->getAttributeDefinition()?->getId()->toRfc4122(),
'preFieldKey' => $existingFieldMappings[$aspect['name']], 'preFieldKey' => $isFieldMapping ? $existing->getArticleFieldKey() : null,
'alreadyAssigned' => true, 'alreadyAssigned' => true,
'suggestedType' => 'string', 'suggestedType' => 'string',
]; ];

View file

@ -43,6 +43,41 @@
{% if categoryId %} {% if categoryId %}
{# ── Existing mappings table ──────────────────────────────────────── #}
{% if existingMappings|length > 0 %}
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="fa fa-table me-2"></i>Aktive Mappings <span class="badge bg-secondary ms-1">{{ existingMappings|length }}</span></h5>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>eBay-Aspekt (Ziel)</th>
<th>Quelle</th>
<th class="text-center" style="width:6rem">Pflicht?</th>
</tr>
</thead>
<tbody>
{% for mapping in existingMappings %}
<tr>
<td class="fw-medium">{{ mapping.ebayAspectName }}</td>
<td class="text-muted">{{ mapping.sourceLabel }}</td>
<td class="text-center">
{% if mapping.required %}
<span class="badge bg-danger">Ja</span>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Summary bar ──────────────────────────────────────────────────── #} {# ── Summary bar ──────────────────────────────────────────────────── #}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">