Appearance
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:
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.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
| Merkmal | Wert |
|---|---|
| Encoding | UTF-8 mit BOM (EF BB BF), vollständig gültiges UTF-8 |
| Zeilenende | LF (\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üche | keine (naives Splitten an \n ergibt exakt 5.709 Zeilen = Header + 5.708) |
| Datenzeilen | 5.708 |
| Spalten | 47 (die letzte Spalte hat einen leeren Header und ist durchgehend leer — Artefakt des abschließenden Semikolons) |
| Steuerzeichen im Text | 0x07 (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 insyncProducts.ts:252fest mitnew TextDecoder()(= UTF-8, BOM wird korrekt verworfen). Für CP1252-Exporte entstündenU+FFFD-Ersatzzeichen. → Feature-FlagsourceEncodingnö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, alsoArtikelklasse → attr_attr4.attr_attr7wird stattdessen von'Handelsbuchart'belegt (Zeile 50). Das ist relevant, weil die Differenzbesteuerung aufattr_attr4prü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:
- Gruppierungsschlüssel ist
Textil-Nr.. Es ist kein Verweis auf eine Artikelnummer, sondern eine reine Gruppen-ID. - Das Hauptprodukt ist die erste Zeile der Gruppe in Dateireihenfolge.
mainnumberwird 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. - Optionsgruppen sind hart verdrahtet: genau zwei,
Farbe(attr_attr8← SpalteFarbe, Zeile 46) undGröße(attr_attr9← SpalteGröße, Zeile 47). FormatGruppe:Wert|Gruppe:Wert. Es gibt keine dynamischen Gruppennamen aus Spaltenüberschriften und keineVari verfügb. Option:-Spalten. Die Menge der Optionswerte einer Gruppe ergibt sich implizit aus allen Zeilen mit derselben Textil-Nr. - 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). - Kein Leer-Check pro Achse:
configuratorOptionsenthält immer beide Gruppen, auch wennFarbeleer 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 mitTextil-Nr.+ festen SpaltenFarbe/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 Suffixeprice_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-Spalte | SW5-Feld |
|---|---|
GP-Menge | purchaseunit |
GP-Faktor | referenceunit |
GP-Art-ME | unitid |
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):
| Byte | Zeichen | Bedeutung |
|---|---|---|
0x07 | BEL | Terminator je Kategoriezuordnung (Mehrfachzuordnung möglich) |
0x08 | BS | trennt Nummerncode von Klartextpfad |
0x09 | TAB | trennt 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
| Thema | Zeilen | Verhalten |
|---|---|---|
| Löschmarker | 158–166 | Bei 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-Status | 169 | Hart active = 1 für jeden Artikel. Es gibt keine Aktiv-Spalte in HQS. |
| Auslaufartikel | 176 | Ausl. ∈ {J,A} → laststock = 1 (Abverkauf), sonst 0. |
| Never-out-of-stock | 179 | NOS Kz == 'J' → instock += 100000. |
| Produktname | 182 | name = 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-Title | 185 | = supplier (also IShop-FAB). |
| Steuer | 244–263 | Mwst% → SW5-Tax-ID: 19→1, 7→4, 5→7, 16→6, 0/leer→5. |
| Differenzbesteuerung | 265–267 | Artikelklasse == 4 → Tax-ID 5 (0 %). Betrifft im Export 24 Artikel. |
| Release-Datum | 172 | aus ab (Datum), d.m.Y → Y-m-d H:i:s. |
| Lieferzeit | 239 | Lieferzeit in Tagen, 0/leer → null. |
| EAN | 39 | GTIN-14 → ean. |
| Hersteller | 31 | IShop-FAB → supplier (SW5 legt Hersteller implizit über den Namen an). |
| Beschreibung | 33 | Beschreibung → description_long (enthält HTML). |
| Bilder | 297–310 | Artikelbild 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 / Alternativartikel | 229–236 | str_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. |
| Übersetzungen | 327–334 | description_long_en → Sprach-ID 3. Spalte existiert im Neukunden-Export nicht. |
| GPSR | — | Nicht 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.
| # | Spalte | Befüllt | Distinct | Beispiel- / Top-Werte |
|---|---|---|---|---|
| 0 | Art-Nr | 100,0 % | 5.708 | 207297, 200835 — immer exakt 6 Zeichen, keine Duplikate |
| 1 | VK-Preis | 100,0 % | 1.213 | 49.95, 39.95, 19.95 (Dezimalpunkt) |
| 2 | IShop-FAB | 100,0 % | 281 | Overhues&Schüssler, Skogen, Fritzmann → Hersteller |
| 3 | Warengruppe | 100,0 % | 168 | Hose (523), T-Shirt (263), Hemd 1/1 (256) |
| 4 | Akt.Beg. | 0,04 % | 2 | 21.07.2026 00:00 |
| 5 | Akt.End. | 0,04 % | 1 | 31.08.2026 23:59 |
| 6 | Akt.Pr. | 100,0 % | 15 | fast immer 0; 17 Zeilen mit echtem Wert |
| 7 | Alternativartikel | 0,05 % | 3 | 215881 |
| 8 | Artikel-Zubehör | 1,0 % | 34 | 200316:200315:202428:206867 (:-getrennt) |
| 9 | Artikelbild | 99,0 % | 3.431 | 10851903.jpg:10851903-2.jpg (:-getrennt) |
| 10 | Bildtext | 3,2 % | 145 | Hundeführerschutzjacke Hubertus |
| 11 | Artikelklasse | 100,0 % | 3 | 1 (5.660), 32 (24), 4 (24) |
| 12 | Artikelmultimediadateien | 0,04 % | 2 | GHS01 Exlosionsgefahr.jpg |
| 13 | Beschreibung | 80,2 % | 2.759 | HTML (<P>…<BR>…) |
| 14 | Ausl. | 100,0 % | 3 | N (4.855), A (841), J (12) |
| 15 | Bez-1 | 100,0 % | 3.730 | Hemd Chambray 1/1-Arm — nie leer, kein Löschmarker im Export |
| 16 | Bez-2 | 92,2 % | 2.915 | M oliv, L oliv — Variantenbezeichnung |
| 17 | Kaliber/Bez.2 | 83,5 % | 2.737 | Material: 100% Baumwolle |
| 18 | Best-Nr | 98,8 % | 4.073 | 10151934 — Herstellerartikelnummer |
| 19 | GTIN-14 | 38,4 % | 2.193 | 08590690331772 — immer 14 Stellen |
| 20 | Textil-Nr. | 100,0 % | 552 | 0 (3.608 = keine Variante), sonst Gruppen-ID |
| 21 | Farbe | 36,7 % | 20 | oliv, grün, orange |
| 22 | Größe | 36,8 % | 94 | M, L, XL, 46, C52/L |
| 23 | Sais. | 0 % | 0 | leer |
| 24 | Grundpreis 1 | 100,0 % | 39 | fast immer 0; 46 Zeilen mit Wert (zwei Leerzeichen im Header!) |
| 25 | GP-Basis-ME | 0,8 % | 2 | l (40), kg (6) |
| 26 | GP-Art-ME | 0,8 % | 4 | ml (28), l (12), kg (5), g (1) |
| 27 | GP-Menge | 0,8 % | 18 | 50, 0.5, 200 |
| 28 | Handelsbuchart | 100,0 % | 2 | 0 (5.368), 1 (340) |
| 29 | Logo | 84,1 % | 127 | osb_logo_neu-2.png — Herstellerlogo |
| 30 | IShop-Navi | 99,5 % | 562 | Kategoriepfad, siehe B.6 |
| 31 | IShop-Navi (SEO) | 0 % | 0 | leer |
| 32 | Masse H*B*T | 1,8 % | 6 | 40*30*10 (100×) |
| 33 | MwSt | 100,0 % | 2 | 1 (5.686), 2 (22) — HQS-interner Steuerschlüssel |
| 34 | Mwst% | 100,0 % | 2 | 19 (5.686), 7 (22) |
| 35 | MEH | 100,0 % | 1 | Stk — konstant |
| 36 | NOS Kz | 100,0 % | 2 | N (5.650), J (58) |
| 37 | Preisstaffel | 6,0 % | 295 | 5, 75.53, — siehe B.5 |
| 38 | ab (Datum) | 0,1 % | 2 | 01.09.2026 |
| 39 | Bestand | 100,0 % | 81 | 0 (2.588), 1 (1.733), 2 (425) |
| 40 | Weblink aus dem Fabrikat | 1,1 % | 10 | https://www.pulsar-nv.com/glo/de |
| 41 | Meta-Description | 6,9 % | 245 | Freitext |
| 42 | Hervorheben | 0,1 % | 1 | J (5×) |
| 43 | Lieferzeit in Tagen | 11,1 % | 15 | 200, 30, 20 |
| 44 | GP-Faktor | 0,8 % | 2 | 1000 (29), 1 (17) |
| 45 | Shop-Preis | 100,0 % | 833 | 2.022 Zeilen ≠ 0 |
| 46 | (leer) | 0 % | 0 | Artefakt |
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-Spalte | Kanonisches ReadySync-Feld | Shopware-6-Ziel | Anmerkung |
|---|---|---|---|
Art-Nr | sku | product.productNumber | vorhanden |
Bez-1 | name | product.name | ohne die SW5-Konkatenation mit Kaliber/Bez.2 |
Bez-2 | variant_label (n/a) | — | rein informativ, entspricht additionalText in SW5; in SW6 überflüssig (ergibt sich aus den Optionen) |
Beschreibung | description (n/a) | product.description | HTML, muss durchgereicht werden |
Kaliber/Bez.2 | custom_field | product.customFields.hqs_bez2 | |
IShop-FAB | manufacturer (n/a) | product.manufacturerId → product_manufacturer (Lookup/Anlage über Name) | 281 Hersteller |
Logo | manufacturer_media (n/a) | product_manufacturer.mediaId | optional, Phase 2 |
VK-Preis | price_gross | product.price[0].gross/.net | Fallback-Preis |
Shop-Preis | price_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].listPrice | Streichpreis |
Akt.Pr. / Akt.Beg. / Akt.End. | promo_* (n/a) | — | In SW6 nicht ohne Rule-Builder abbildbar. Empfehlung: vorerst ignorieren (17 Zeilen), siehe Risiken |
Preisstaffel | tier_prices[] (n/a) | product.prices[] (quantityStart, quantityEnd, ruleId, price) | siehe B.5 |
Mwst% | tax_rate | product.taxId (Lookup über tax.taxRate) | 19 / 7 |
Artikelklasse == 4 | tax_rate = 0 | product.taxId (0 %) | Differenzbesteuerung, 24 Artikel |
Bestand | stock | product.stock | vorhanden |
NOS Kz == J | stock += 100000 | product.stock | Transformation, 58 Artikel |
Ausl. ∈ {J,A} | is_closeout (n/a) | product.isCloseout = true | 853 Artikel |
GTIN-14 | ean (n/a) | product.ean | 2.193 Artikel |
Best-Nr | manufacturer_number (n/a) | product.manufacturerNumber | |
Lieferzeit in Tagen | delivery_time (n/a) | product.deliveryTimeId → delivery_time (Lookup/Anlage) | 15 distinct Werte |
Artikelbild | _imageNames[] | product_media / media | :-getrennt, max. 10 pro Artikel |
Bildtext | image_alt (n/a) | media.alt | |
IShop-Navi | categories[] (n/a) | category + product_category | siehe B.6 |
Warengruppe | custom_field | product.customFields.hqs_warengruppe | Alternativ-Kategorisierung, 168 Werte |
Textil-Nr. | variant_group_key (n/a) | Ableitung product.parentId | siehe B.3 |
Farbe | options[{group:'Farbe'}] (n/a) | property_group_option + product_option + product_configurator_setting | 20 Werte |
Größe | options[{group:'Größe'}] (n/a) | dito | 94 Werte |
GP-Menge | purchase_unit (n/a) | product.purchaseUnit | 46 Artikel |
GP-Faktor | reference_unit (n/a) | product.referenceUnit | |
GP-Art-ME | unit_name (n/a) | product.unitId → unit (Lookup über Kurzform) | ml,l,kg,g |
MEH | pack_unit (n/a) | product.packUnit | konstant Stk |
Grundpreis 1 | — | — | berechnetes Feld, in SW6 nicht schreibbar (SW6 rechnet selbst). Nur zur Validierung |
Masse H*B*T | dimensions (n/a) | product.height/width/length | H*B*T in cm, wird beim Parsen in mm umgerechnet, 1,8 % befüllt |
| — | weight | product.weight | keine Quellspalte vorhanden |
Artikel-Zubehör | cross_selling[] (n/a) | product_cross_selling + ..._assigned_products | :-getrennt, 34 Artikel |
Alternativartikel | cross_selling[] (n/a) | dito, eigener Typ | 3 Artikel |
Meta-Description | meta_description (n/a) | product.metaDescription | |
Hervorheben == J | markAsTopseller (n/a) | product.markAsTopseller | 5 Artikel |
ab (Datum) | release_date (n/a) | product.releaseDate | 6 Artikel |
Weblink aus dem Fabrikat | custom_field | product.customFields.hqs_weblink | |
Handelsbuchart | custom_field | product.customFields.hqs_handelsbuchart | |
| — | active | product.active | HQS liefert kein Aktiv-Feld → konstant true (wie SW5, Zeile 169) |
Sais., IShop-Navi (SEO), Artikelmultimediadateien, Spalte 46 | — | — | leer bzw. irrelevant |
B.3 Varianten: Struktur und Datenqualität
| Kennzahl | Wert |
|---|---|
| Zeilen gesamt | 5.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 Zeile | 328 |
| Gruppen mit > 1 Zeile (echte Varianten) | 223 (zusammen 1.772 Zeilen) |
| davon vom Import akzeptiert | 222 (1.760 Artikel + 222 synthetische Hauptprodukte); Gruppe 1056 fällt heraus, siehe Datenqualität |
| Erwartete Artikelzahl im Shop nach einem Vollimport | 5.708 Zeilen + 222 Hauptprodukte = 5.930 |
| Gruppengröße (Multi-Gruppen): min / median / max | 2 / 8 / 42 |
| Größenverteilung | 2: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:
| Achsenbelegung | Gruppen |
|---|---|
nur Größe variiert | 179 |
Farbe und Größe variieren | 34 |
nur Farbe variiert | 10 |
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üfung | Ergebnis |
|---|---|
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 Leere | entfällt (Textil-Nr. ist eine Gruppen-ID, kein SKU-Verweis) |
Leere Größe in Multi-Gruppen | 0 von 1.772 |
Leere Farbe in Multi-Gruppen | 6 — alle in Gruppe 1056 („Hubertus Lederhose TRAPPER", Art-Nr 216572–216577, 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-1 | 6 — der Parent-Name muss aus dem ersten Kind übernommen werden (oder aus dem häufigsten Wert) |
Multi-Gruppen mit identischem Artikelbild über alle Kinder | 174 von 223 |
… identischer Beschreibung | 195 von 223 |
… identischer IShop-Navi | 219 von 223 |
… identischem VK-Preis | 119 von 223 → 104 Gruppen brauchen variantenindividuelle Preise |
| Multi-Gruppen mit variantenindividueller GTIN | 14 |
Verteilung in der Datei — das kritische Ergebnis:
| Kennzahl | Wert |
|---|---|
| Multi-Gruppen, die zusammenhängend in der Datei stehen | 0 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ühren | 223 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):
| Segmente | Staffeln | Anzahl Artikel |
|---|---|---|
| 3 | 1 | 213 |
| 5 | 2 | 40 |
| 7 | 3 | 35 |
| 9 | 4 | 48 |
| 11 | 5 | 4 |
| 13 | 6 | 3 |
| 15 | 7 | 2 |
→ 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:
| ruleId | quantityStart | quantityEnd | gross |
|---|---|---|---|
<defaultRuleId> | 1 | 1 | 14.00 |
<defaultRuleId> | 2 | 9 | 13.43 |
<defaultRuleId> | 10 | 19 | 12.60 |
<defaultRuleId> | 20 | null | 11.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\x07Mehrfachzuordnung (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| Kennzahl | Wert |
|---|---|
| Kategoriezuordnungen gesamt | 6.405 |
| Artikel mit mehr als einer Kategorie | 608 |
| Distinct Kategoriepfade | 505 |
| Maximale Hierarchietiefe | 5 |
| Tiefenverteilung | 1: 26 · 2: 415 · 3: 2.852 · 4: 2.281 · 5: 831 |
| Codelängen-Verteilung | 3: 26 · 6: 415 · 9: 2.852 · 12: 2.281 · 15: 831 |
| Fehlformatierte Zuordnungen | 0 (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):
- Wert an
\x07splitten, leere Segmente verwerfen → n Kategoriezuordnungen. - Jedes Segment an
\x08incodeundtextsplitten. textan TAB (\x09) splitten → Pfadebenen (Klartext).codein 3-Zeichen-Blöcke zerlegen → ein Sortierschlüssel pro Ebene. Verwendung:category.afterCategoryId-freie Sortierung ist in SW6 unpraktisch; stattdessen alscustomFields.hqs_category_codeablegen und optional für die Reihenfolge nutzen.- Pfad rekursiv unter der konfigurierten Wurzelkategorie anlegen; Wiedererkennung je Ebene über den Namen innerhalb des jeweiligen Parents.
- Zuordnung des Artikels nur an die Blattkategorie (wie
array_pop()in Zeile 224). - Beim Re-Import werden die Zuordnungen ersetzt (HQS ist führend).
- 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-Nr | Grundpreis 1 | GP-Basis-ME | GP-Art-ME | GP-Menge | GP-Faktor | MEH | Preis |
|---|---|---|---|---|---|---|---|
| 211162 | 65.80 | l | l | 0.5 | 1 | Stk | 32.90 |
| 207287 | 550.00 | l | ml | 15 | 1000 | Stk | 8.25 |
| 211165 | 24.90 | l | l | 1 | 1 | Stk | 24.90 |
| 204748 | 24.75 | l | l | 0.4 | 1 | Stk | 9.90 |
| 204737 | 379.00 | l | ml | 50 | 1000 | Stk | 18.95 |
| 202709 | 36.00 | l | ml | 200 | 1000 | Stk | 7.20 |
| 215974 | 9.95 | kg | kg | 2 | 1 | Stk | 19.90 |
| 203240 | 47.80 | l | ml | 250 | 1000 | Stk | 47.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):
| HQS | Shopware 6 |
|---|---|
GP-Menge | product.purchaseUnit |
GP-Faktor | product.referenceUnit |
GP-Art-ME | product.unitId → Lookup in unit über shortCode/name |
MEH | product.packUnit (Stk), packUnitPlural (Stk) |
Grundpreis 1 | nicht schreiben — SW6 berechnet den Wert; nur zur Plausibilisierung nutzen |
GP-Basis-ME | nur 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
| Zweck | Entität | Schlüssel-Felder | Anlagestrategie |
|---|---|---|---|
| Parent-Produkt | product | id, productNumber, name, taxId, price, active, stock=0 | synthetisch pro Textil-Nr. |
| Varianten | product | id, parentId, productNumber, options[], ggf. eigener price/stock/ean | pro CSV-Zeile |
| Optionsgruppe | property_group | id, name (Farbe, Größe), displayType, sortingType | Lookup über Name, sonst anlegen |
| Optionswert | property_group_option | id, groupId, name | Lookup über (groupId,name) |
| Varianten-Zuordnung | product.options (product_option) | productId, optionId | pro Variante |
| Konfigurator | product_configurator_setting | productId (= Parent), optionId | alle in der Gruppe vorkommenden Optionswerte am Parent |
| Staffelpreise | product_price (product.prices) | productId, ruleId, quantityStart, quantityEnd, price[] | siehe B.5 |
| Preisregel | rule | id, name, priority, bedingungsloser andContainer | einmalig pro Verbindung, ID in Connection-Config |
| Steuersatz | tax | taxRate | nur Lookup, keine Anlage |
| Kategorie | category | id, parentId, name, customFields.hqs_category_code | rekursiv unter Wurzelkategorie |
| Kategoriezuordnung | product_category | productId, categoryId | beim Re-Import ersetzen |
| Einheit | unit | id, shortCode, name | Lookup über shortCode, optional anlegen |
| Hersteller | product_manufacturer | id, name | Lookup über Name, sonst anlegen |
| Lieferzeit | delivery_time | id, name, min, max, unit='day' | Lookup/Anlage aus Lieferzeit in Tagen |
| Medien | media, product_media, product.coverId | bestehender Media-Pfad (MediaTarget) | |
| Cross-Selling | product_cross_selling + product_cross_selling_assigned_products | Phase 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
| # | Thema | Bewertung |
|---|---|---|
| 1 | Netto 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. | |
| 2 | Chunk-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). | |
| 3 | Aktionspreise. 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. | |
| 4 | Gruppe 1056 (6 Varianten, Farbe leer): Ohne skipEmptyOptionValues entstünde eine Optionsgruppe mit leerem Wert. Muss getestet werden. | |
| 5 | 328 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. | |
| 6 | Verwaiste 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. | |
| 7 | Kein 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. | |
| 8 | Kein 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. | |
| 9 | Handelsbuchart/Artikelklasse sind semantisch unklar. Artikelklasse == 4 löst laut SW5 Differenzbesteuerung aus (24 Artikel), 32 (24 Artikel) ist ungeklärt. Rückfrage beim Kunden. | |
| 10 | Bildpfad. 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. | |
| 11 | property_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. | |
| 12 | Zweites 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
| Datei | Aktueller Stand | Notwendige Erweiterung |
|---|---|---|
supabase/functions/_shared/integration/shop/interfaces.ts:42-50 | ProductForUpsert 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.ts | upsertProducts schreibt id, productNumber, name, stock, active, taxId, price[1], customFields | Parent/Child-Sync, Properties, Konfigurator, Advanced Prices, Kategorien, Einheiten, Hersteller |
supabase/functions/_shared/mapping/productMappingEngine.ts | mapRowsToProducts erzeugt flache Felder aus source_fields | strukturierte Zieltypen (Listen, Objekte), Multi-Value-Transformationen |
supabase/functions/_shared/integration/product/syncProducts.ts:33,302,633 | sequenzielles 15er-Chunking über rawDataLines | gruppenbewusste Chunk-Planung (siehe Implementierungsplan) |
types.ts:212-225 | CANONICAL_PRODUCT_FIELDS mit 12 Einträgen | Erweiterung um alle neuen kanonischen Felder, damit sie im Mapping-UI wählbar sind |
G. Implementierung
G.1 Neue und geänderte Dateien
| Datei | Rolle |
|---|---|
supabase/functions/_shared/erp/hqs/productFields.ts | Parser für die HQS-Feldformate: Kategoriepfade, Staffelpreise, Abmessungen, Varianten-Marker, Nie-ausverkauft-Kennzeichen, verkettete Artikelnummern |
supabase/functions/_shared/integration/product/variantGrouping.ts | Gruppierung, Validierung und gruppensichere Chunk-Planung |
supabase/functions/_shared/integration/product/variantParents.ts | Ableitung der Containerprodukte aus den gruppierten Zeilen |
supabase/functions/_shared/integration/product/productSyncConfig.ts | Auslesen der Feature-Flags aus der Verbindungskonfiguration |
supabase/functions/_shared/integration/product/fileProductIndex.ts | Alle Artikelnummern und Gruppenschlüssel einer Exportdatei – die Gegenseite von tenant_synced_skus |
supabase/functions/_shared/integration/shop/productModel.ts | Kanonisches Produktmodell, gemeinsam genutzt von Orchestrator und Adaptern |
supabase/functions/_shared/integration/shop/target/shopwareCatalog.ts | Auflösen und Anlegen von Kategorien, Einheiten, Eigenschaften und Herstellern |
supabase/functions/_shared/integration/shop/target/shopwarePayload.ts | Aufbau der Shopware-Produkt-Payloads (reine Funktionen) |
scripts/analyze-hqs-export.ts | Diagnosewerkzeug: prüft einen HQS-Export gegen die Parser, bevor eine Verbindung eingerichtet wird |
scripts/smoke-shopware-import.ts | Smoke-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):
| Zielfeld | HQS-Spalte im Referenzexport | Wirkung |
|---|---|---|
categories | IShop-Navi | Kategoriepfade; fehlende Ebenen werden angelegt |
tier_prices | Preisstaffel | Mengenstaffeln als product.prices |
variant_group | Textil-Nr. | Gruppierungsschlüssel; 0 bedeutet „keine Variante" |
variant_option.Farbe | Farbe | Optionsachse; der Teil nach dem Punkt ist der Gruppenname |
variant_option.Größe | Größe | weitere Optionsachse |
unit.purchase_unit | GP-Menge | product.purchaseUnit |
unit.reference_unit | GP-Faktor | product.referenceUnit |
unit.unit_name | GP-Art-ME | product.unitId (Auflösung über Kurzform/Name) |
unit.pack_unit | MEH | product.packUnit |
dimensions | Masse H*B*T | product.height / width / length |
weight | – | product.weight (im Referenzexport nicht befüllbar) |
ean | GTIN-14 | product.ean |
description | Beschreibung | product.description |
manufacturer | IShop-FAB | product.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
enabledoder 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_sizehat (Default 2), - sie mehr als
max_group_sizeMitglieder 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 inisHqsVariantGroupKey()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_SECRETDie 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 --yesDer 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. --cleanupmerkt 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
childCountnoch 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:
product.pricesist 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 undquantityStartabgeleitete ID, und Zeilen, die das ERP nicht mehr liefert, werden nach dem Upsert gelöscht (analog zu den Kategoriezuordnungen).- 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 Gruppe1056(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:
| Testdatensatz | Erwartung |
|---|---|
SMOKE-FREMD-ARTIKEL | nie nachverfolgt → bleibt unangetastet |
SMOKE-FREMD-VARIANTE am importierten Hauptprodukt | schü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…0120 | Fü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:
- Ein fehlgeschlagener Lookup sah aus wie ein gelöschter Artikel.
lookupProductsBySkuschluckt Fehler und liefert dann ein leeres Ergebnis. Im Entfernungspfad heißt das: der Artikel gilt als „im Shop nicht mehr vorhanden“, wird austenant_synced_skusgestrichen 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. disableschrieb 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 aufactive: falsegesetzt — 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.- Die Warnung zu Fremdvarianten behauptete zu viel. Sobald eine eigene Variante behandelt und aus
tenant_synced_skusentfernt 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.