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:
parent
9259b99e7d
commit
bf1af0a0bf
6 changed files with 242 additions and 47 deletions
44
migrations/Version20260520100000.php
Normal file
44
migrations/Version20260520100000.php
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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> */
|
||||
|
|
|
|||
83
src/Domain/Article/ArticleTypeEbayMapping.php
Normal file
83
src/Domain/Article/ArticleTypeEbayMapping.php
Normal 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() ?? '?');
|
||||
}
|
||||
}
|
||||
|
|
@ -111,20 +111,35 @@ 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);
|
||||
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 && '' !== $fieldValue) {
|
||||
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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $aspects;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -122,6 +123,7 @@ final class EbayAspectImportController extends AbstractController
|
|||
'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;
|
||||
|
||||
|
|
@ -158,25 +159,27 @@ final class EbayAspectImportController extends AbstractController
|
|||
$data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData);
|
||||
|
||||
$action = $data['action'] ?? 'skip';
|
||||
if ('skip' === $action) {
|
||||
$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,19 +274,25 @@ 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']],
|
||||
'action' => $isFieldMapping ? 'article_field' : 'match',
|
||||
'preMatchId' => $isFieldMapping ? null : $existing->getAttributeDefinition()?->getId()->toRfc4122(),
|
||||
'preFieldKey' => $isFieldMapping ? $existing->getArticleFieldKey() : null,
|
||||
'alreadyAssigned' => true,
|
||||
'suggestedType' => 'string',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue