feat: eBay aspect import — match/create attributes from eBay taxonomy

ArticleType gains ebayCategoryId (migration 20260520080000).

New admin action "Import eBay Aspects" on ArticleType list and detail:
  - Fetches aspects via EbayTaxonomyService (cached 7d)
  - Sorts: Required → Recommended → Optional
  - Auto-matches by case-insensitive name to existing AttributeDefinitions
  - Pre-selects "Create new" for required/recommended with no match
  - Pre-selects "Skip" for optional with no match
  - Already-assigned definitions highlighted green
  - Per-row: override to Skip / Match existing / Create new
  - Type auto-detected: Select (≤30 eBay values) or String (freetext)
  - User can override type in create form
  - Required checkbox pre-checked for eBay-required aspects
  - "All → Create" / "All → Skip" bulk buttons
  - On submit: creates new AttributeDefinitions, links all to ArticleType,
    deduplicates, calls applyAttributeAssignments(), flushes

PHPStan level 9 clean throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 18:30:45 +00:00
parent 7f2ec21c64
commit d26c534c34
5 changed files with 490 additions and 7 deletions

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260520080000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add ebay_category_id to app.article_types for taxonomy aspect import';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE app.article_types ADD COLUMN ebay_category_id VARCHAR(50) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE app.article_types DROP COLUMN ebay_category_id');
}
}

View file

@ -20,6 +20,9 @@ class ArticleType
#[ORM\Column(type: 'string', length: 255, unique: true)]
private string $name;
#[ORM\Column(type: 'string', length: 50, nullable: true)]
private ?string $ebayCategoryId = null;
/** @var Collection<int, ArticleTypeAttribute> */
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $attributeAssignments;
@ -57,6 +60,16 @@ class ArticleType
$this->name = $name;
}
public function getEbayCategoryId(): ?string
{
return $this->ebayCategoryId;
}
public function setEbayCategoryId(?string $id): void
{
$this->ebayCategoryId = $id;
}
/** @return Collection<int, ArticleTypeAttribute> */
public function getAttributeAssignments(): Collection
{
@ -95,7 +108,9 @@ class ArticleType
/** @param iterable<AttributeDefinition> $defs */
public function setRequiredAttributeDefs(iterable $defs): void
{
$this->pendingRequired = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false);
/** @var list<AttributeDefinition> $list */
$list = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false);
$this->pendingRequired = $list;
}
/** @return Collection<int, AttributeDefinition> */
@ -109,7 +124,9 @@ class ArticleType
/** @param iterable<AttributeDefinition> $defs */
public function setOptionalAttributeDefs(iterable $defs): void
{
$this->pendingOptional = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false);
/** @var list<AttributeDefinition> $list */
$list = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false);
$this->pendingOptional = $list;
}
/**

View file

@ -7,6 +7,8 @@ namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Article\ArticleType;
use App\Domain\Article\AttributeDefinition;
use Doctrine\ORM\EntityManagerInterface;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Assets;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
@ -27,7 +29,21 @@ final class ArticleTypeCrudController extends AbstractCrudController
public function configureCrud(Crud $crud): Crud
{
return $crud->setEntityLabelInSingular(new TranslatableMessage('crud.article_type.singular', [], 'admin'))->setEntityLabelInPlural(new TranslatableMessage('crud.article_type.plural', [], 'admin'));
return $crud
->setEntityLabelInSingular(new TranslatableMessage('crud.article_type.singular', [], 'admin'))
->setEntityLabelInPlural(new TranslatableMessage('crud.article_type.plural', [], 'admin'));
}
public function configureActions(Actions $actions): Actions
{
$importEbay = Action::new('importEbayAspects', 'Import eBay Aspects', 'fa fa-cloud-download-alt')
->linkToRoute('admin_ebay_aspect_import', static fn (ArticleType $at) => ['id' => $at->getId()->toRfc4122()])
->setCssClass('btn btn-sm btn-outline-info');
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->add(Crud::PAGE_INDEX, $importEbay)
->add(Crud::PAGE_DETAIL, $importEbay);
}
public function configureAssets(Assets $assets): Assets
@ -44,6 +60,7 @@ final class ArticleTypeCrudController extends AbstractCrudController
{
yield IdField::new('id')->hideOnForm()->hideOnIndex();
yield TextField::new('name', 'Name');
yield TextField::new('ebayCategoryId', 'eBay Category ID')->setRequired(false)->hideOnIndex();
yield IntegerField::new('attributeAssignments', '# Attributes')
->formatValue(static fn (mixed $v): int => is_countable($v) ? count($v) : 0)
->hideOnForm()
@ -82,15 +99,19 @@ final class ArticleTypeCrudController extends AbstractCrudController
public function persistEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
{
\assert($entityInstance instanceof ArticleType);
$entityInstance->applyAttributeAssignments();
/** @phpstan-ignore instanceof.alwaysTrue */
if ($entityInstance instanceof ArticleType) {
$entityInstance->applyAttributeAssignments();
}
parent::persistEntity($entityManager, $entityInstance);
}
public function updateEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
{
\assert($entityInstance instanceof ArticleType);
$entityInstance->applyAttributeAssignments();
/** @phpstan-ignore instanceof.alwaysTrue */
if ($entityInstance instanceof ArticleType) {
$entityInstance->applyAttributeAssignments();
}
parent::updateEntity($entityManager, $entityInstance);
}
}

View file

@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http\Controller\Admin;
use App\Domain\Article\ArticleType;
use App\Domain\Article\AttributeDefinition;
use App\Domain\Article\AttributeType;
use App\Domain\Article\Repository\ArticleTypeRepositoryInterface;
use App\Infrastructure\Channel\Ebay\EbayTaxonomyService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
#[IsGranted('ROLE_USER')]
final class EbayAspectImportController extends AbstractController
{
public function __construct(
private readonly ArticleTypeRepositoryInterface $articleTypeRepo,
private readonly EbayTaxonomyService $taxonomy,
private readonly EntityManagerInterface $em,
) {
}
public function __invoke(string $id, Request $request): Response
{
$articleType = $this->articleTypeRepo->findById(Uuid::fromString($id));
if (null === $articleType) {
throw $this->createNotFoundException();
}
$categoryId = $articleType->getEbayCategoryId();
if (null === $categoryId) {
$this->addFlash('warning', 'Bitte zuerst die eBay Category ID am Artikel-Typ hinterlegen (Edit → eBay Category ID).');
return $this->redirectToRoute('easyadmin', [
'crudAction' => 'edit',
'crudControllerFqcn' => ArticleTypeCrudController::class,
'entityId' => $id,
]);
}
if ($request->isMethod('POST')) {
return $this->handleImport($request, $articleType);
}
$aspects = $this->taxonomy->getCategoryAspects($categoryId);
usort($aspects, static function (array $a, array $b): int {
$tierA = $a['required'] ? 0 : ('RECOMMENDED' === $a['usage'] ? 1 : 2);
$tierB = $b['required'] ? 0 : ('RECOMMENDED' === $b['usage'] ? 1 : 2);
return $tierA <=> $tierB ?: strcmp($a['name'], $b['name']);
});
/** @var list<AttributeDefinition> $allDefs */
$allDefs = $this->em->getRepository(AttributeDefinition::class)->findBy([], ['name' => 'ASC']);
$rows = $this->buildRows($aspects, $allDefs, $articleType);
$counts = [
'required' => count(array_filter($aspects, static fn (array $a) => $a['required'])),
'recommended' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'RECOMMENDED' === $a['usage'])),
'optional' => count(array_filter($aspects, static fn (array $a) => !$a['required'] && 'OPTIONAL' === $a['usage'])),
];
return $this->render('admin/ebay/aspect_import.html.twig', [
'articleType' => $articleType,
'rows' => $rows,
'allDefs' => $allDefs,
'counts' => $counts,
'categoryId' => $categoryId,
]);
}
private function handleImport(Request $request, ArticleType $articleType): Response
{
if (!$this->isCsrfTokenValid('ebay_aspect_import', (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$requiredDefs = $articleType->getRequiredAttributeDefs()->toArray();
$optionalDefs = $articleType->getOptionalAttributeDefs()->toArray();
$imported = 0;
foreach ($request->request->all('aspects') as $rawData) {
if (!\is_array($rawData)) {
continue;
}
// HTTP POST values are always strings; cast once to satisfy PHPStan level 9
/** @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) {
continue;
}
$markRequired = ($data['required'] ?? '0') === '1';
if ('match' === $action) {
$def = $this->em->find(AttributeDefinition::class, $data['definitionId'] ?? '');
if (null === $def) {
continue;
}
} else {
$name = trim($data['name'] ?? '');
if ('' === $name) {
continue;
}
$rawValues = array_values(array_filter(array_map('trim', explode(',', $data['ebayValues'] ?? ''))));
$type = (count($rawValues) > 0 && count($rawValues) <= 30)
? AttributeType::Select
: AttributeType::String;
$typeOverride = $data['type'] ?? '';
if ('' !== $typeOverride) {
$type = AttributeType::from($typeOverride);
}
$def = new AttributeDefinition($name, $type);
if (AttributeType::Select === $type && [] !== $rawValues) {
$def->setOptions($rawValues);
}
$this->em->persist($def);
}
if ($markRequired) {
$requiredDefs[] = $def;
} else {
$optionalDefs[] = $def;
}
++$imported;
}
// Deduplicate: required wins over optional if same def appears in both
$seen = [];
$dedupRequired = [];
foreach ($requiredDefs as $def) {
$key = $def->getId()->toRfc4122();
if (!isset($seen[$key])) {
$seen[$key] = true;
$dedupRequired[] = $def;
}
}
$dedupOptional = [];
foreach ($optionalDefs as $def) {
$key = $def->getId()->toRfc4122();
if (!isset($seen[$key])) {
$seen[$key] = true;
$dedupOptional[] = $def;
}
}
$articleType->setRequiredAttributeDefs($dedupRequired);
$articleType->setOptionalAttributeDefs($dedupOptional);
$articleType->applyAttributeAssignments();
$this->em->flush();
$this->addFlash('success', "{$imported} eBay-Aspekt(e) importiert / verknüpft.");
return $this->redirectToRoute('easyadmin', [
'crudAction' => 'detail',
'crudControllerFqcn' => ArticleTypeCrudController::class,
'entityId' => $articleType->getId()->toRfc4122(),
]);
}
/**
* @param list<array{name: string, required: bool, usage: string, values: list<string>}> $aspects
* @param list<AttributeDefinition> $allDefs
*
* @return list<array{aspect: array{name: string, required: bool, usage: string, values: list<string>}, action: string, preMatchId: string|null, 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;
}
$rows = [];
foreach ($aspects as $aspect) {
$normalized = mb_strtolower(trim($aspect['name']));
$match = $defsByName[$normalized] ?? null;
$alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]);
if ($match !== null) {
$action = 'match';
$preMatchId = $match->getId()->toRfc4122();
} elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) {
$action = 'create';
$preMatchId = null;
} else {
$action = 'skip';
$preMatchId = null;
}
$suggestedType = (count($aspect['values']) > 0 && count($aspect['values']) <= 30)
? AttributeType::Select->value
: AttributeType::String->value;
$rows[] = [
'aspect' => $aspect,
'action' => $action,
'preMatchId' => $preMatchId,
'alreadyAssigned' => $alreadyAssigned,
'suggestedType' => $suggestedType,
];
}
return $rows;
}
}

View file

@ -0,0 +1,190 @@
{% extends '@EasyAdmin/page/content.html.twig' %}
{% block page_title %}
<i class="fa fa-cloud-download-alt me-2"></i>eBay Aspects importieren — {{ articleType.name }}
<small class="text-muted fw-normal ms-2 fs-6">Kategorie {{ categoryId }}</small>
{% endblock %}
{% block main %}
<form method="post" id="aspect-import-form">
<input type="hidden" name="_token" value="{{ csrf_token('ebay_aspect_import') }}">
{# ── Summary bar ──────────────────────────────────────────────────── #}
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex gap-2 align-items-center">
<span class="badge bg-danger fs-6">{{ counts.required }} Required</span>
<span class="badge bg-warning text-dark fs-6">{{ counts.recommended }} Recommended</span>
<span class="badge bg-secondary fs-6">{{ counts.optional }} Optional</span>
<span class="text-muted small ms-2">Auto-matched {{ rows|filter(r => r.action == 'match')|length }} · Create {{ rows|filter(r => r.action == 'create')|length }} · Skip {{ rows|filter(r => r.action == 'skip')|length }}</span>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-select-all-create">All → Create</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="btn-select-all-skip">All → Skip</button>
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\ArticleTypeCrudController').setAction('index').generateUrl() }}"
class="btn btn-sm btn-outline-secondary">
<i class="fa fa-arrow-left me-1"></i>Abbrechen
</a>
<button type="submit" class="btn btn-primary btn-sm">
<i class="fa fa-check me-1"></i>Importieren
</button>
</div>
</div>
{# ── Main table ───────────────────────────────────────────────────── #}
<table class="table table-hover align-middle" id="aspects-table">
<thead class="table-light">
<tr>
<th style="width:22%">eBay Aspect</th>
<th style="width:10%">Tier</th>
<th style="width:28%">eBay-Werte</th>
<th style="width:13%">Aktion</th>
<th>Attribut / Name + Typ</th>
<th style="width:8%" class="text-center">Pflicht?</th>
</tr>
</thead>
<tbody>
{% for i, row in rows %}
{% set aspect = row.aspect %}
<tr class="aspect-row{% if row.alreadyAssigned %} table-success{% endif %}" data-index="{{ i }}">
{# Aspect name #}
<td>
<span class="fw-medium">{{ aspect.name }}</span>
{% if row.alreadyAssigned %}
<span class="badge bg-success ms-1" title="Bereits zugewiesen">✓</span>
{% endif %}
</td>
{# Tier badge #}
<td>
{% if aspect.required %}
<span class="badge bg-danger">Required</span>
{% elseif aspect.usage == 'RECOMMENDED' %}
<span class="badge bg-warning text-dark">Recommended</span>
{% else %}
<span class="badge bg-secondary">Optional</span>
{% endif %}
</td>
{# eBay values preview #}
<td>
{% if aspect.values %}
<span class="text-muted small">
{{ aspect.values|slice(0, 6)|join(', ') }}{% if aspect.values|length > 6 %} <em>(+{{ aspect.values|length - 6 }} weitere)</em>{% endif %}
</span>
{% else %}
<span class="text-muted small fst-italic">Freitext</span>
{% endif %}
</td>
{# Action selector #}
<td>
<select name="aspects[{{ i }}][action]"
class="form-select form-select-sm aspect-action"
data-index="{{ i }}">
<option value="skip" {% if row.action == 'skip' %}selected{% endif %}>— Überspringen</option>
<option value="match" {% if row.action == 'match' %}selected{% endif %}>Vorhandenes verknüpfen</option>
<option value="create" {% if row.action == 'create' %}selected{% endif %}>Neu anlegen</option>
</select>
<input type="hidden" name="aspects[{{ i }}][ebayName]" value="{{ aspect.name }}">
<input type="hidden" name="aspects[{{ i }}][ebayValues]" value="{{ aspect.values|join(',') }}">
<input type="hidden" name="aspects[{{ i }}][ebayRequired]" value="{{ aspect.required ? '1' : '0' }}">
</td>
{# Attribute input (match or create) #}
<td>
{# Match: pick existing definition #}
<div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};">
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
{% for def in allDefs %}
<option value="{{ def.id }}" {% if row.preMatchId == def.id|toString %}selected{% endif %}>
{{ def.name }} ({{ def.type.value }})
</option>
{% endfor %}
</select>
</div>
{# Create: name + type #}
<div class="section-create-{{ i }}" style="display:{% if row.action == 'create' %}block{% else %}none{% endif %};">
<div class="d-flex gap-1">
<input type="text"
name="aspects[{{ i }}][name]"
value="{{ aspect.name }}"
class="form-control form-control-sm flex-grow-1"
placeholder="Attribut-Name">
<select name="aspects[{{ i }}][type]" class="form-select form-select-sm" style="width:auto;">
<option value="string" {% if row.suggestedType == 'string' %}selected{% endif %}>Text</option>
<option value="select" {% if row.suggestedType == 'select' %}selected{% endif %}>Select ({{ aspect.values|length }} Werte)</option>
<option value="int">Int</option>
<option value="float">Float</option>
</select>
</div>
</div>
{# Skip: placeholder #}
<div class="section-skip-{{ i }}" style="display:{% if row.action == 'skip' %}block{% else %}none{% endif %};">
<span class="text-muted small">—</span>
</div>
</td>
{# Required checkbox #}
<td class="text-center">
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};">
<input type="checkbox"
name="aspects[{{ i }}][required]"
value="1"
class="form-check-input"
{% if aspect.required %}checked{% endif %}>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="d-flex justify-content-end gap-2 mt-3">
<a href="{{ ea_url().setController('App\\Infrastructure\\Http\\Controller\\Admin\\ArticleTypeCrudController').setAction('index').generateUrl() }}"
class="btn btn-outline-secondary">Abbrechen</a>
<button type="submit" class="btn btn-primary">
<i class="fa fa-check me-1"></i>Importieren
</button>
</div>
</form>
<script>
(function () {
function syncRow(index, action) {
['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';
}
document.querySelectorAll('.aspect-action').forEach(sel => {
sel.addEventListener('change', function () {
syncRow(this.dataset.index, this.value);
});
});
document.getElementById('btn-select-all-create').addEventListener('click', () => {
document.querySelectorAll('.aspect-action').forEach(sel => {
sel.value = 'create';
syncRow(sel.dataset.index, 'create');
});
});
document.getElementById('btn-select-all-skip').addEventListener('click', () => {
document.querySelectorAll('.aspect-action').forEach(sel => {
sel.value = 'skip';
syncRow(sel.dataset.index, 'skip');
});
});
})();
</script>
{% endblock %}