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)]
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> */
#[ORM\OneToMany(targetEntity: ArticleTypeAttribute::class, mappedBy: 'articleType', cascade: ['persist', 'remove'], orphanRemoval: true)]
private Collection $attributeAssignments;
@ -70,6 +77,18 @@ class ArticleType
$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> */
public function getAttributeAssignments(): Collection
{

View file

@ -116,6 +116,17 @@ final class EbayAdapter implements ChannelAdapterInterface
$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;
}

View file

@ -47,6 +47,29 @@ final class EbayAspectImportController extends AbstractController
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')]
#[IsGranted('ROLE_USER')]
public function __invoke(string $id, Request $request): Response
@ -56,7 +79,6 @@ final class EbayAspectImportController extends AbstractController
throw $this->createNotFoundException();
}
// Save category selection (step 1 of the 2-step flow)
if ($request->isMethod('POST') && 'set-category' === $request->request->get('_action')) {
return $this->handleSetCategory($request, $articleType);
}
@ -94,6 +116,7 @@ final class EbayAspectImportController extends AbstractController
'articleType' => $articleType,
'rows' => $rows,
'allDefs' => $allDefs,
'articleFields' => self::ARTICLE_FIELDS,
'counts' => $counts,
'categoryId' => $categoryId,
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
@ -121,6 +144,7 @@ final class EbayAspectImportController extends AbstractController
$requiredDefs = $articleType->getRequiredAttributeDefs()->toArray();
$optionalDefs = $articleType->getOptionalAttributeDefs()->toArray();
$fieldMappings = $articleType->getEbayAspectFieldMappings();
$imported = 0;
@ -128,7 +152,6 @@ final class EbayAspectImportController extends AbstractController
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);
@ -137,6 +160,20 @@ final class EbayAspectImportController extends AbstractController
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';
if ('match' === $action) {
@ -196,6 +233,7 @@ final class EbayAspectImportController extends AbstractController
$articleType->setRequiredAttributeDefs($dedupRequired);
$articleType->setOptionalAttributeDefs($dedupOptional);
$articleType->setEbayAspectFieldMappings($fieldMappings);
$articleType->applyAttributeAssignments();
$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<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
{
// 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;
}
$existingFieldMappings = $articleType->getEbayAspectFieldMappings();
$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']])) {
$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;
$alreadyAssigned = $match && isset($assignedDefIds[$match->getId()->toRfc4122()]);
if ($match !== null) {
if (null !== $match) {
$action = 'match';
$preMatchId = $match->getId()->toRfc4122();
} elseif ($aspect['required'] || 'RECOMMENDED' === $aspect['usage']) {
@ -254,6 +320,7 @@ final class EbayAspectImportController extends AbstractController
'aspect' => $aspect,
'action' => $action,
'preMatchId' => $preMatchId,
'preFieldKey' => null,
'alreadyAssigned' => $alreadyAssigned,
'suggestedType' => $suggestedType,
];

View file

@ -120,8 +120,9 @@
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>
<option value="article_field" {% if row.action == 'article_field' %}selected{% endif %}>→ Artikelfeld</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>
<input type="hidden" name="aspects[{{ i }}][ebayName]" value="{{ aspect.name }}">
<input type="hidden" name="aspects[{{ i }}][ebayValues]" value="{{ aspect.values|join(',') }}">
@ -129,6 +130,15 @@
</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 %};">
<select name="aspects[{{ i }}][definitionId]" class="form-select form-select-sm">
{% for def in allDefs %}
@ -159,7 +169,7 @@
</td>
<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"
name="aspects[{{ i }}][required]"
value="1"
@ -263,12 +273,12 @@
/* ── Aspect table JS ─────────────────────────────────────────── */
function syncRow(index, action) {
['match', 'create', 'skip'].forEach(s => {
['article_field', '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';
if (req) req.style.display = (action !== 'skip' && action !== 'article_field') ? 'block' : 'none';
}
document.querySelectorAll('.aspect-action').forEach(sel => {