diff --git a/migrations/Version20260520080000.php b/migrations/Version20260520080000.php new file mode 100644 index 0000000..56ee8d4 --- /dev/null +++ b/migrations/Version20260520080000.php @@ -0,0 +1,26 @@ +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'); + } +} diff --git a/src/Domain/Article/ArticleType.php b/src/Domain/Article/ArticleType.php index b9dd5b4..c4a148b 100644 --- a/src/Domain/Article/ArticleType.php +++ b/src/Domain/Article/ArticleType.php @@ -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 */ #[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 */ public function getAttributeAssignments(): Collection { @@ -95,7 +108,9 @@ class ArticleType /** @param iterable $defs */ public function setRequiredAttributeDefs(iterable $defs): void { - $this->pendingRequired = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false); + /** @var list $list */ + $list = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false); + $this->pendingRequired = $list; } /** @return Collection */ @@ -109,7 +124,9 @@ class ArticleType /** @param iterable $defs */ public function setOptionalAttributeDefs(iterable $defs): void { - $this->pendingOptional = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false); + /** @var list $list */ + $list = $defs instanceof Collection ? $defs->toArray() : \iterator_to_array($defs, false); + $this->pendingOptional = $list; } /** diff --git a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php index c48a92d..cd72e90 100644 --- a/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php +++ b/src/Infrastructure/Http/Controller/Admin/ArticleTypeCrudController.php @@ -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); } } diff --git a/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php b/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php new file mode 100644 index 0000000..3603716 --- /dev/null +++ b/src/Infrastructure/Http/Controller/Admin/EbayAspectImportController.php @@ -0,0 +1,229 @@ +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 $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 $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}> $aspects + * @param list $allDefs + * + * @return list}, 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; + } +} diff --git a/templates/admin/ebay/aspect_import.html.twig b/templates/admin/ebay/aspect_import.html.twig new file mode 100644 index 0000000..798ec5b --- /dev/null +++ b/templates/admin/ebay/aspect_import.html.twig @@ -0,0 +1,190 @@ +{% extends '@EasyAdmin/page/content.html.twig' %} + +{% block page_title %} + eBay Aspects importieren — {{ articleType.name }} + Kategorie {{ categoryId }} +{% endblock %} + +{% block main %} + +
+ + +{# ── Summary bar ──────────────────────────────────────────────────── #} +
+
+ {{ counts.required }} Required + {{ counts.recommended }} Recommended + {{ counts.optional }} Optional + 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 }} +
+
+ + + + Abbrechen + + +
+
+ +{# ── Main table ───────────────────────────────────────────────────── #} + + + + + + + + + + + + + {% for i, row in rows %} + {% set aspect = row.aspect %} + + + {# Aspect name #} + + + {# Tier badge #} + + + {# eBay values preview #} + + + {# Action selector #} + + + {# Attribute input (match or create) #} + + + {# Required checkbox #} + + + + {% endfor %} + +
eBay AspectTiereBay-WerteAktionAttribut / Name + TypPflicht?
+ {{ aspect.name }} + {% if row.alreadyAssigned %} + + {% endif %} + + {% if aspect.required %} + Required + {% elseif aspect.usage == 'RECOMMENDED' %} + Recommended + {% else %} + Optional + {% endif %} + + {% if aspect.values %} + + {{ aspect.values|slice(0, 6)|join(', ') }}{% if aspect.values|length > 6 %} (+{{ aspect.values|length - 6 }} weitere){% endif %} + + {% else %} + Freitext + {% endif %} + + + + + + + {# Match: pick existing definition #} +
+ +
+ + {# Create: name + type #} +
+
+ + +
+
+ + {# Skip: placeholder #} +
+ +
+
+
+ +
+
+ +
+ Abbrechen + +
+ +
+ + + +{% endblock %}