B2B-Checkout: Preise, Konditionen und Live-Bestand

Kundenspezifische Preise, Konditionen und Live-Bestand im Shopware-B2B-Checkout: Architektur der ERP-Anbindung mit Caching, Fallback und Messenger.

Ein B2B-Einkäufer bestellt nicht spontan. Er erwartet seine Preise, seine Konditionen und verlässliche Lieferzusagen. Stimmt eines davon nicht, ruft er an, statt zu bestellen. Der B2B-Checkout ist deshalb vor allem ein Integrationsthema. Dieser Artikel zeigt, wie Sie Preise, Konditionen und Bestände aus ERP oder SAP zuverlässig in Shopware bringen, ohne den Shop auszubremsen.

Einkäufer prüft am Bildschirm Preise und Bestände, im Hintergrund ein Lager

Die Architekturfrage: Wer ist die Quelle der Wahrheit?

Bevor Sie programmieren, klären Sie pro Datenart, wo sie liegt und wie frisch sie sein muss:

DatenQuelleFrischebedarfEmpfohlenes Muster
Stammdaten (Artikel, Texte)PIM/ERPStundenImport per Schnittstelle
ListenpreiseERPStunden bis TageImport, im Shop gespeichert
Kundenspezifische PreiseERPMinutenAbruf mit Cache pro Kunde
BestandERP/LagerSekunden bis MinutenEreignisbasiert plus Prüfung im Checkout
BestellungShopwaresofortAsynchroner Export mit Wiederholung

Die Faustregel: Alles, was langsam ist oder ausfallen kann, gehört nicht in den Pfad, den jeder Seitenaufruf nimmt.

Kundenspezifische Preise im Warenkorb

Shopware berechnet den Warenkorb über Prozessoren. Mit einem eigenen CartProcessorInterface setzen Sie den Preis einer Position aus Ihren ERP-Daten. Das Grundgerüst:

final class ErpPriceProcessor implements CartProcessorInterface
{
    public function __construct(private ErpPriceProvider $prices) {}

    public function process(
        CartDataCollection $data,
        Cart $original,
        Cart $toCalculate,
        SalesChannelContext $context,
        CartBehavior $behavior
    ): void {
        $customerNumber = $context->getCustomer()?->getCustomerNumber();
        if ($customerNumber === null) {
            return;
        }

        foreach ($toCalculate->getLineItems()->filterType(LineItem::PRODUCT_LINE_ITEM_TYPE) as $item) {
            $price = $this->prices->priceFor($customerNumber, $item->getReferencedId(), $item->getQuantity());
            if ($price === null) {
                continue;
            }
            $item->setPriceDefinition(new QuantityPriceDefinition($price, $item->getPrice()?->getTaxRules() ?? new TaxRuleCollection(), $item->getQuantity()));
        }
    }
}

Wichtig ist, was nicht im Prozessor passiert: kein einzelner HTTP-Aufruf pro Position. Der ErpPriceProvider holt Preise gebündelt, hält sie pro Kunde und Artikel kurz im Cache und hat ein striktes Timeout.

Caching, Timeout und Fallback

Ein ERP ist nicht immer erreichbar. Planen Sie das Verhalten vorher:

  • Cache pro Kunde und Artikel mit kurzer Lebensdauer (Minuten), damit der Warenkorb nicht bei jedem Request das ERP fragt.
  • Timeout im niedrigen Sekundenbereich, danach Fallback.
  • Fallback-Strategie je Datenart: Bei Preisen eventuell letzten bekannten Preis mit Hinweis, bei Zahlung auf Rechnung lieber Vorkasse anbieten, statt die Bestellung zu blockieren.
  • Circuit Breaker: Fällt das ERP aus, werden Aufrufe kurz ausgesetzt, statt jeden Request warten zu lassen.

Live-Bestand ohne Dauerlast

Den Bestand bei jedem Seitenaufruf live abzufragen, überlastet beide Systeme. Bewährt hat sich eine Kombination:

  1. Ereignisbasierte Aktualisierung: Das ERP meldet Bestandsänderungen, Shopware aktualisiert product.stock per Admin-API oder Queue.
  2. Prüfung im Checkout: Erst im Warenkorb und beim Abschluss wird der Bestand kritischer Artikel live geprüft.
  3. Klare Meldung: Ist ein Artikel nicht lieferbar, sieht der Kunde das vor dem Bezahlen, nicht danach.

Bestellung asynchron ans ERP übergeben

Der Export einer Bestellung darf den Abschluss nicht blockieren. Hören Sie auf das Bestellereignis und legen Sie die Arbeit in eine Warteschlange (Symfony Messenger):

final class OrderPlacedSubscriber implements EventSubscriberInterface
{
    public function __construct(private MessageBusInterface $bus) {}

    public static function getSubscribedEvents(): array
    {
        return [CheckoutOrderPlacedEvent::class => 'onOrderPlaced'];
    }

    public function onOrderPlaced(CheckoutOrderPlacedEvent $event): void
    {
        $this->bus->dispatch(new ExportOrderToErp($event->getOrderId()));
    }
}

Der Handler überträgt die Bestellung und sollte folgende Eigenschaften haben:

  • Idempotent: Eine doppelte Zustellung darf keine doppelte ERP-Bestellung erzeugen (zum Beispiel über die Shopware-Bestellnummer als Schlüssel).
  • Wiederholung mit Backoff: Bei Fehlern erneut versuchen, nicht sofort aufgeben.
  • Dead-Letter und Alarm: Endgültig fehlgeschlagene Exporte müssen sichtbar werden, bevor ein Kunde nachfragt.

Konditionen und Zahlarten

Zahlungsziele, Kreditlimits und Rechnungskauf sollten per Regel geprüft werden. In Shopware helfen der Rule Builder für Versand- und Zahlarten sowie Kundengruppen. Das Kreditlimit selbst liegt meist im ERP und wird beim Checkout abgefragt, mit demselben Fallback-Denken wie oben.

Wiederbestellung und mehrere Benutzer

Merklisten, Wiederbestellung aus früheren Bestellungen und mehrere Benutzer pro Firma mit Rollen sparen Einkäufern Zeit. Für Freigabeprozesse (Besteller, Genehmiger) gibt es Erweiterungen. Prüfen Sie früh, ob Ihr Anwendungsfall im Standard oder in einer Erweiterung abgedeckt ist, bevor Sie selbst bauen.

Was Sie im B2B-Checkout selbst prüfen können, zeigt Punkt 9 der Shopware-Checkout-Checkliste.

Fazit

Die beste Oberfläche hilft wenig, wenn Preise und Bestände nicht stimmen. Entscheidend sind klare Datenhoheit, Caching mit Fallback und asynchrone Übergaben. Ich plane und baue die ERP- und SAP-Anbindung für Shopware und Symfony. Planen Sie einen B2B-Shop oder eine Anbindung? Besprechen wir Ihr Projekt.