Skip to content

HQS-Artikelexport → Shopware 6: Mapping-Spezifikation (Textilmodul, Staffelpreise, Kategorien, Einheiten)

Status: Umgesetzt (Varianten, Staffelpreise, Kategorien, Einheiten, Abmessungen, Hersteller, EAN). Stand: 2026-08-06

Die tatsächlich implementierte Konfiguration weicht in den Schlüsselnamen vom Vorschlag in Abschnitt D ab. Maßgeblich ist Abschnitt G am Ende dieses Dokuments. Noch offen: Cross-Selling, Aktionspreise, Lieferzeiten, Streichpreise, Quell-Encoding-Umschaltung.

Grundlage dieser Spezifikation:

  1. MaxoutHqsConnector\Components\Custom.php — Referenzimplementierung aus dem Shopware-5-Plugin beim Kunden (lokale Arbeitskopie: /Users/hbee/Documents/Projekte/Netzkombyse/HQS/tmp/Custom.php, 372 Zeilen). Alle Zeilenangaben unten beziehen sich auf diese Datei.
  2. artikel_komplett.csv — realer HQS-Artikelexport des Neukunden (7,4 MB, 5.708 Datenzeilen, 47 Spalten). Alle Zahlen und Beispielwerte unten stammen aus diesem Export.

0. Datei-Eckdaten des Referenzexports

MerkmalWert
EncodingUTF-8 mit BOM (EF BB BF), vollständig gültiges UTF-8
ZeilenendeLF (\n), kein CRLF
Delimiter; (46 Semikola in der Kopfzeile → 47 Spalten)
Quoting" (342.572 Vorkommen; 1.860 Zeilen enthalten Semikola innerhalb von Quotes — naives Splitten am Semikolon ist nicht zulässig)
Eingebettete Zeilenumbrüchekeine (naives Splitten an \n ergibt exakt 5.709 Zeilen = Header + 5.708)
Datenzeilen5.708
Spalten47 (die letzte Spalte hat einen leeren Header und ist durchgehend leer — Artefakt des abschließenden Semikolons)
Steuerzeichen im Text0x07 (6.405×) und 0x08 (6.405×) — ausschließlich im Kategoriefeld; 0x09 (17.888×) ebenfalls nur dort

Wichtig zum Encoding: Dieser Export ist UTF-8. Andere HQS-Exporte sind CP1252 (das SW5-Plugin ruft deshalb $Importer->toUtf8() auf, Zeile 141). Die aktuelle Pipeline dekodiert in syncProducts.ts:252 fest mit new TextDecoder() (= UTF-8, BOM wird korrekt verworfen). Für CP1252-Exporte entstünden U+FFFD-Ersatzzeichen. → Feature-Flag sourceEncoding nötig (siehe Abschnitt 8).


A. Auswertung von Custom.php

A.0 Grundstruktur

Custom::convertCustomerData() (Zeilen 113–346) lädt den kompletten CSV-Iterator in ein Array (136–138), konvertiert nach UTF-8 (141), benennt die Spalten anhand von $columnMapping (25–51) um (144–149) und läuft dann einmal über alle Artikel (156–336). Rückgabe sind vier getrennte Datenströme (338–344):

php
return [
    'success' => 1,
    'importData'       => $this->customerData,
    'articlesToDelete' => $articlesToDelete,
    'imagesData'       => $imagesData,
    'purchaseSteps'    => $purchaseSteps
];

Entscheidend: Bilder und Staffelpreise werden aus dem Artikelstrom herausgelöst und als eigene, flache Listen mit ordernumber-Referenz geliefert. Das ist genau das Muster, das wir für Shopware 6 ebenfalls brauchen (Preise = eigene Entität, Medien = eigener Upload-Pfad).

Zwei Detailfallen in $columnMapping:

  • Der Schlüssel 'Artikelklasse' kommt doppelt vor (Zeile 45 → attr_attr7, Zeile 49 → attr_attr4). PHP behält den letzten Wert, also Artikelklasse → attr_attr4. attr_attr7 wird stattdessen von 'Handelsbuchart' belegt (Zeile 50). Das ist relevant, weil die Differenzbesteuerung auf attr_attr4 prüft (Zeile 265).
  • 'Textil-Nr.' => 'attr_attr6' (Zeile 44) ist der Varianten-Indikator.

A.1 Varianten / Textilmodul

Der Code (Zeilen 313–325):

php
//set variants
if($this->variantsEnabled && !empty($article[$this->variantIndicatorField])){
    if(!array_key_exists($article[$this->variantIndicatorField],$variants)){
        $variants[$article[$this->variantIndicatorField]]['mainnumber'] = $article['ordernumber'];
    }
    $article['mainnumber'] = $variants[$article[$this->variantIndicatorField]]['mainnumber'];
    $article['additionalText'] = !empty($article[$this->variantIndicatorField]) ? $article['attr_attr8'] . ' / ' . $article['attr_attr9'] : '';
    $article['configuratorOptions'] = !empty($article[$this->variantIndicatorField]) ? 'Farbe:'.$article['attr_attr8'].'|Größe:'.$article['attr_attr9'] : '';
}else{
    $article['mainnumber'] = $article['ordernumber'];
    $article['additionalText'] = '';
    $article['configuratorOptions'] = '';
}

mit $this->variantIndicatorField = 'attr_attr6' (Zeile 101), gesetzt nur wenn MaxoutHqsConnectorVariantsEnabled aktiv ist (97–102).

Daraus ergibt sich:

  1. Gruppierungsschlüssel ist Textil-Nr.. Es ist kein Verweis auf eine Artikelnummer, sondern eine reine Gruppen-ID.
  2. Das Hauptprodukt ist die erste Zeile der Gruppe in Dateireihenfolge. mainnumber wird beim ersten Auftreten der Textil-Nr. gesetzt und danach für alle weiteren Zeilen derselben Gruppe wiederverwendet. Es gibt keine explizite Master-Kennzeichnung in den Daten. In SW5 ist das Hauptprodukt selbst gleichzeitig die erste Variante.
  3. Optionsgruppen sind hart verdrahtet: genau zwei, Farbe (attr_attr8 ← Spalte Farbe, Zeile 46) und Größe (attr_attr9 ← Spalte Größe, Zeile 47). Format Gruppe:Wert|Gruppe:Wert. Es gibt keine dynamischen Gruppennamen aus Spaltenüberschriften und keine Vari verfügb. Option:-Spalten. Die Menge der Optionswerte einer Gruppe ergibt sich implizit aus allen Zeilen mit derselben Textil-Nr.
  4. Parent-vor-Child-Reihenfolge wird nicht behandelt. Das Plugin baut nur ein flaches Array mit mainnumber-Verweisen und übergibt es an den Shopware-5-Import (maxout_hqs_connector.importer, Zeile 117). Die Auflösung Parent→Child übernimmt dort der Shopware-Artikel-Importer. Für unsere Pipeline gibt es hier keine übertragbare Lösung — das müssen wir selbst bauen (siehe Implementierungsplan).
  5. Kein Leer-Check pro Achse: configuratorOptions enthält immer beide Gruppen, auch wenn Farbe leer ist → 'Farbe:|Größe:26'. Im Neukunden-Export betrifft das 6 Zeilen (siehe B.3).

Abweichung zum bisher rekonstruierten HQS-Wissen: Dieser Export nutzt nicht das Layout mit Variante Master / Vari verfügb. Option: <Gruppe> / Vari Zuordnung: <Gruppe> (wie bei Martins Musikkiste), sondern das ältere Layout mit Textil-Nr. + festen Spalten Farbe/Größe. HQS liefert das Textilmodul also in mindestens zwei verschiedenen Spaltenlayouts aus. Die Implementierung muss beide über Konfiguration abbilden können.

A.2 Staffelpreise

Preisstaffel wird auf purchasesteps gemappt (Zeile 38) und unverändert an den Importer durchgereicht (Zeilen 271–279):

php
$prices = $Importer->getPriceArray(
    array(
        'ordernumber'   => $article['ordernumber'],
        'price'         => !empty($article['Shop-Preis']) ? $article['Shop-Preis'] : $article['price'],
        'pseudoprice'   => !empty($article['Shop-Preis']) ? $article['price'] : 0,
        'baseprice'     => $article['baseprice'],
        'purchasesteps' => $article['purchasesteps']
    )
);

Die eigentliche Zerlegung des Staffel-Strings steht NICHT in Custom.php, sondern in Importer::getPriceArray(), das uns nicht vorliegt. Belegbar ist nur die Ausgabestruktur (281–295):

php
$article['price_EK']       = $prices[0]['price'];
$article['pseudoprice_EK'] = $prices[0]['pseudoPrice'];
$article['baseprice_EK']   = $prices[0]['basePrice'];

if(count($prices) > 1){
    foreach($prices as $price){
        $purchaseSteps[] = array(
            'ordernumber' => $price['ordernumber'],
            'price'       => $price['price'],
            'pricegroup'  => $price['customerGroupKey'],
            'from'        => $price['from'],
        );
    }
}

Daraus gesichert ableitbar:

  • $prices[0] ist immer der Basispreis (Menge 1), weitere Einträge sind die Staffeln.
  • Jede Staffel trägt eine Startmenge (from) und eine Kundengruppe (customerGroupKey). Die Kundengruppe kommt nicht aus dem CSV — im Datenformat gibt es kein Kundengruppen-Feld (siehe B.5). Sie wird also im Importer auf die Standardkundengruppe (EK, vgl. die Suffixe price_EK/pseudoprice_EK) gesetzt.
  • Eine Endmenge (to) wird nicht transportiert; sie ergibt sich implizit aus der nächsten Staffel.

Das reale Feldformat habe ich stattdessen aus den Daten rekonstruiert (Abschnitt B.5).

Preislogik im Übrigen (Zeilen 274–275): Ist Shop-Preis gesetzt und nicht '0' (PHP-empty() behandelt den String '0' als leer — das ist hier ein Glücksfall, weil der Export in 3.686 Zeilen tatsächlich 0 liefert), wird Shop-Preis zum Verkaufspreis und VK-Preis zum durchgestrichenen Pseudopreis. Sonst gilt VK-Preis.

Latenter Fehler: $article['baseprice'] (Zeile 276) existiert nie — es gibt keinen Mapping-Eintrag, der baseprice erzeugt, und die Spalte Grundpreis 1 ist in $columnMapping gar nicht enthalten. Der Grundpreis aus HQS wird vom SW5-Plugin also nicht übernommen.

Aktionspreise (Akt.Beg., Akt.End., Akt.Pr.) werden von Custom.php überhaupt nicht ausgewertet — die Spalten kommen im gesamten Code nicht vor.

A.3 Einheiten / Gewicht

Mapping (Zeilen 41–43):

HQS-SpalteSW5-Feld
GP-Mengepurchaseunit
GP-Faktorreferenceunit
GP-Art-MEunitid

Die Einheit wird über eine fest kodierte Lookup-Tabelle auf Shopware-5-Unit-IDs übersetzt (Zeilen 188–215):

php
$unitId = null;
switch($article['unitid']){
    case 'ml':       $unitId = 10; break;
    case 'Stck.':    $unitId = 9;  break;
    case 'kg':       $unitId = 6;  break;
    case 'l':        $unitId = 1;  break;
    case 'g':        $unitId = 2;  break;
    case 'lfm':      $unitId = 5;  break;
    case 'Paket(e)': $unitId = 8;  break;
}
$article['unitID'] = $unitId;

('ml' steht doppelt drin, Zeilen 190 und 196 — wirkungslos.) Unbekannte Einheiten ergeben null, also keine Einheit.

Gewicht wird nirgends behandelt. Weder in $columnMapping noch im Verarbeitungsloop taucht weight auf. Der Export enthält auch keine Gewichtsspalte (siehe B.7) — Masse H*B*T sind Abmessungen, MEH ist durchgehend Stk. → Für Shopware 6 ist weight aus diesem Export nicht befüllbar.

A.4 Kategorien

Zerlegung in getCategoryPath() (Zeilen 354–368):

php
$categoriesString = explode(utf8_decode(chr(7)), $path);
foreach ($categoriesString as $key => $cString) {
    if (!empty($cString)) {
        $parts      = explode(utf8_decode(chr(8)), $cString);
        $categories = explode(utf8_decode(chr(9)), $parts[1]);
        $catSort    = str_split($parts[0], 3);
        $ret[$key]  = array('categories' => $categories, 'sort' => $catSort);
    }
}

Also drei Trennzeichen — und diese sind im Neukunden-Export exakt so vorhanden (bestätigt über Byte-Histogramm und Feldanalyse):

ByteZeichenBedeutung
0x07BELTerminator je Kategoriezuordnung (Mehrfachzuordnung möglich)
0x08BStrennt Nummerncode von Klartextpfad
0x09TABtrennt Hierarchieebenen im Klartextpfad

Der Nummerncode wird in 3-Zeichen-Blöcke zerlegt (str_split($parts[0], 3)) — ein Block pro Ebene, verwendet als Sortierindex. Im Export gilt durchgehend strlen(code) == 3 * Anzahl Ebenen (0 Verstöße bei 6.405 Zuordnungen).

Anlage/Zuordnung (Zeilen 218–227):

php
if(!empty($article['categories'])) {
    $categories    = $this->getCategoryPath($article['categories']);
    $categoryIds   = $Importer->addCategories($categories);
    $articleCategoryIds = [];
    foreach($categoryIds as $categoryIdSibling){
        $articleCategoryIds[] = array_pop($categoryIdSibling);
    }
    $article['categories'] = implode('|', $articleCategoryIds);
}

Bemerkenswert: array_pop() nimmt pro Zuordnung nur die letzte (tiefste) Kategorie-ID. Der Artikel landet also ausschließlich im Blatt, nicht zusätzlich in allen Elternebenen. Die rekursive Anlage der Zwischenebenen macht Importer::addCategories()dieser Code liegt nicht vor; nur seine Rückgabestruktur (ein Array von Pfaden aus IDs) ist ableitbar. Das deckt sich aber mit der bereits getroffenen Produktentscheidung (vollständig rekursiver Pfadaufbau, Wiedererkennung über den Namen innerhalb der Elternebene).

A.5 Sonstiges, für den Import relevant

ThemaZeilenVerhalten
Löschmarker158–166Bei Vollimport ($importType == 2): Zeilen ohne Bez-1 gelten als gelöscht, werden aus dem Importstrom entfernt und in articlesToDelete gelegt. Im Delta-Import (else, Zeile 165) wird jede Artikelnummer in articlesToDelete geschrieben — das sieht nach einem Bug bzw. nach einem „erst löschen, dann neu anlegen"-Muster aus und sollte nicht übernommen werden.
Aktiv-Status169Hart active = 1 für jeden Artikel. Es gibt keine Aktiv-Spalte in HQS.
Auslaufartikel176Ausl. ∈ {J,A} → laststock = 1 (Abverkauf), sonst 0.
Never-out-of-stock179NOS Kz == 'J'instock += 100000.
Produktname182name = Bez-1 . ' - ' . Kaliber/Bez.2 — kundenspezifische Konkatenation (beim Waffen-Kunden ist Kaliber/Bez.2 das Kaliber). Im Neukunden-Export enthält die Spalte Materialangaben wie Material: 100% Baumwolle; die Konkatenation ist hier fachlich falsch und darf nicht blind übernommen werden.
Meta-Title185= supplier (also IShop-FAB).
Steuer244–263Mwst% → SW5-Tax-ID: 19→1, 7→4, 5→7, 16→6, 0/leer→5.
Differenzbesteuerung265–267Artikelklasse == 4 → Tax-ID 5 (0 %). Betrifft im Export 24 Artikel.
Release-Datum172aus ab (Datum), d.m.YY-m-d H:i:s.
Lieferzeit239Lieferzeit in Tagen, 0/leer → null.
EAN39GTIN-14ean.
Hersteller31IShop-FABsupplier (SW5 legt Hersteller implizit über den Namen an).
Beschreibung33Beschreibungdescription_long (enthält HTML).
Bilder297–310Artikelbild wird an : gesplittet; pro Name entsteht eine URL shopUrl + MaxoutHqsConnectorImageDir + name; erstes Bild main = 1, alle weiteren main = 2; position = Index; description aus Bildtext, sonst Produktname.
Cross-Selling / Alternativartikel229–236str_split($value, 6) — die Artikelnummern werden blind in 6-Zeichen-Blöcke zerhackt. Im Neukunden-Export sind die Nummern jedoch durch : getrennt ('200316:200315:202428:206867'). Dieses Verfahren würde hier kaputte Nummern erzeugen ('200316', ':20031', '5'). → In unserer Implementierung an : splitten, nicht an der Länge.
Übersetzungen327–334description_long_en → Sprach-ID 3. Spalte existiert im Neukunden-Export nicht.
GPSRNicht vorhanden. Weder im Code noch im Export gibt es GPSR-/Produktsicherheits-Felder (kein Hersteller-Kontakt, keine Verantwortliche Person, keine Warnhinweise).

B. Analyse des Kundenexports artikel_komplett.csv

B.1 Spaltenübersicht (alle 47 Spalten)

Befüllungsgrad bezogen auf 5.708 Datenzeilen; „distinct" zählt nur nicht-leere Werte.

#SpalteBefülltDistinctBeispiel- / Top-Werte
0Art-Nr100,0 %5.708207297, 200835immer exakt 6 Zeichen, keine Duplikate
1VK-Preis100,0 %1.21349.95, 39.95, 19.95 (Dezimalpunkt)
2IShop-FAB100,0 %281Overhues&Schüssler, Skogen, Fritzmann → Hersteller
3Warengruppe100,0 %168Hose (523), T-Shirt (263), Hemd 1/1 (256)
4Akt.Beg.0,04 %221.07.2026 00:00
5Akt.End.0,04 %131.08.2026 23:59
6Akt.Pr.100,0 %15fast immer 0; 17 Zeilen mit echtem Wert
7Alternativartikel0,05 %3215881
8Artikel-Zubehör1,0 %34200316:200315:202428:206867 (:-getrennt)
9Artikelbild99,0 %3.43110851903.jpg:10851903-2.jpg (:-getrennt)
10Bildtext3,2 %145Hundeführerschutzjacke Hubertus
11Artikelklasse100,0 %31 (5.660), 32 (24), 4 (24)
12Artikelmultimediadateien0,04 %2GHS01 Exlosionsgefahr.jpg
13Beschreibung80,2 %2.759HTML (<P>…<BR>…)
14Ausl.100,0 %3N (4.855), A (841), J (12)
15Bez-1100,0 %3.730Hemd Chambray 1/1-Armnie leer, kein Löschmarker im Export
16Bez-292,2 %2.915M oliv, L oliv — Variantenbezeichnung
17Kaliber/Bez.283,5 %2.737Material: 100% Baumwolle
18Best-Nr98,8 %4.07310151934 — Herstellerartikelnummer
19GTIN-1438,4 %2.19308590690331772immer 14 Stellen
20Textil-Nr.100,0 %5520 (3.608 = keine Variante), sonst Gruppen-ID
21Farbe36,7 %20oliv, grün, orange
22Größe36,8 %94M, L, XL, 46, C52/L
23Sais.0 %0leer
24Grundpreis 1100,0 %39fast immer 0; 46 Zeilen mit Wert (zwei Leerzeichen im Header!)
25GP-Basis-ME0,8 %2l (40), kg (6)
26GP-Art-ME0,8 %4ml (28), l (12), kg (5), g (1)
27GP-Menge0,8 %1850, 0.5, 200
28Handelsbuchart100,0 %20 (5.368), 1 (340)
29Logo84,1 %127osb_logo_neu-2.png — Herstellerlogo
30IShop-Navi99,5 %562Kategoriepfad, siehe B.6
31IShop-Navi (SEO)0 %0leer
32Masse H*B*T1,8 %640*30*10 (100×)
33MwSt100,0 %21 (5.686), 2 (22) — HQS-interner Steuerschlüssel
34Mwst%100,0 %219 (5.686), 7 (22)
35MEH100,0 %1Stk — konstant
36NOS Kz100,0 %2N (5.650), J (58)
37Preisstaffel6,0 %2955, 75.53, — siehe B.5
38ab (Datum)0,1 %201.09.2026
39Bestand100,0 %810 (2.588), 1 (1.733), 2 (425)
40Weblink aus dem Fabrikat1,1 %10https://www.pulsar-nv.com/glo/de
41Meta-Description6,9 %245Freitext
42Hervorheben0,1 %1J (5×)
43Lieferzeit in Tagen11,1 %15200, 30, 20
44GP-Faktor0,8 %21000 (29), 1 (17)
45Shop-Preis100,0 %8332.022 Zeilen ≠ 0
46(leer)0 %0Artefakt

B.2 Feldmapping HQS → ReadySync → Shopware 6

n/a = im aktuellen kanonischen Modell (ProductForUpsert, interfaces.ts:42-50) noch nicht vorhanden; muss ergänzt werden.

HQS-SpalteKanonisches ReadySync-FeldShopware-6-ZielAnmerkung
Art-Nrskuproduct.productNumbervorhanden
Bez-1nameproduct.nameohne die SW5-Konkatenation mit Kaliber/Bez.2
Bez-2variant_label (n/a)rein informativ, entspricht additionalText in SW5; in SW6 überflüssig (ergibt sich aus den Optionen)
Beschreibungdescription (n/a)product.descriptionHTML, muss durchgereicht werden
Kaliber/Bez.2custom_fieldproduct.customFields.hqs_bez2
IShop-FABmanufacturer (n/a)product.manufacturerIdproduct_manufacturer (Lookup/Anlage über Name)281 Hersteller
Logomanufacturer_media (n/a)product_manufacturer.mediaIdoptional, Phase 2
VK-Preisprice_grossproduct.price[0].gross/.netFallback-Preis
Shop-Preisprice_gross (Vorrang)product.price[0]wenn ≠ 0/leer → Verkaufspreis, VK-Preis wird listPrice
VK-Preis (bei gesetztem Shop-Preis)list_price_gross (n/a)product.price[0].listPriceStreichpreis
Akt.Pr. / Akt.Beg. / Akt.End.promo_* (n/a)In SW6 nicht ohne Rule-Builder abbildbar. Empfehlung: vorerst ignorieren (17 Zeilen), siehe Risiken
Preisstaffeltier_prices[] (n/a)product.prices[] (quantityStart, quantityEnd, ruleId, price)siehe B.5
Mwst%tax_rateproduct.taxId (Lookup über tax.taxRate)19 / 7
Artikelklasse == 4tax_rate = 0product.taxId (0 %)Differenzbesteuerung, 24 Artikel
Bestandstockproduct.stockvorhanden
NOS Kz == Jstock += 100000product.stockTransformation, 58 Artikel
Ausl. ∈ {J,A}is_closeout (n/a)product.isCloseout = true853 Artikel
GTIN-14ean (n/a)product.ean2.193 Artikel
Best-Nrmanufacturer_number (n/a)product.manufacturerNumber
Lieferzeit in Tagendelivery_time (n/a)product.deliveryTimeIddelivery_time (Lookup/Anlage)15 distinct Werte
Artikelbild_imageNames[]product_media / media:-getrennt, max. 10 pro Artikel
Bildtextimage_alt (n/a)media.alt
IShop-Navicategories[] (n/a)category + product_categorysiehe B.6
Warengruppecustom_fieldproduct.customFields.hqs_warengruppeAlternativ-Kategorisierung, 168 Werte
Textil-Nr.variant_group_key (n/a)Ableitung product.parentIdsiehe B.3
Farbeoptions[{group:'Farbe'}] (n/a)property_group_option + product_option + product_configurator_setting20 Werte
Größeoptions[{group:'Größe'}] (n/a)dito94 Werte
GP-Mengepurchase_unit (n/a)product.purchaseUnit46 Artikel
GP-Faktorreference_unit (n/a)product.referenceUnit
GP-Art-MEunit_name (n/a)product.unitIdunit (Lookup über Kurzform)ml,l,kg,g
MEHpack_unit (n/a)product.packUnitkonstant Stk
Grundpreis 1berechnetes Feld, in SW6 nicht schreibbar (SW6 rechnet selbst). Nur zur Validierung
Masse H*B*Tdimensions (n/a)product.height/width/lengthH*B*T in cm, wird beim Parsen in mm umgerechnet, 1,8 % befüllt
weightproduct.weightkeine Quellspalte vorhanden
Artikel-Zubehörcross_selling[] (n/a)product_cross_selling + ..._assigned_products:-getrennt, 34 Artikel
Alternativartikelcross_selling[] (n/a)dito, eigener Typ3 Artikel
Meta-Descriptionmeta_description (n/a)product.metaDescription
Hervorheben == JmarkAsTopseller (n/a)product.markAsTopseller5 Artikel
ab (Datum)release_date (n/a)product.releaseDate6 Artikel
Weblink aus dem Fabrikatcustom_fieldproduct.customFields.hqs_weblink
Handelsbuchartcustom_fieldproduct.customFields.hqs_handelsbuchart
activeproduct.activeHQS liefert kein Aktiv-Feld → konstant true (wie SW5, Zeile 169)
Sais., IShop-Navi (SEO), Artikelmultimediadateien, Spalte 46leer bzw. irrelevant

B.3 Varianten: Struktur und Datenqualität

KennzahlWert
Zeilen gesamt5.708
Zeilen mit Textil-Nr. == 0 (keine Variante)3.608
Zeilen mit echter Textil-Nr.2.100
Distinct Textil-Nr. (ohne 0)551
Gruppen mit genau 1 Zeile328
Gruppen mit > 1 Zeile (echte Varianten)223 (zusammen 1.772 Zeilen)
davon vom Import akzeptiert222 (1.760 Artikel + 222 synthetische Hauptprodukte); Gruppe 1056 fällt heraus, siehe Datenqualität
Erwartete Artikelzahl im Shop nach einem Vollimport5.708 Zeilen + 222 Hauptprodukte = 5.930
Gruppengröße (Multi-Gruppen): min / median / max2 / 8 / 42
Größenverteilung2:61, 3:17, 4:8, 5:10, 6:7, 7:7, 8:16, 9:16, 10:20, 11:15, 12:11, 13:7, 14:6, 15:1, 16:2, 18:8, 19:1, 20:4, 22:1, 24:1, 25:1, 39:1, 40:1, 42:1

Master-Erkennung: Die Textil-Nr. ist keine Artikelnummer. Alle 551 Gruppen-IDs (438, 353, 1056, 462, …) existieren nicht als eigene Art-Nr. Es gibt also keine Master-Zeile im Datensatz — anders als beim Layout mit Variante Master. Der Master ist per Konvention die erste Zeile der Gruppe (so macht es Custom.php:314-316). Für Shopware 6 bedeutet das: das Parent-Produkt muss synthetisch erzeugt werden.

Optionsachsen:

AchsenbelegungGruppen
nur Größe variiert179
Farbe und Größe variieren34
nur Farbe variiert10

Alle 328 Einzel-Gruppen tragen ebenfalls Optionswerte. Zeilen mit Textil-Nr. == 0 haben nie Farbe oder Größe — die Trennung ist sauber.

Datenqualität:

PrüfungErgebnis
Doppelte Art-Nr (SKU)0 — SKUs sind eindeutig
Gruppen mit doppelter Optionskombination (Farbe,Größe)0 — Shopware-Varianten sind konfliktfrei möglich
Master-Referenzen ins Leereentfällt (Textil-Nr. ist eine Gruppen-ID, kein SKU-Verweis)
Leere Größe in Multi-Gruppen0 von 1.772
Leere Farbe in Multi-Gruppen6 — alle in Gruppe 1056 („Hubertus Lederhose TRAPPER", Art-Nr 216572216577, nur Größe 24–31). Diese Gruppe ist faktisch einachsig; die Achse Farbe darf hier nicht angelegt werden. Umgesetzt in dropPartialAxes: eine Achse, die nicht jedes Mitglied trägt, wird verworfen. Danach kollidieren in 1056 die Größen (jede Größe kommt einmal mit und einmal ohne braun vor), die Gruppe wird als duplicate_option_combination abgelehnt und alle 12 Artikel gehen als Einzelartikel in den Shop
Multi-Gruppen mit abweichendem Bez-16 — der Parent-Name muss aus dem ersten Kind übernommen werden (oder aus dem häufigsten Wert)
Multi-Gruppen mit identischem Artikelbild über alle Kinder174 von 223
… identischer Beschreibung195 von 223
… identischer IShop-Navi219 von 223
… identischem VK-Preis119 von 223 → 104 Gruppen brauchen variantenindividuelle Preise
Multi-Gruppen mit variantenindividueller GTIN14

Verteilung in der Datei — das kritische Ergebnis:

KennzahlWert
Multi-Gruppen, die zusammenhängend in der Datei stehen0 von 223
Index-Spanne einer Gruppe (median / max)4.064 / 5.632 Zeilen
Berührte 15er-Chunks pro Multi-Gruppe (median / max)8 / 39
Multi-Gruppen, die mehr als einen 15er-Chunk berühren223 von 223 (100 %)

Beispiel Gruppe 462 („Hemd Chambray 1/1-Arm", 42 Varianten): die Zeilen liegen auf den Indizes 55, 56, 73, 104, 117, 413, 665, 700, … bis 5.531. Die Datei ist auch nicht nach Art-Nr sortiert.

Ein sequenzieller 15-Zeilen-Chunk kann Variantengruppen prinzipiell nicht korrekt verarbeiten. Ohne Umsortierung oder Zwei-Phasen-Verfahren ist Variantenimport unmöglich. Details im Implementierungsplan.

B.4 Chunking-Kontext

  • 5.708 Zeilen / 15 = 381 Selbstaufrufe der Edge Function pro Vollimport (syncProducts.ts:33, :302, :633).
  • Bei jedem Aufruf wird die komplette Datei erneut per FTP geladen und dekodiert (syncProducts.ts:205-212, :252, :261). Der vollständige Zeilensatz liegt also in jedem Chunk im Speicher vor. Das ist teuer (381 × 7,4 MB Download-Traffic), macht aber einen globalen Vorab-Pass innerhalb jedes Chunks praktisch kostenlos — genau das nutzt der bevorzugte Lösungsvorschlag.
  • MAX_IMPORT_FILE_SIZE = 20 MB (syncProducts.ts:35) — 7,4 MB liegen komfortabel darunter.

B.5 Staffelpreise: reales Format

Spalte Preisstaffel, befüllt in 345 von 5.708 Zeilen (6,0 %).

Format: kommaseparierte Paare Menge, Preis mit abschließendem Komma und Leerzeichen nach jedem Komma. Der Dezimaltrenner ist der Punkt. Die Preise haben keine feste Nachkommastellenzahl (7, 7.6, 75.53).

Reale Beispiele:

200835   VK-Preis 41.70    "5, 37.2, "
200852   VK-Preis 74.70    "2, 67.6, 8, 64.1, "
216710   VK-Preis 14.00    "2, 13.43, 10, 12.6, 20, 11.55, "
207409   VK-Preis 12.00    "10, 10.55, 20, 10.03, 40, 9.7, 200, 9.3, 400, 8.6, "
200912   VK-Preis 62.00    "5, 58.25, 10, 54.6, 15, 52.7, 20, 49.6, 40, 49, 60, 48.1, "
216472   VK-Preis  7.85    "20, 7.75, 40, 7.7, 100, 7.65, 200, 7.55, 300, 7.45, 400, 7.35, 500, 7.25, "
216471   VK-Preis  7.60    "20, 7.5, 50, 7.45, 100, 7.4, 200, 7.3, 300, 7.2, 400, 7.1, 500, 7, "

Strukturverteilung nach Anzahl der Komma-Segmente (immer mit leerem Schlusssegment):

SegmenteStaffelnAnzahl Artikel
31213
5240
7335
9448
1154
1363
1572

→ Maximal 7 Staffeln, meist genau eine. Die Mengen sind streng aufsteigend, die Preise streng absteigend, und die erste Staffelmenge ist immer ≥ 2. Es gibt keine Kundengruppen-Information im Feld — alle Staffeln gelten für die Standardkundengruppe.

Parsing-Regel:

tiers = split(value, ",")
       .map(trim)
       .filter(nonEmpty)
       .chunk(2)
       .map(([qty, price]) => ({ quantityStart: int(qty), gross: float(price) }))

Validierung: gerade Anzahl nicht-leerer Segmente; quantityStart streng monoton steigend; Preise ≤ Basispreis. Bei Verstoß: Staffel verwerfen, Warnung ins Job-Log, Basispreis behalten.

Shopware-6-Abbildung (product.prices, Entität product_price):

Shopware 6 fällt nicht automatisch auf product.price zurück, sobald Advanced Prices existieren — die Mengenkette muss lückenlos bei 1 beginnen. Für 216710 (Basis 14.00, Staffeln 2/13.43, 10/12.60, 20/11.55) entsteht:

ruleIdquantityStartquantityEndgross
<defaultRuleId>1114.00
<defaultRuleId>2913.43
<defaultRuleId>101912.60
<defaultRuleId>20null11.55

ruleId muss auf eine „immer wahr"-Rule zeigen. Empfehlung: pro Verbindung konfigurierbar (tierPriceRuleId), mit Auto-Anlage einer Rule „ReadySync – Alle Kunden" (rule mit einem andContainer ohne Bedingungen) beim ersten Import, falls nicht gesetzt.

B.6 Kategorien: reales Format

Spalte IShop-Navi, befüllt in 5.678 von 5.708 Zeilen (99,5 %); 30 Artikel ohne Kategorie.

Rohwerte (Steuerzeichen als \x07/\x08/TAB dargestellt):

207297  001001001015001\x08Waffen\tLangwaffen\tRepetierer\tMauser\tM12\x07
200835  003001017\x08Munition\tBüchsenpatronen\tSellier & Bellot\x07
205411  007001002003002\x08Bekleidung\tJagd\tHemden und T-Shirts\tMänner\tLangarmhemden\x07

Mehrfachzuordnung (zwei Kategorien, je mit \x07 terminiert):

202390  001005004\x08Waffen\tWaffenpflege\tReinigungsbürsten\x07005008004\x08Ausrüstung\tWaffenpflege\tReinigungsbürsten\x07
216764  002006003001\x08Freie Waffen\tMesser\tJagdmesser\tfeststehend\x07005004003001\x08Ausrüstung\tMesser\tJagdmesser\tfeststehend\x07
KennzahlWert
Kategoriezuordnungen gesamt6.405
Artikel mit mehr als einer Kategorie608
Distinct Kategoriepfade505
Maximale Hierarchietiefe5
Tiefenverteilung1: 26 · 2: 415 · 3: 2.852 · 4: 2.281 · 5: 831
Codelängen-Verteilung3: 26 · 6: 415 · 9: 2.852 · 12: 2.281 · 15: 831
Fehlformatierte Zuordnungen0 (len(code) == 3 × Tiefe gilt ausnahmslos)

Häufigste Pfade: Bekleidung / Jagd / Hosen / Hubertus (277), Bekleidung / Jagd / Hemden und T-Shirts / Männer / T-Shirt´s (225), Bekleidung / Jagd / Hemden und T-Shirts / Männer / Langarmhemden (187).

Verarbeitungsregel (deckt sich mit Custom.php:354-368 und den bereits getroffenen Produktentscheidungen):

  1. Wert an \x07 splitten, leere Segmente verwerfen → n Kategoriezuordnungen.
  2. Jedes Segment an \x08 in code und text splitten.
  3. text an TAB (\x09) splitten → Pfadebenen (Klartext).
  4. code in 3-Zeichen-Blöcke zerlegen → ein Sortierschlüssel pro Ebene. Verwendung: category.afterCategoryId-freie Sortierung ist in SW6 unpraktisch; stattdessen als customFields.hqs_category_code ablegen und optional für die Reihenfolge nutzen.
  5. Pfad rekursiv unter der konfigurierten Wurzelkategorie anlegen; Wiedererkennung je Ebene über den Namen innerhalb des jeweiligen Parents.
  6. Zuordnung des Artikels nur an die Blattkategorie (wie array_pop() in Zeile 224).
  7. Beim Re-Import werden die Zuordnungen ersetzt (HQS ist führend).
  8. Trennzeichen müssen konfigurierbar sein — der Musikkiste-Export verwendet ³/^/µ statt \x08/\x09/\x07.

B.7 Einheiten, Grundpreis, Gewicht

Nur 46 von 5.708 Artikeln (0,8 %) haben Grundpreisangaben. Reale Werte:

Art-NrGrundpreis 1GP-Basis-MEGP-Art-MEGP-MengeGP-FaktorMEHPreis
21116265.80ll0.51Stk32.90
207287550.00lml151000Stk8.25
21116524.90ll11Stk24.90
20474824.75ll0.41Stk9.90
204737379.00lml501000Stk18.95
20270936.00lml2001000Stk7.20
2159749.95kgkg21Stk19.90
20324047.80lml2501000Stk47.80 / 250 ml

Verifizierte Formel: Grundpreis 1 = Preis / GP-Menge × GP-Faktor

  • 207287: 8.25 / 15 × 1000 = 550.00
  • 211162: 32.90 / 0.5 × 1 = 65.80
  • 215974: 19.90 / 2 × 1 = 9.95

Das ist exakt die Shopware-6-Grundpreisformel price / purchaseUnit × referenceUnit. Daraus folgt das Mapping (identisch zu Custom.php:41-43):

HQSShopware 6
GP-Mengeproduct.purchaseUnit
GP-Faktorproduct.referenceUnit
GP-Art-MEproduct.unitId → Lookup in unit über shortCode/name
MEHproduct.packUnit (Stk), packUnitPlural (Stk)
Grundpreis 1nicht schreiben — SW6 berechnet den Wert; nur zur Plausibilisierung nutzen
GP-Basis-MEnur informativ (Anzeigebasis; SW6 zeigt pro {referenceUnit} {unit}, also z. B. „pro 1000 ml" statt „pro l")

Statt der SW5-Hardcoded-ID-Tabelle (Zeilen 188–215) wird in SW6 die Einheit per API über shortCode aufgelöst und bei Bedarf angelegt. Benötigt werden hier nur ml, l, kg, g.

Gewicht: Es existiert keine Gewichtsspalte. MEH ist konstant Stk, Masse H*B*T enthält Abmessungen (40*30*10, in 100 Zeilen identisch, insgesamt 1,8 % befüllt) und kann auf product.height/width/length gemappt werden — mehr ist nicht möglich. product.weight bleibt leer.


C. Benötigte Shopware-6-Entitäten

ZweckEntitätSchlüssel-FelderAnlagestrategie
Parent-Produktproductid, productNumber, name, taxId, price, active, stock=0synthetisch pro Textil-Nr.
Variantenproductid, parentId, productNumber, options[], ggf. eigener price/stock/eanpro CSV-Zeile
Optionsgruppeproperty_groupid, name (Farbe, Größe), displayType, sortingTypeLookup über Name, sonst anlegen
Optionswertproperty_group_optionid, groupId, nameLookup über (groupId,name)
Varianten-Zuordnungproduct.options (product_option)productId, optionIdpro Variante
Konfiguratorproduct_configurator_settingproductId (= Parent), optionIdalle in der Gruppe vorkommenden Optionswerte am Parent
Staffelpreiseproduct_price (product.prices)productId, ruleId, quantityStart, quantityEnd, price[]siehe B.5
Preisregelruleid, name, priority, bedingungsloser andContainereinmalig pro Verbindung, ID in Connection-Config
SteuersatztaxtaxRatenur Lookup, keine Anlage
Kategoriecategoryid, parentId, name, customFields.hqs_category_coderekursiv unter Wurzelkategorie
Kategoriezuordnungproduct_categoryproductId, categoryIdbeim Re-Import ersetzen
Einheitunitid, shortCode, nameLookup über shortCode, optional anlegen
Herstellerproduct_manufacturerid, nameLookup über Name, sonst anlegen
Lieferzeitdelivery_timeid, name, min, max, unit='day'Lookup/Anlage aus Lieferzeit in Tagen
Medienmedia, product_media, product.coverIdbestehender Media-Pfad (MediaTarget)
Cross-Sellingproduct_cross_selling + product_cross_selling_assigned_productsPhase 3

Parent-Produktnummer: Vorschlag {variantParentPrefix}{Textil-Nr.}, Default HQS-VAR-, also HQS-VAR-462. Begründung: Textil-Nr. ist stabil über Re-Importe, kollidiert dank Präfix nicht mit den 6-stelligen Art-Nr und ist ohne Zusatzstate rekonstruierbar (wichtig, weil jeder Chunk ein eigener Function-Aufruf ist).

Parent-Attribute: name, description, manufacturerId, taxId, Kategorien und Medien vom ersten Kind der Gruppe erben; price = niedrigster Kindpreis (price am Parent ist in SW6 Pflicht); stock = 0; active = true. Kindspezifisch bleiben: productNumber, ean, stock, price (nur wenn abweichend), options.


D. Feature-Flags in der Connection-Config

Alles generisch, keine kundenspezifischen Sonderwege. Vorschlag für connections.config (bzw. den Mapping-Datensatz):

jsonc
{
  "source": {
    "encoding": "utf-8",                 // "utf-8" | "cp1252" | "auto"
    "delimiter": "auto"                  // bestehende Auto-Erkennung
  },

  "variants": {
    "enabled": true,
    "mode": "group_key",                 // "group_key" (Textil-Nr.) | "master_sku" (Variante Master)
    "groupKeyColumn": "Textil-Nr.",
    "ignoreValues": ["0", ""],           // diese Werte = kein Variantenartikel
    "minChildren": 2,                    // Gruppen mit weniger Kindern -> eigenständiges Produkt
    "optionColumns": [                   // feste Spalte -> Optionsgruppe
      { "column": "Farbe",  "group": "Farbe"  },
      { "column": "Größe",  "group": "Größe"  }
    ],
    "optionValueSeparator": "³",         // nur für mode "master_sku" / Multi-Value-Spalten
    "skipEmptyOptionValues": true,       // Gruppe 1056: leere Farbe -> Achse weglassen
    "parentNumberPattern": "HQS-VAR-{groupKey}",
    "parentInheritsFrom": "first_child", // "first_child" | "most_common"
    "parentPriceStrategy": "min_child"
  },

  "tierPrices": {
    "enabled": true,
    "column": "Preisstaffel",
    "format": "qty_price_pairs",         // "5, 37.2, "
    "pairSeparator": ",",
    "ruleId": null,                      // null -> Rule "ReadySync – Alle Kunden" automatisch anlegen
    "emitBaseQuantityRow": true          // lückenlose Kette ab quantityStart = 1
  },

  "categories": {
    "enabled": true,
    "column": "IShop-Navi",
    "assignmentSeparator": "\u0007",     // BEL – Terminator je Zuordnung
    "codeTextSeparator": "\u0008",       // BS  – Code | Klartext
    "levelSeparator": "\u0009",          // TAB – Hierarchieebenen
    "codeChunkSize": 3,
    "rootCategoryId": "<uuid>",          // pro Verbindung konfigurierbare Wurzel
    "createMissing": true,
    "matchBy": "name_within_parent",
    "assignLeafOnly": true,
    "replaceOnReimport": true,
    "storeCodeInCustomField": "hqs_category_code"
  },

  "units": {
    "enabled": true,
    "purchaseUnitColumn": "GP-Menge",
    "referenceUnitColumn": "GP-Faktor",
    "unitColumn": "GP-Art-ME",
    "packUnitColumn": "MEH",
    "createMissingUnits": true
  },

  "manufacturer": {
    "enabled": true,
    "column": "IShop-FAB",
    "createMissing": true
  },

  "dimensions": {
    "enabled": true,
    "column": "Masse H*B*T",
    "separator": "*",
    "order": ["height", "width", "length"],
    "unit": "cm"
  },

  "crossSelling": {
    "enabled": false,
    "accessoryColumn": "Artikel-Zubehör",
    "alternativeColumn": "Alternativartikel",
    "separator": ":"
  },

  "chunking": {
    "strategy": "grouped",               // "sequential" | "grouped"
    "productsPerChunk": 15,
    "groupAwarePacking": true            // Chunk-Grenzen nie innerhalb einer Variantengruppe
  }
}

E. Offene Punkte und Risiken

#ThemaBewertung
1Netto oder brutto? Custom.php reicht die Preise an Importer::getPriceArray() durch, dessen Code nicht vorliegt. Ob VK-Preis/Shop-Preis/Preisstaffel brutto oder netto sind, ist aus den Daten allein nicht entscheidbar. Muss beim Kunden bzw. anhand des Live-Shops verifiziert werden, bevor implementiert wird. Das kanonische Feld heißt aktuell price_gross.
2Chunk-Anzahl. 381 Function-Aufrufe à 7,4 MB Download = ~2,8 GB Traffic pro Vollimport. Das ist unabhängig von den Varianten ein Skalierungsproblem und sollte mitgelöst werden (Datei einmalig in Supabase Storage cachen und pro Chunk von dort lesen).
3Aktionspreise. Akt.Pr./Akt.Beg./Akt.End. sind in SW6 nur über zeitabhängige Rules abbildbar. Betrifft 17 Artikel, davon nur 2 mit Zeitfenster. Empfehlung: Phase 1 ignorieren, in customFields ablegen.
4Gruppe 1056 (6 Varianten, Farbe leer): Ohne skipEmptyOptionValues entstünde eine Optionsgruppe mit leerem Wert. Muss getestet werden.
5328 Einzel-Gruppen. Mit minChildren: 2 werden sie zu eigenständigen Produkten. Falls HQS später Geschwister nachliefert, muss ein bestehendes eigenständiges Produkt in eine Variante umgewandelt werden (Setzen von parentId an einem bestehenden Produkt). Das ist in SW6 möglich, aber ein eigener Migrationspfad.
6Verwaiste Varianten beim Re-Import. Wenn eine Variante aus dem Export verschwindet, bleibt sie in SW6 stehen. Es braucht eine Aufräumstrategie (Deaktivieren statt Löschen), am besten am Ende des letzten Chunks.
7Kein Löschmarker im Datensatz. Bez-1 ist in allen 5.708 Zeilen befüllt — die SW5-Regel „leerer Name = löschen" (Zeile 160) ist hier nie aktiv. Ob HQS Löschungen überhaupt so signalisiert, ist unbestätigt.
8Kein Aktiv-Flag. Alle Produkte werden active = true. Bei Bestand = 0 (2.588 Artikel = 45 %) bleiben sie sichtbar. Das entspricht dem SW5-Verhalten, sollte aber als Feature-Flag deactivateOnZeroStock verfügbar sein.
9Handelsbuchart/Artikelklasse sind semantisch unklar. Artikelklasse == 4 löst laut SW5 Differenzbesteuerung aus (24 Artikel), 32 (24 Artikel) ist ungeklärt. Rückfrage beim Kunden.
10Bildpfad. SW5 baut die URL aus Shop-URL + konfiguriertem Verzeichnis (Zeile 301) — die Bilddateien liegen also bereits im Shop. Für ReadySync muss geklärt werden, ob die Bilder per FTP mitgeliefert werden oder über eine Basis-URL erreichbar sind.
11property_group_option-Skalierung. 94 Größen + 20 Farben sind unkritisch. Bei jedem Chunk müssen die Optionen aber aufgelöst werden → Caching pro Function-Aufruf, idealerweise Persistenz der ID-Map im Job-Datensatz.
12Zweites HQS-Layout. Die Implementierung muss mode: "master_sku" (Musikkiste-Layout mit Variante Master und Vari …-Spalten) mitdenken, auch wenn zunächst nur group_key umgesetzt wird.

F. Bezug zum aktuellen Code

DateiAktueller StandNotwendige Erweiterung
supabase/functions/_shared/integration/shop/interfaces.ts:42-50ProductForUpsert kennt nur sku, name, stock, active, price_gross, customFields, _imageNames~20 neue Felder (siehe B.2) plus parentSku, options[], tierPrices[], categoryPaths[]
supabase/functions/_shared/integration/shop/target/shopware.tsupsertProducts schreibt id, productNumber, name, stock, active, taxId, price[1], customFieldsParent/Child-Sync, Properties, Konfigurator, Advanced Prices, Kategorien, Einheiten, Hersteller
supabase/functions/_shared/mapping/productMappingEngine.tsmapRowsToProducts erzeugt flache Felder aus source_fieldsstrukturierte Zieltypen (Listen, Objekte), Multi-Value-Transformationen
supabase/functions/_shared/integration/product/syncProducts.ts:33,302,633sequenzielles 15er-Chunking über rawDataLinesgruppenbewusste Chunk-Planung (siehe Implementierungsplan)
types.ts:212-225CANONICAL_PRODUCT_FIELDS mit 12 EinträgenErweiterung um alle neuen kanonischen Felder, damit sie im Mapping-UI wählbar sind

G. Implementierung

G.1 Neue und geänderte Dateien

DateiRolle
supabase/functions/_shared/erp/hqs/productFields.tsParser für die HQS-Feldformate: Kategoriepfade, Staffelpreise, Abmessungen, Varianten-Marker, Nie-ausverkauft-Kennzeichen, verkettete Artikelnummern
supabase/functions/_shared/integration/product/variantGrouping.tsGruppierung, Validierung und gruppensichere Chunk-Planung
supabase/functions/_shared/integration/product/variantParents.tsAbleitung der Containerprodukte aus den gruppierten Zeilen
supabase/functions/_shared/integration/product/productSyncConfig.tsAuslesen der Feature-Flags aus der Verbindungskonfiguration
supabase/functions/_shared/integration/product/fileProductIndex.tsAlle Artikelnummern und Gruppenschlüssel einer Exportdatei – die Gegenseite von tenant_synced_skus
supabase/functions/_shared/integration/shop/productModel.tsKanonisches Produktmodell, gemeinsam genutzt von Orchestrator und Adaptern
supabase/functions/_shared/integration/shop/target/shopwareCatalog.tsAuflösen und Anlegen von Kategorien, Einheiten, Eigenschaften und Herstellern
supabase/functions/_shared/integration/shop/target/shopwarePayload.tsAufbau der Shopware-Produkt-Payloads (reine Funktionen)
scripts/analyze-hqs-export.tsDiagnosewerkzeug: prüft einen HQS-Export gegen die Parser, bevor eine Verbindung eingerichtet wird
scripts/smoke-shopware-import.tsSmoke-Test: schickt eine Stichprobe durch die echten Adapterpfade gegen eine Shopware-Instanz (Abschnitte G.7 und G.8)

Angepasst wurden interfaces.ts, target/types.ts, target/shopware.ts, adapters/shopware/index.ts, mapping/productMappingEngine.ts, product/syncProducts.ts und types.ts.

G.2 Mapping-Zielfelder

Diese Zielfelder stehen im Mapping-Editor zur Verfügung (CANONICAL_PRODUCT_FIELDS in types.ts):

ZielfeldHQS-Spalte im ReferenzexportWirkung
categoriesIShop-NaviKategoriepfade; fehlende Ebenen werden angelegt
tier_pricesPreisstaffelMengenstaffeln als product.prices
variant_groupTextil-Nr.Gruppierungsschlüssel; 0 bedeutet „keine Variante"
variant_option.FarbeFarbeOptionsachse; der Teil nach dem Punkt ist der Gruppenname
variant_option.GrößeGrößeweitere Optionsachse
unit.purchase_unitGP-Mengeproduct.purchaseUnit
unit.reference_unitGP-Faktorproduct.referenceUnit
unit.unit_nameGP-Art-MEproduct.unitId (Auflösung über Kurzform/Name)
unit.pack_unitMEHproduct.packUnit
dimensionsMasse H*B*Tproduct.height / width / length
weightproduct.weight (im Referenzexport nicht befüllbar)
eanGTIN-14product.ean
descriptionBeschreibungproduct.description
manufacturerIShop-FABproduct.manufacturerId, Anlage über den Namen

Weitere Optionsachsen brauchen keinen Code: Ein Mapping-Eintrag mit dem Ziel variant_option.<Gruppenname> genügt.

G.3 Konfiguration pro Verbindung

Alle Funktionen sind standardmäßig aus. Bestehende Verbindungen verhalten sich unverändert, bis sie eingeschaltet werden.

jsonc
{
  "product_sync": {
    "variants": {
      "enabled": true,
      "parent_sku_pattern": "VAR-{group}",  // Default
      "min_group_size": 2,                  // Default; 1 = auch Ein-Varianten-Gruppen anlegen
      "max_group_size": 200                 // Default; Schutz vor Sammelgruppen
    },
    "tier_prices": {
      "enabled": true,
      "price_rule_id": null                 // null = Regel „ReadySync – Staffelpreise" automatisch anlegen
    },
    "categories": {
      "enabled": true,
      "root_id": "<Shopware-Kategorie-UUID>",   // Pflicht, sonst werden Kategorien übersprungen
      "assignment_mode": "replace",             // "replace" | "append" (Default)
      "separator_preset": "control"             // "control" (Default) | "printable" (µ ³ ^)
      // alternativ einzeln: "separators": { "category": "µ", "sort_code": "³", "level": "^" }
    },
    "visibility": {
      "enabled": true,
      "sales_channel_ids": ["<Shopware-SalesChannel-UUID>", "..."],  // Pflicht, sonst passiert nichts
      "level": 30                               // 30 = überall (Default), 20 = nur Suche, 10 = nur Link
    }
  }
}

Fehlt bei aktivem Kategorie-Import die Wurzelkategorie, protokolliert der Job eine Warnung und importiert die Produkte ohne Kategorien, statt abzubrechen. Dasselbe gilt für die Sichtbarkeit ohne Verkaufskanäle.

Verkaufskanal-Sichtbarkeit (product_sync.visibility)

Ein Shopware-6-Produkt ohne Zeile in product_visibility erscheint in keiner Storefront — auch nicht bei active: true. Der Import hat solche Zeilen nie geschrieben; am Testshop bestätigt: Über die Sync-API angelegte Artikel sind über die Store-API nicht auffindbar, bis eine Zuordnung existiert. Das betrifft auch bestehende produktive Verbindungen; dort wurden die Kanäle bisher offenbar von Hand oder über dynamische Produktgruppen gesetzt.

Verhalten mit gesetzter Konfiguration:

  • Geschrieben wird nur für Einzelartikel und Varianten-Hauptprodukte. Varianten erben die Sichtbarkeit vom Container; eigene Zeilen wären überflüssig und würden Handänderungen erschweren.
  • Die Zeilen-ID wird deterministisch aus Produkt- und Kanal-ID abgeleitet, ein zweiter Lauf legt also keine Duplikate an.
  • Existiert für ein Produkt bereits eine Zuordnung zu diesem Kanal, wird sie nicht angefasst. Eine im Shop von Hand auf 20 gestellte Stufe bleibt auf 20.
  • Ohne enabled oder ohne Kanäle schreibt der Import wie bisher keine Sichtbarkeit und protokolliert einen Hinweis, dass die Artikel damit unsichtbar bleiben.

Die Kanal-UUIDs stehen in der Shopware-Administration unter Verkaufskanäle; die Oberfläche für die Auswahl steht noch aus (pages/ConnectionConfigPage.tsx).

G.4 Varianten in Shopware 6

Shopware 6 trennt strikt zwischen Container- und Variantenprodukt: Ein Produkt kann nicht gleichzeitig beides sein. Die Shopware-5-Konvention „das Hauptprodukt ist die erste Variante" ist deshalb nicht 1:1 übertragbar.

Umgesetzt ist stattdessen: Pro Gruppe entsteht ein synthetisches Containerprodukt mit der Artikelnummer VAR-{Textil-Nr.}. Jede ERP-Zeile wird zu einer Variante darunter und behält ihre eigene Artikelnummer. Der Container erbt Name, Beschreibung, Hersteller, Kategorien und Bilder von der ersten Zeile der Gruppe; die Varianten tragen Bestand, Preis, EAN und Optionswerte.

Eine Gruppe wird abgelehnt und ihre Artikel werden als Einzelprodukte importiert, wenn

  • sie weniger Mitglieder als min_group_size hat (Default 2),
  • sie mehr als max_group_size Mitglieder hat,
  • kein Mitglied Optionswerte trägt, oder
  • zwei Mitglieder dieselbe Optionskombination haben.

Die letzten beiden Fälle fängt genau die Sammelgruppen ab, die sonst hunderte unzusammenhängende Artikel zu einem unbrauchbaren Variantenprodukt verschmelzen würden. Jede Ablehnung landet mit Grund und betroffenen Artikelnummern im Job-Log.

Fallstrick Textil-Nr. = "0": HQS markiert damit „keine Variante". Das Shopware-5-Plugin prüft mit PHP-empty(), das den String "0" als leer behandelt — in JavaScript ist "0" dagegen wahr. Ohne die explizite Prüfung in isHqsVariantGroupKey() landeten im Referenzexport 3.608 unabhängige Artikel in einer einzigen Gruppe.

G.5 Chunking

Ein Chunk verarbeitet 15 Zeilen. Variantengruppen liegen im Export aber verstreut: Im Referenzexport berührt jede der 223 Mehrfachgruppen mehr als einen Chunk, im Extremfall 39.

Da Shopware den Container vor seinen Varianten braucht, werden die Zeilen vor der Verarbeitung so umsortiert, dass die Mitglieder einer Gruppe direkt aufeinanderfolgen; anschließend wird die Chunk-Grenze bis zum Gruppenende verschoben. Die Sortierung wird allein aus der Datei abgeleitet und ist damit in jedem Chunk-Aufruf identisch — ohne Zustand zwischen den Aufrufen. Der Folge-Offset ist deshalb das tatsächliche Chunk-Ende, nicht Offset + 15.

Im Referenzexport ergibt das 347 Chunks; der größte wächst durch das Nachziehen auf 44 Zeilen.

G.6 Export vorab prüfen

bash
deno run --allow-read scripts/analyze-hqs-export.ts <export.csv>

Das Skript zeigt Befüllungsgrad je Spalte, die erkannten Variantengruppen samt Ablehnungsgründen, die geparsten Staffelpreise, den vollständigen Kategoriebaum inklusive der Zahl anzulegender Kategorien und eine Gegenprobe, dass die Chunk-Planung jede Zeile genau einmal abdeckt. Mit --variant-column, --option-columns, --category-column, --tier-column und --separators=printable lässt es sich an abweichende HQS-Layouts anpassen.

G.7 Gegen einen echten Shop prüfen

Die Unit-Tests decken die Payload-Regeln ab, sprechen aber nie mit Shopware. Ob Varianten, Staffelpreise, Kategorien und Grundpreise auch von einer laufenden Instanz akzeptiert werden, prüft scripts/smoke-shopware-import.ts. Das Skript zieht eine kleine Stichprobe aus einem echten HQS-Export und schickt sie durch dieselben Adapterfunktionen wie die Edge Function — Mapping, Variantengruppierung, Katalog-Auflösung und Sync-API.

Nur gegen Testshops. Das Skript legt Artikel, Kategorien, Eigenschaften und einen Hersteller an. In einem Produktivshop hat es nichts zu suchen.

Zugangsdaten kommen ausschließlich aus der Umgebung — es gibt keine Defaults, und weder Skript noch Ausgabe noch die Aufräumdatei enthalten sie:

bash
export SHOPWARE_URL=https://<testshop>
export SHOPWARE_CLIENT_ID=<integration-id>
read -rs SHOPWARE_CLIENT_SECRET && export SHOPWARE_CLIENT_SECRET

Die drei Modi:

bash
# 1. Standard: anmelden, alle Payloads bauen und ausgeben – schreibt nichts
deno run --allow-net --allow-read --allow-env \
  scripts/smoke-shopware-import.ts <export.csv>

# 2. Stichprobe tatsächlich anlegen (fragt ohne --yes nach dem Hostnamen)
deno run --allow-net --allow-read --allow-env --allow-write \
  scripts/smoke-shopware-import.ts <export.csv> --write --yes

# 3. Alles wieder entfernen, was Schritt 2 angelegt hat
deno run --allow-net --allow-read --allow-env --allow-write \
  scripts/smoke-shopware-import.ts --cleanup --yes

Der Dry-Run kommt ohne Schreibrechte im Shop aus: Alle schreibenden Aufrufe werden abgefangen, bevor sie das Netz verlassen, und stattdessen als vollständige JSON-Payloads ausgegeben.

Stichprobe. Die Auswahl wird aus den Daten abgeleitet, nicht auf Artikelnummern verdrahtet: eine möglichst vollständige Variantengruppe mit zwei Optionsachsen, der Artikel mit den meisten Staffelpreisen, ein Artikel mit vollständigen Grundpreisfeldern (bevorzugt mit GP-Faktor > 1) und der Artikel mit den meisten bzw. tiefsten Kategoriepfaden. Trägt ein Artikel mehrere Merkmale, wird er nur einmal importiert. Alle Artikelnummern und Namen bekommen das Präfix SMOKE- (--prefix=), die Artikel werden inaktiv angelegt (--active schaltet das um).

Vorabprüfungen, die auch im Dry-Run laufen: Shopware-Version, Existenz der Wurzelkategorie (ohne --category-root= wird eine inaktive Wegwerf-Wurzel angelegt), Existenz der Preisregel für Staffelpreise, Existenz der benötigten unit-Einträge sowie welche der Stichproben-Artikel bereits im Shop liegen. Währung und Steuersatz kommen über den normalen preparePricing-Pfad.

Report. Am Ende steht je Konstrukt — Varianten, Staffelpreise, Kategorien, Grundpreis, Hersteller, Preisregel — ob es funktioniert hat. Nach --write wird die Stichprobe zusätzlich wieder ausgelesen und gegen die Erwartung geprüft (Anzahl Varianten, Konfigurator-Einträge, Preisstufen, Kategoriezuordnungen, unitId). Schlägt die Sync-API fehl, werden die verschachtelten errors[].detail vollständig ausgegeben, inklusive source.pointer und dem Wert, der an dieser Stelle gesendet wurde.

Mit --variant-group=<Textil-Nr.> lässt sich statt der automatischen Wahl eine bestimmte Gruppe testen — nützlich, um einen konkreten Verdachtsfall aus dem Export gegen den Shop zu halten.

Zu beachten:

  • Einheiten legt der Adapter bewusst nicht an (Shopware braucht je Shop-Sprache eine Übersetzung). Fehlt ml, bleibt der Grundpreis leer — die Vorabprüfung sagt das vorher.
  • --cleanup merkt sich die angelegten Datensätze in einer Datei unter $TMPDIR (--state= überschreibt den Pfad). Mehrere Schreibläufe ergänzen diese Datei, statt sie zu überschreiben. Fehlt sie, werden Artikel am Präfix erkannt und angelegte Testwurzeln samt Unterbaum am Namen <prefix>Smoketest …; Eigenschaften und Hersteller bleiben dann stehen.
  • Bilder deckt das Skript nicht ab; dafür fehlt die FTP-Quelle.
  • Direkt nach dem Schreiben sind abgeleitete Felder wie childCount noch nicht gefüllt; das erledigt der Indexer asynchron. Prüfungen dürfen sich nicht darauf stützen.

Ergebnis des ersten Laufs gegen Shopware 6.7.12.2 (Testshop, August 2026): Varianten inklusive parentId, Konfigurator-Einträgen und Optionszuordnung, Staffelpreise mit vorhandener Rule, rekursive Kategorie-Anlage über fünf Ebenen, Grundpreis mit unitId und Herstellerzuordnung funktionieren alle. Zwei Befunde führten zu Korrekturen:

  1. product.prices ist eine To-many-Association ohne natürlichen Schlüssel. Ohne feste IDs hängt jeder erneute Import eine zweite komplette Preisstaffel an den Artikel. Die Zeilen bekommen deshalb eine aus Produkt, Rule und quantityStart abgeleitete ID, und Zeilen, die das ERP nicht mehr liefert, werden nach dem Upsert gelöscht (analog zu den Kategoriezuordnungen).
  2. Shopware akzeptiert eine Variante, die nicht jede Optionsachse besetzt, ohne Fehlermeldung. Im Storefront ist sie danach unerreichbar, weil der Konfigurator jede Achse abfragt. Die Gruppierung verwirft solche Teil-Achsen jetzt (dropPartialAxes), und das Skript meldet verbliebene Fälle als Warnung. Im Kundenexport betrifft das genau die Gruppe 1056 (siehe B.3).

G.8 Fehlende Produkte gegen einen echten Shop prüfen

config.product_sync.obsolete_products entscheidet, was mit Artikeln passiert, die ein Vollexport nicht mehr enthält (none, disable, delete, dazu Schwellenwert und Übersteuerung). Die Regeln sind rein und unit-getestet; ob die echte Shopware-DAL und die Sync-API sich so verhalten, wie die Regeln annehmen, prüft derselbe Smoke-Test:

bash
# baut die Stichprobe auf, legt Sonderfälle an und fährt fünf Durchgänge
deno run --allow-net --allow-read --allow-env --allow-write \
  scripts/smoke-shopware-import.ts <export.csv> --obsolete=disable --yes \
  --category-root=<id> --price-rule=<id>

# dasselbe mit Löschen statt Deaktivieren
deno run --allow-net --allow-read --allow-env --allow-write \
  scripts/smoke-shopware-import.ts <export.csv> --obsolete=delete --yes

Der Modus importiert zuerst die normale Stichprobe (aktiv, damit ein Deaktivieren überhaupt sichtbar ist), legt dann die Fälle an, die ein Export nicht liefern kann, und fährt den echten runObsoleteProducts gegen eine künstlich verkleinerte Exportdatei:

TestdatensatzErwartung
SMOKE-FREMD-ARTIKELnie nachverfolgt → bleibt unangetastet
SMOKE-FREMD-VARIANTE am importierten Hauptproduktschützt seinen Container vor dem Löschen
SMOKE-VAR-9999 (Container ohne Varianten)Gruppe fehlt in der Datei → wird behandelt
SMOKE-VAR-8888 (Gruppe liegt in der Datei)bleibt stehen
SMOKE-MASSE-0001…0120Füllartikel, damit Lookup-Batches (100) und Sync-Batches (50) echt sind

Die fünf Durchgänge prüfen: Schwellenwert überschritten (muss null Anfragen auslösen), Lauf mit Obergrenze (Rest wird zurückgestellt), Rest plus Container-Abgleich, und zwei Wiederholungen — eine ohne und eine mit wieder nachverfolgtem Artikel, damit auch ein Lauf geprüft wird, der den Shop erneut erreicht. Die Obergrenze pro Lauf liegt im Betrieb bei 500 (DEFAULT_MAX_OBSOLETE_PER_RUN); der Test setzt sie unter die Kandidatenzahl, damit das Zurückstellen und der Folgelauf überhaupt auftreten. --bulk= steuert die Füllmenge, --skip-setup überspringt den erneuten Import.

Ergebnis gegen Shopware 6.7.12.2 (Testshop, August 2026): Der prefix-Filter der DAL existiert und filtert korrekt (ein unbekannter Filtertyp quittiert mit FRAMEWORK__INVALID_FILTER_QUERY, ein leeres Ergebnis ist also wirklich leer). Der Schwellenwert löst nachweislich keine einzige Anfrage aus. Die Sync-API nimmt Löschstapel zu 50 und Lookups zu 100 Artikelnummern an. Das Löschen eines Containers nimmt seine Varianten mit — direkt nachgemessen —, weshalb die Reihenfolge „Einzelartikel vor Containern“ nötig ist; ein bereits gelöschter Datensatz wird beim Löschen dagegen kommentarlos als notFound gemeldet und ist kein Fehler. Fremdartikel und Artikel, die die Datei noch liefert, bleiben unangetastet.

Drei Befunde führten zu Korrekturen:

  1. Ein fehlgeschlagener Lookup sah aus wie ein gelöschter Artikel. lookupProductsBySku schluckt Fehler und liefert dann ein leeres Ergebnis. Im Entfernungspfad heißt das: der Artikel gilt als „im Shop nicht mehr vorhanden“, wird aus tenant_synced_skus gestrichen und ist damit für jeden künftigen Abgleich unsichtbar. Beim Suchen der Varianten eines Containers war es noch gefährlicher — ein Container ohne gefundene Varianten gilt als leere Hülle und wird gelöscht, samt allem, was daran hängt. Der Entfernungspfad benutzt jetzt einen eigenen Lookup, der bei einer fehlgeschlagenen Suche abbricht.
  2. disable schrieb bei jedem Lauf erneut. Ein bereits deaktiviertes Hauptprodukt bleibt Kandidat, weil Container nicht nachverfolgt werden. Es wurde deshalb in jedem Lauf, der überhaupt etwas tut, erneut auf active: false gesetzt — und eine bewusste Reaktivierung durch den Shop-Betreiber wäre still wieder rückgängig gemacht worden. Artikel, die schon im Zielzustand sind, werden jetzt übersprungen und nur noch aus der Nachverfolgung genommen.
  3. Die Warnung zu Fremdvarianten behauptete zu viel. Sobald eine eigene Variante behandelt und aus tenant_synced_skus entfernt ist, sieht sie aus wie eine fremde. Die Meldung sagt jetzt, was der Code tatsächlich weiß: an dem Hauptprodukt hängen Varianten, die diese Verbindung nicht entfernen darf.

Zusätzlich sortieren die Container- und Variantensuchen jetzt explizit nach Artikelnummer. Ohne sort ist die Reihenfolge über Seitengrenzen hinweg nicht garantiert, und ab 250 Hauptprodukten hätte das einzelne Container überspringen können.

Offen: Die Batchgröße 200 beim Bereinigen von tenant_synced_skus liegt auf der Supabase-Seite und wurde nur gelesen, nicht ausgeführt. Und disable nimmt die Artikel ebenfalls aus der Nachverfolgung — sie werden dadurch nie gelöscht, auch wenn die Verbindung später auf delete umgestellt wird. Das ist eine fachliche Festlegung, keine Panne, sollte aber in der Oberfläche stehen.

G.9 Payload-Lücken, am Testshop nachgemessen

Artikel ohne Preis reißen kein Paket mehr mit. Am Testshop bestätigt: Ein neuer Artikel ohne price lässt die Sync-API das gesamte Paket ablehnen — im Referenzexport bis zu 44 Artikel wegen eines einzigen. Ein bestehender Artikel dagegen darf ohne Preis aktualisiert werden, Shopware behält den vorhandenen. Der Import schlägt deshalb vor dem Schreiben nach, ob es den preislosen Artikel schon gibt: bestehende laufen durch, neue werden übersprungen und mit Artikelnummer protokolliert. Zusätzlich wird ein trotzdem abgelehntes Paket ein zweites Mal ohne die Datensätze geschickt, die Shopware in seinen Fehlerzeigern benennt — ein unvorhergesehener Einzelfehler kostet dann seinen eigenen Datensatz, nicht mehr seine Nachbarn.

Varianten-Hauptprodukte bekommen ihr Bild. Die Bildnamen wurden für den Container zwar ermittelt, aber nie gelesen, weil der Upload nur über die Zeilen des Produkt-Chunks lief und der Container dort nicht vorkommt. Betraf alle 222 Hauptprodukte.

active wird nur geschrieben, wenn das Mapping es liefert. Vorher setzte jeder Lauf active: true und hob damit eine bewusste Deaktivierung im Shop wieder auf. Am Testshop nachgewiesen: Ein von Hand deaktivierter Artikel bleibt über einen weiteren Import hinweg deaktiviert. Shopware setzt bei Neuanlage ohnehin active = true, neue Artikel verhalten sich also unverändert.

Varianten-Container übernehmen den Steuersatz ihrer Gruppe, sofern alle Mitglieder denselben tragen; sonst bleibt es beim Standardsatz der Verbindung. Am Testshop mit 7 % gegengeprüft: Container und Varianten landen auf derselben taxId.

Abmessungen: Umrechnung cm → mm ist umgesetzt. Masse H*B*T liefert Werte wie 40*30*10 in Zentimetern; Shopware rechnet height/width/length in der Basiseinheit Millimeter (im Testshop bestätigt). Ohne Faktor 10 wäre aus 40 cm ein 4 cm hoher Karton geworden. Die Umrechnung sitzt jetzt in parseHqsDimensions (supabase/functions/_shared/erp/hqs/productFields.ts), also im ERP-Parser: Die Einheit ist eine Eigenschaft des ERP, das kanonische Modell führt Längen ausdrücklich in Millimetern (ProductMeasurements), und der Shop-Adapter reicht sie unverändert durch. Dass die Werte Zentimeter sind, belegt der Referenzexport selbst — der Artikel „Wildwanne 90x50x30 cm“ trägt exakt 90*50*30. Eine Zuordnung der Spalte ist damit gefahrlos möglich; sie ist allerdings nur zu 1,8 % befüllt und steht deshalb weiterhin nicht in der empfohlenen Erstkonfiguration.

Zwischenspeicher: lohnt sich, aber ist eine Infrastruktur-Entscheidung. Ohne ihn lädt und parst jeder der 347 Abschnitte die vollständige Datei: gemessen rund 0,5 s Dekodieren und Zerlegen pro Aufruf, dazu 7,4 MB Download — zusammen etwa 2,6 GB Traffic und knapp 3 Minuten reine Parse-Zeit pro Vollimport. Der Zwischenspeicher fällt bei fehlendem Bucket oder Storage-Ausfall lediglich mit einer Warnung auf den Direktweg zurück, das Einschalten ist also risikoarm. Trotzdem bleibt der Standard aus: Sonst begännen bestehende Verbindungen ungefragt, 7-MB-Dateien in den Supabase-Storage zu schreiben, dessen Bucket beim Kunden noch gar nicht angelegt sein muss. Empfehlung: Bucket bereitstellen und den Zwischenspeicher für diese Verbindung einschalten.

G.10 Nicht umgesetzt

Cross-Selling und Alternativartikel, Aktionspreise, Lieferzeiten (delivery_time), Streichpreise aus VK-Preis bei gesetztem Shop-Preis, isCloseout aus Ausl., markAsTopseller, releaseDate, metaDescription, manufacturerNumber sowie die Umschaltung des Quell-Encodings auf CP1252. Die Bewertungen dazu stehen unverändert in Abschnitt E.

ReadySync Dokumentation