feat: map eBay aspects to Article fields (Marke→manufacturer, PN→modelNumber)

Adds an 'Artikelfeld' action in the aspect import UI alongside skip/match/create.
Aspects like 'Marke' and 'Herstellernummer' auto-detect to manufacturer/modelNumber
via ARTICLE_FIELD_ALIASES. Mappings are persisted as a JSON column on ArticleType.
EbayAdapter.buildAspects() now reads these mappings and populates them from the
article's direct fields when building eBay listing aspects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Simon Kuehn 2026-05-18 20:34:49 +00:00
parent 929f5a0b2d
commit 61ce94bc6f
5 changed files with 151 additions and 18 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 Version20260520090000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add ebay_aspect_field_mappings JSON column to article_types';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE app.article_types ADD COLUMN ebay_aspect_field_mappings JSON DEFAULT NULL");
}
public function down(Schema $schema): void
{
$this->addSql("ALTER TABLE app.article_types DROP COLUMN ebay_aspect_field_mappings");
}
}

View file

@ -23,6 +23,13 @@ class ArticleType
#[ORM\Column(type: 'string', length: 50, nullable: true)] #[ORM\Column(type: 'string', length: 50, nullable: true)]
private ?string $ebayCategoryId = null; private ?string $ebayCategoryId = null;
/**
* Maps eBay aspect name Article field name (e.g. 'Marke' => 'manufacturer').
* @var array<string, string>
*/
#[ORM\Column(type: 'json', nullable: true)]
private array $ebayAspectFieldMappings = [];
/** @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;
@ -70,6 +77,18 @@ class ArticleType
$this->ebayCategoryId = $id; $this->ebayCategoryId = $id;
} }
/** @return array<string, string> */
public function getEbayAspectFieldMappings(): array
{
return $this->ebayAspectFieldMappings;
}
/** @param array<string, string> $mappings */
public function setEbayAspectFieldMappings(array $mappings): void
{
$this->ebayAspectFieldMappings = $mappings;
}
/** @return Collection<int, ArticleTypeAttribute> */ /** @return Collection<int, ArticleTypeAttribute> */
public function getAttributeAssignments(): Collection public function getAttributeAssignments(): Collection
{ {

View file

@ -116,6 +116,17 @@ final class EbayAdapter implements ChannelAdapterInterface
$aspects[$name] = [$value->getValue()]; $aspects[$name] = [$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];
}
}
return $aspects; return $aspects;
} }

View file

@ -47,6 +47,29 @@ final class EbayAspectImportController extends AbstractController
return $this->json(array_slice($results, 0, 15)); return $this->json(array_slice($results, 0, 15));
} }
/**
* Article fields that can be mapped directly from eBay aspects.
* key = Article getter suffix / property name, value = German UI label.
*/
private const ARTICLE_FIELDS = [
'manufacturer' => 'Hersteller (Marke)',
'modelNumber' => 'Herstellernummer (PN / MPN)',
'modelName' => 'Modellname',
'serialNumber' => 'Seriennummer',
];
/** eBay aspect names (lowercase) that auto-match to article fields. */
private const ARTICLE_FIELD_ALIASES = [
'marke' => 'manufacturer',
'brand' => 'manufacturer',
'hersteller' => 'manufacturer',
'herstellernummer' => 'modelNumber',
'mpn' => 'modelNumber',
'teilenummer' => 'modelNumber',
'modell' => 'modelName',
'seriennummer' => 'serialNumber',
];
#[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')] #[Route('/admin/ebay/aspect-import/{id}', name: 'admin_ebay_aspect_import')]
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
public function __invoke(string $id, Request $request): Response public function __invoke(string $id, Request $request): Response
@ -56,7 +79,6 @@ final class EbayAspectImportController extends AbstractController
throw $this->createNotFoundException(); throw $this->createNotFoundException();
} }
// Save category selection (step 1 of the 2-step flow)
if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) { if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) {
return $this->handleSetCategory($request, $articleType); return $this->handleSetCategory($request, $articleType);
} }
@ -94,6 +116,7 @@ final class EbayAspectImportController extends AbstractController
'articleType' => $articleType, 'articleType' => $articleType,
'rows' => $rows, 'rows' => $rows,
'allDefs' => $allDefs, 'allDefs' => $allDefs,
'articleFields' => self::ARTICLE_FIELDS,
'counts' => $counts, 'counts' => $counts,
'categoryId' => $categoryId, 'categoryId' => $categoryId,
'searchUrl' => $this->generateUrl('admin_ebay_category_search'), 'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
@ -121,6 +144,7 @@ final class EbayAspectImportController extends AbstractController
$requiredDefs = $articleType->getRequiredAttributeDefs()->toArray(); $requiredDefs = $articleType->getRequiredAttributeDefs()->toArray();
$optionalDefs = $articleType->getOptionalAttributeDefs()->toArray(); $optionalDefs = $articleType->getOptionalAttributeDefs()->toArray();
$fieldMappings = $articleType->getEbayAspectFieldMappings();
$imported = 0; $imported = 0;
@ -128,7 +152,6 @@ final class EbayAspectImportController extends AbstractController
if (!\is_array($rawData)) { if (!\is_array($rawData)) {
continue; continue;
} }
// HTTP POST values are always strings; cast once to satisfy PHPStan level 9
/** @var array<string, string> $data */ /** @var array<string, string> $data */
$data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData); $data = array_map(static fn (mixed $v): string => \is_scalar($v) ? (string) $v : '', $rawData);
@ -137,6 +160,20 @@ final class EbayAspectImportController extends AbstractController
continue; 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;
}
++$imported;
continue;
}
$markRequired = ($data['required'] ?? '0') === '1'; $markRequired = ($data['required'] ?? '0') === '1';
if ('match' === $action) { if ('match' === $action) {
@ -196,6 +233,7 @@ final class EbayAspectImportController extends AbstractController
$articleType->setRequiredAttributeDefs($dedupRequired); $articleType->setRequiredAttributeDefs($dedupRequired);
$articleType->setOptionalAttributeDefs($dedupOptional); $articleType->setOptionalAttributeDefs($dedupOptional);
$articleType->setEbayAspectFieldMappings($fieldMappings);
$articleType->applyAttributeAssignments(); $articleType->applyAttributeAssignments();
$this->em->flush(); $this->em->flush();
@ -213,29 +251,57 @@ final class EbayAspectImportController extends AbstractController
* @param list<array{name: string, required: bool, usage: string, values: list<string>}> $aspects * @param list<array{name: string, required: bool, usage: string, values: list<string>}> $aspects
* @param list<AttributeDefinition> $allDefs * @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}> * @return list<array{aspect: array{name: string, required: bool, usage: string, values: list<string>}, action: string, preMatchId: string|null, preFieldKey: string|null, alreadyAssigned: bool, suggestedType: string}>
*/ */
private function buildRows(array $aspects, array $allDefs, ArticleType $articleType): array private function buildRows(array $aspects, array $allDefs, ArticleType $articleType): array
{ {
// Build name → def map for auto-matching
$defsByName = []; $defsByName = [];
foreach ($allDefs as $def) { foreach ($allDefs as $def) {
$defsByName[mb_strtolower(trim($def->getName()))] = $def; $defsByName[mb_strtolower(trim($def->getName()))] = $def;
} }
// Track which defs are already assigned to this article type
$assignedDefIds = []; $assignedDefIds = [];
foreach ($articleType->getAttributeAssignments() as $assignment) { foreach ($articleType->getAttributeAssignments() as $assignment) {
$assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true; $assignedDefIds[$assignment->getAttributeDefinition()->getId()->toRfc4122()] = true;
} }
$existingFieldMappings = $articleType->getEbayAspectFieldMappings();
$rows = []; $rows = [];
foreach ($aspects as $aspect) { foreach ($aspects as $aspect) {
$normalized = mb_strtolower(trim($aspect['name'])); $normalized = mb_strtolower(trim($aspect['name']));
// Highest priority: already stored as an article field mapping
if (isset($existingFieldMappings[$aspect['name']])) {
$rows[] = [
'aspect' => $aspect,
'action' => 'article_field',
'preMatchId' => null,
'preFieldKey' => $existingFieldMappings[$aspect['name']],
'alreadyAssigned' => true,
'suggestedType' => 'string',
];
continue;
}
// Auto-detect article field by alias
$autoField = self::ARTICLE_FIELD_ALIASES[$normalized] ?? null;
if (null !== $autoField) {
$rows[] = [
'aspect' => $aspect,
'action' => 'article_field',
'preMatchId' => null,
'preFieldKey' => $autoField,
'alreadyAssigned' => false,
'suggestedType' => 'string',
];
continue;
}
$match = $defsByName[$normalized] ?? null; $match = $defsByName[$normalized] ?? null;
$alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]); $alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]);
if ($match !== null) { if (null !== $match) {
$action = 'match'; $action = 'match';
$preMatchId = $match->getId()->toRfc4122(); $preMatchId = $match->getId()->toRfc4122();
} elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) { } elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) {
@ -254,6 +320,7 @@ final class EbayAspectImportController extends AbstractController
'aspect' => $aspect, 'aspect' => $aspect,
'action' => $action, 'action' => $action,
'preMatchId' => $preMatchId, 'preMatchId' => $preMatchId,
'preFieldKey' => null,
'alreadyAssigned' => $alreadyAssigned, 'alreadyAssigned' => $alreadyAssigned,
'suggestedType' => $suggestedType, 'suggestedType' => $suggestedType,
]; ];

View file

@ -120,8 +120,9 @@
class="form-select form-select-sm aspect-action" class="form-select form-select-sm aspect-action"
data-index="{{ i }}"> data-index="{{ i }}">
<option value="skip" {% if row.action == 'skip' %}selected{% endif %}>— Überspringen</option> <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="article_field" {% if row.action == 'article_field' %}selected{% endif %}>→ Artikelfeld</option>
<option value="create" {% if row.action == 'create' %}selected{% endif %}>Neu anlegen</option> <option value="match" {% if row.action == 'match' %}selected{% endif %}>Attribut verknüpfen</option>
<option value="create" {% if row.action == 'create' %}selected{% endif %}>Attribut anlegen</option>
</select> </select>
<input type="hidden" name="aspects[{{ i }}][ebayName]" value="{{ aspect.name }}"> <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 }}][ebayValues]" value="{{ aspect.values|join(',') }}">
@ -129,6 +130,15 @@
</td> </td>
<td> <td>
<div class="section-article_field-{{ i }}" style="display:{% if row.action == 'article_field' %}block{% else %}none{% endif %};">
<select name="aspects[{{ i }}][articleField]" class="form-select form-select-sm">
{% for fieldKey, fieldLabel in articleFields %}
<option value="{{ fieldKey }}" {% if row.preFieldKey == fieldKey %}selected{% endif %}>
{{ fieldLabel }}
</option>
{% endfor %}
</select>
</div>
<div class="section-match-{{ i }}" style="display:{% if row.action == 'match' %}block{% else %}none{% endif %};"> <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"> <select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
{% for def in allDefs %} {% for def in allDefs %}
@ -159,7 +169,7 @@
</td> </td>
<td class="text-center"> <td class="text-center">
<div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' %}block{% else %}none{% endif %};"> <div class="section-req-{{ i }}" style="display:{% if row.action != 'skip' and row.action != 'article_field' %}block{% else %}none{% endif %};">
<input type="checkbox" <input type="checkbox"
name="aspects[{{ i }}][required]" name="aspects[{{ i }}][required]"
value="1" value="1"
@ -263,12 +273,12 @@
/* ── Aspect table JS ─────────────────────────────────────────── */ /* ── Aspect table JS ─────────────────────────────────────────── */
function syncRow(index, action) { function syncRow(index, action) {
['match', 'create', 'skip'].forEach(s => { ['article_field', 'match', 'create', 'skip'].forEach(s => {
const el = document.querySelector(`.section-${s}-${index}`); const el = document.querySelector(`.section-${s}-${index}`);
if (el) el.style.display = s === action ? 'block' : 'none'; if (el) el.style.display = s === action ? 'block' : 'none';
}); });
const req = document.querySelector(`.section-req-${index}`); const req = document.querySelector(`.section-req-${index}`);
if (req) req.style.display = action !== 'skip' ? 'block' : 'none'; if (req) req.style.display = (action !== 'skip' && action !== 'article_field') ? 'block' : 'none';
} }
document.querySelectorAll('.aspect-action').forEach(sel => { document.querySelectorAll('.aspect-action').forEach(sel => {