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:
parent
7f2ec21c64
commit
d26c534c34
5 changed files with 490 additions and 7 deletions
26
migrations/Version20260520080000.php
Normal file
26
migrations/Version20260520080000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,9 @@ class ArticleType
|
||||||
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
#[ORM\Column(type: 'string', length: 255, unique: true)]
|
||||||
private string $name;
|
private string $name;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 50, nullable: true)]
|
||||||
|
private ?string $ebayCategoryId = 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;
|
||||||
|
|
@ -57,6 +60,16 @@ class ArticleType
|
||||||
$this->name = $name;
|
$this->name = $name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEbayCategoryId(): ?string
|
||||||
|
{
|
||||||
|
return $this->ebayCategoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEbayCategoryId(?string $id): void
|
||||||
|
{
|
||||||
|
$this->ebayCategoryId = $id;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return Collection<int, ArticleTypeAttribute> */
|
/** @return Collection<int, ArticleTypeAttribute> */
|
||||||
public function getAttributeAssignments(): Collection
|
public function getAttributeAssignments(): Collection
|
||||||
{
|
{
|
||||||
|
|
@ -95,7 +108,9 @@ class ArticleType
|
||||||
/** @param iterable<AttributeDefinition> $defs */
|
/** @param iterable<AttributeDefinition> $defs */
|
||||||
public function setRequiredAttributeDefs(iterable $defs): void
|
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> */
|
/** @return Collection<int, AttributeDefinition> */
|
||||||
|
|
@ -109,7 +124,9 @@ class ArticleType
|
||||||
/** @param iterable<AttributeDefinition> $defs */
|
/** @param iterable<AttributeDefinition> $defs */
|
||||||
public function setOptionalAttributeDefs(iterable $defs): void
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ namespace App\Infrastructure\Http\Controller\Admin;
|
||||||
use App\Domain\Article\ArticleType;
|
use App\Domain\Article\ArticleType;
|
||||||
use App\Domain\Article\AttributeDefinition;
|
use App\Domain\Article\AttributeDefinition;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
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\Assets;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
|
||||||
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
|
||||||
|
|
@ -27,7 +29,21 @@ final class ArticleTypeCrudController extends AbstractCrudController
|
||||||
|
|
||||||
public function configureCrud(Crud $crud): Crud
|
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
|
public function configureAssets(Assets $assets): Assets
|
||||||
|
|
@ -44,6 +60,7 @@ final class ArticleTypeCrudController extends AbstractCrudController
|
||||||
{
|
{
|
||||||
yield IdField::new('id')->hideOnForm()->hideOnIndex();
|
yield IdField::new('id')->hideOnForm()->hideOnIndex();
|
||||||
yield TextField::new('name', 'Name');
|
yield TextField::new('name', 'Name');
|
||||||
|
yield TextField::new('ebayCategoryId', 'eBay Category ID')->setRequired(false)->hideOnIndex();
|
||||||
yield IntegerField::new('attributeAssignments', '# Attributes')
|
yield IntegerField::new('attributeAssignments', '# Attributes')
|
||||||
->formatValue(static fn (mixed $v): int => is_countable($v) ? count($v) : 0)
|
->formatValue(static fn (mixed $v): int => is_countable($v) ? count($v) : 0)
|
||||||
->hideOnForm()
|
->hideOnForm()
|
||||||
|
|
@ -82,15 +99,19 @@ final class ArticleTypeCrudController extends AbstractCrudController
|
||||||
|
|
||||||
public function persistEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
|
public function persistEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
|
||||||
{
|
{
|
||||||
\assert($entityInstance instanceof ArticleType);
|
/** @phpstan-ignore instanceof.alwaysTrue */
|
||||||
|
if ($entityInstance instanceof ArticleType) {
|
||||||
$entityInstance->applyAttributeAssignments();
|
$entityInstance->applyAttributeAssignments();
|
||||||
|
}
|
||||||
parent::persistEntity($entityManager, $entityInstance);
|
parent::persistEntity($entityManager, $entityInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
|
public function updateEntity(EntityManagerInterface $entityManager, mixed $entityInstance): void
|
||||||
{
|
{
|
||||||
\assert($entityInstance instanceof ArticleType);
|
/** @phpstan-ignore instanceof.alwaysTrue */
|
||||||
|
if ($entityInstance instanceof ArticleType) {
|
||||||
$entityInstance->applyAttributeAssignments();
|
$entityInstance->applyAttributeAssignments();
|
||||||
|
}
|
||||||
parent::updateEntity($entityManager, $entityInstance);
|
parent::updateEntity($entityManager, $entityInstance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
190
templates/admin/ebay/aspect_import.html.twig
Normal file
190
templates/admin/ebay/aspect_import.html.twig
Normal 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 %}
|
||||||
Loading…
Reference in a new issue