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)]
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> */
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
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() */
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<string, string> */
public function getEbayAspectFieldMappings(): array
/** @return Collection<int, ArticleTypeEbayMapping> */
public function getEbayMappings(): Collection
{
return $this->ebayAspectFieldMappings ?? [];
return $this->ebayMappings;
}
/** @param array<string, string> $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<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,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];
}
}
}

View file

@ -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<string, string> $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;
}

View file

@ -43,6 +43,41 @@
{% 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 ──────────────────────────────────────────────────── #}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex gap-2 align-items-center">