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:
parent
929f5a0b2d
commit
61ce94bc6f
5 changed files with 151 additions and 18 deletions
26
migrations/Version20260520090000.php
Normal file
26
migrations/Version20260520090000.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 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -91,12 +113,13 @@ final class EbayAspectImportController extends AbstractController
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('admin/ebay/aspect_import.html.twig', [
|
return $this->render('admin/ebay/aspect_import.html.twig', [
|
||||||
'articleType' => $articleType,
|
'articleType' => $articleType,
|
||||||
'rows' => $rows,
|
'rows' => $rows,
|
||||||
'allDefs' => $allDefs,
|
'allDefs' => $allDefs,
|
||||||
'counts' => $counts,
|
'articleFields' => self::ARTICLE_FIELDS,
|
||||||
'categoryId' => $categoryId,
|
'counts' => $counts,
|
||||||
'searchUrl' => $this->generateUrl('admin_ebay_category_search'),
|
'categoryId' => $categoryId,
|
||||||
|
'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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,10 @@
|
||||||
<select name="aspects[{{ i }}][action]"
|
<select name="aspects[{{ i }}][action]"
|
||||||
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 => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue