Search by

tietge / silverstripe-shipping

moritz-sauer-13

Versandabwicklung für SilverShop: Sendungen, Etiketten, Statuswechsel und Sendungsverfolgung im Kundenkonto — mit austauschbaren Paketdiensten.

Package info

git.innomedia.de/Tietge/silverstripe-shipping

Type:silverstripe-vendormodule

pkg:composer/tietge/silverstripe-shipping

Statistics

Installs: 1

Dependents: 3

Suggesters: 0

1.1.2 2026-10-14 11:08 UTC

This package is auto-updated.

Last update: 2026-10-14 12:03:40 UTC


README

Versandabwicklung für SilverShop mit austauschbaren Paketdiensten. Aus einer bezahlten Bestellung heraus per Knopfdruck im Backend ein Versandetikett erzeugen, das PDF geschützt ablegen, den Kunden benachrichtigen und ihm Sendungsnummer samt Verfolgungslink im Konto zeigen.

Dieses Modul kennt keinen einzigen Paketdienst. Welcher das Etikett liefert, entscheidet ein Provider-Modul, das sich selbst anmeldet — DHL, DPD und UPS gibt es fertig, weitere sind eine Klasse.

Installation

composer require tietge/silverstripe-shipping
vendor/bin/sake db:build --flush

Nicht auf Packagist — im Projekt als VCS-Repository eintragen:

"repositories": [
    {"type": "vcs", "url": "git@github.com:Tietge/silverstripe-shipping.git"}
]

Für die Versandmail als Hintergrundauftrag symbiote/silverstripe-queuedjobs (bei innomedia/shopextensions ohnehin vorhanden); ohne das Modul geht die Mail direkt hinaus.

Abhängigkeiten: silverstripe/framework ^6, silvershop/core ^6, lekoala/silverstripe-cms-actions ^2. Nicht innomedia/shopextensions — das Modul setzt es weder voraus noch fasst es an.

Allein ist es funktionsfähig, aber nutzlos: Ohne Provider steht kein Dienstleister zur Wahl. Mindestens eines der Provider-Module installieren.

Ohne Paketdienst ausprobieren

Für die Einrichtung liegt ein Testdienst bei, der ein lesbares PDF im Etikettenformat erzeugt, ohne irgendwo anzurufen. Damit lässt sich die ganze Kette prüfen — Knopf, Vorgaben, Ablage, geschützter Download, Statuswechsel, Kundenmail —, bevor ein einziger echter Zugang existiert. Er meldet sich nicht von selbst an:

Tietge\Shipping\Carrier\CarrierRegistry:
  carriers:
    - Tietge\Shipping\Carrier\DemoCarrier

Er verhält sich absichtlich unbequem: fehlt der Empfängeradresse die Hausnummer, druckt er trotzdem und gibt eine Beanstandung zurück — genau wie DHL bei einer nicht leitcodierbaren Adresse. Vor dem Produktivgang wieder austragen.

Was es mitbringt

ShipmentEine Sendung: ein Etikett, ein Paket, eine Sendungsnummer. Eine Bestellung hat mehrere
Reiter „Versand" an der BestellungErzeugte Sendungen, Vorgaben fürs nächste Etikett, Lieferanschrift
Knopf „Versandetikett erzeugen"In der Aktionsleiste des Bestellformulars
Etikett-AblageGeschützt in assets/.protected, Download nur mit CMS-Rechten
StatuswechselOrder.Status → Sent beim Etikettendruck (nur aus Paid/Processing, nie bei Testetiketten)
VersandmailDeutsche Vorlage mit Sendungsnummer und Verfolgungslink, vom Modul verschickt — auch an Gäste
Schutz vor doppelten EtikettenSperre je Bestellung, Abgleich beim Klick, ungeklärte Aufträge bleiben als „Pending" sichtbar
Reiter „Versand" in der SiteConfigAbsender, Vorgaben, je Provider ein Unterreiter
Testseite versandtest/<dienst>„Verbindung testen" je Dienstleister
Storno je SendungNur dort, wo der Dienst es kann

Projektseitig zu erledigen

1. Versandmail — nichts zu tun. Das Modul verschickt sie selbst, sobald ein Etikett die Bestellung auf „Versendet" setzt (ShipmentService.customer_notification: shipment). Steht Sent in Order.log_status, unterdrückt es SilverShops allgemeine Statusmail für genau diesen Wechsel, damit der Kunde nicht zwei Mails bekommt; den Protokolleintrag schreibt es selbst.

Warum nicht über SilverShops Statusmail: silvershop/core 6 und innomedia/shopextensions 6 setzen die Vorlage SilverShop/Model/Order_StatusEmail fest und bieten keinen Hook. Der frühere Weg über updateStatusChangeEmail feuerte dort nie — bis 1.0.0 kam beim Kunden die Statusmail ohne Sendungsnummer an.

Wer lieber SilverShops Statusmail nutzt: customer_notification: status und die Vorlage SilverShop/Model/Order_StatusEmail.ss im Theme um $Order.VisibleShipments ergänzen.

2. Sendungen im Konto anzeigen. Das Modul liefert die Daten, das Template gehört ins Projekt:

<% if $HasVisibleShipments %>
    <% loop $VisibleShipments %>
        $CarrierTitle — <a href="$TrackingUrl">$TrackingNumber</a>
    <% end_loop %>
<% end_if %>

VisibleShipments filtert stornierte, ungeklärte (Pending) und Testetiketten heraus. In der Kaufbestätigung ist die Liste immer leer — Etiketten entstehen erst später im Backend.

3. Mail-Vorlage anpassen (optional). Tietge/Shipping/Order_StatusEmail.ss im Theme ablegen. Betreff: Sprachschlüssel Tietge\Shipping\Email.Subject. Die Sendungen stehen in der Vorlage als $Shipments (sichtbare Sendungen plus die auslösende, auch ein Testetikett) — nicht $Order.VisibleShipments nehmen, sonst fehlt beim Probelauf mit Testetiketten der Link.

4. Queued Jobs. Bei installiertem queuedjobs muss der Job-Runner laufen (Cron), sonst bleibt die Versandmail in der Warteschlange.

Die Schnittstelle

Ein Provider implementiert Tietge\Shipping\Carrier\CarrierInterface und meldet sich an:

Tietge\Shipping\Carrier\CarrierRegistry:
  carriers:
    - Meine\Firma\MeinCarrier

Das ist der ganze Plug-&-Play-Mechanismus. Ob der Dienst im Backend erscheint, entscheidet danach allein isConfigured().

Die Value Objects sind reine PHP-Objekte ohne ORM-Bezug:

  • LabelRequest — Absender, Empfänger, Produktcode, Gewicht, optional Maße. Einheiten sind festgelegt: Gramm und Millimeter, beide als Integer. Das ist keine Formalie — DPD rechnet in 10-Gramm-Einheiten, UPS in Kilogramm und Zentimetern. Ein gemeinsames Feld ohne festgeschriebene Einheit wäre ein Fehler mit unmittelbarer Kostenwirkung. Jeder Adapter rechnet genau einmal um, an einer Stelle.
  • LabelResult — Sendungsnummer, Etiketteninhalt, Storno-Kennung, Warnungen, Dateiendung.
  • CarrierException — trägt einen Übersetzungsschlüssel samt Platzhaltern, keinen fertigen Satz. Übersetzt wird an der Ausgabestelle.

Zwei Entwurfsentscheidungen, die beim Bauen teuer erkauft wurden

Ein LabelResult bedeutet: ein Etikett liegt vor. Die Trennlinie ist ausdrücklich nicht der HTTP-Status. DHL liefert 200 mit gültigem Etikett und Beanstandungen („Hausnummer nicht gefunden"); DPD legt Fehler in eine formal erfolgreiche Antwort und kann dort sogar eine Paketscheinnummer ohne PDF zurückgeben — die Nummer ist dann verbraucht. Entsteht kein Etikett, wirft der Adapter. Gibt es trotz Etikett etwas zu sagen, gehört das in getWarnings().

Die Dateiendung kommt aus dem Ergebnis, nicht aus einer Annahme. Das Modul nannte einmal jede Datei .pdf. Beim ersten Nicht-PDF lag eine unlesbare Datei unter einem PDF-Namen in der Ablage — leere Druckseite, kein Fehler, keine Meldung. getFileExtension() verhindert das; Shipment::isPrintableLabel() entscheidet, ob der Druckdialog aufgeht oder ein Download angeboten wird.

Konfiguration

Tietge\Shipping\Service\ShipmentService:
  label_folder: 'Versandetiketten'   # Ordner unter assets/
  sent_status: 'Sent'                # Zielstatus beim Etikettendruck
  label_allowed_statuses: [Paid, Processing, Sent]   # nur dafür gibt es den Knopf
  sent_from_statuses: [Paid, Processing]             # nur daraus wird auf Sent gesetzt
  customer_notification: 'shipment'  # shipment | status | none
  update_order_for_test_labels: false  # Testetiketten: Bestellung/Kunde unberührt
  default_products:                  # Produkt je Dienst und Zielland, wenn keins gewählt
    dhl: { DE: V01PAK, '*': V53WPAK }
  log_carrier_details: false         # Rohantworten (mit Anschriften) ins Log
  lock_timeout: 0
  recipient_street_fields:           # getrennte Adressfelder des Projekts, falls vorhanden;
    street: StreetName               # beide gefüllt → unverändert aufs Etikett,
    house_number: HouseNumber        # sonst wird die Zeile `Address` geteilt

Tietge\Shipping\Service\ShipmentNotifier:
  email_template: 'Tietge/Shipping/Order_StatusEmail'
  use_queued_jobs: true
  email_from: ''                     # leer: SiteConfig.AdminEmail, ShopConfig.email_from, Email.admin_email

Tietge\Shipping\Http\HttpClientFactory:
  connect_timeout: 5
  timeout: 30

Tietge\Shipping\Model\Shipment:
  printable_label_extensions: ['pdf', 'gif', 'png', 'jpg', 'jpeg']

Doppelte Etiketten, ungeklärte Aufträge

Jedes Etikett kostet. Der Etikettendruck sperrt die Bestellung (Datenbanksperre), vergleicht beim Klick die Zahl der Sendungen mit der beim Laden der Seite und legt die Sendung vor dem Aufruf als „Pending" an. Lehnt der Dienst klar ab, verschwindet sie wieder. Endet der Aufruf ohne klares Ergebnis (Zeitlimit, Abbruch, Serverfehler), bleibt sie als „Ungeklärt" stehen und sperrt weitere Etiketten, bis jemand im Portal des Dienstleisters nachgesehen und sie im Reiter „Versand" verworfen hat.

Eigene Sperren: Hook updateLabelBlocker

Ob eine Bestellung ein Etikett bekommen darf, entscheidet ShipmentService::getLabelBlocker() — für den Knopf im CMS (fehlt er, steht die Begründung im Reiter „Versand") und mit derselben Regel serverseitig in createShipment(). Projekte ergänzen eigene Fälle per Extension: $blocker kommt als null (erlaubt) oder mit der Begründung der Statusprüfung an. Eine übersetzte Zeichenkette sperrt, null gibt frei. Beispiel — Abholbestellungen sperren:

use SilverShop\Model\Order;
use SilverStripe\Core\Extension;

class PickupLabelBlocker extends Extension
{
    protected function updateLabelBlocker(?string &$blocker, Order $order): void
    {
        if ($blocker === null && $order->IsPickup) {
            $blocker = _t(__CLASS__ . '.Pickup', 'Abholbestellung — kein Versandetikett nötig.');
        }
    }
}
Tietge\Shipping\Service\ShipmentService:
  extensions:
    - App\Shipping\PickupLabelBlocker

Ein ungeklärter Auftrag („Pending") sperrt unabhängig davon und lässt sich per Hook nicht aufheben — er ist der Schutz vor doppelten Etiketten.

Testmodus

Dienste in der Sandbox (und die Attrappe) erzeugen Etiketten mit IsTest. Die Bestellung bleibt dann unverändert, der Kunde bekommt keine Mail, und der Reiter „Versand" zeigt einen Hinweis — im Livebetrieb rot.

Für Provider-Module

  • TestModeCarrierInterface, DestinationAwareCarrierInterface — optional.
  • EnvironmentConfigTrait — Zugangsdaten als '`SS_VAR`' oder <name>_env aus der Umgebung.
  • CarrierException::markOutcomeUnknown() — für Fehlschläge beim Erzeugen, nach denen offen ist, ob ein Etikett entstanden ist.
  • HttpClientFactory::singleton()->create() — PSR-18-Client mit Zeitlimits.

Absenderanschrift, Verpackungszuschlag und Etikettenformat stehen im CMS, nicht in YAML — sie ändern sich, ohne dass jemand an Dateien sollte.

Caching

Die Konto-Templates sind bewusst ohne <% cached %> gebaut („die Bestellung gehört einem einzelnen Kunden"). Es sind daher keine cares-Einträge in cache.yml nötig.

Tests

composer install
vendor/bin/phpunit                      # alles
vendor/bin/phpunit --testsuite unit     # ohne Kernel, Sekundenbruchteile

Zwei Suiten mit verschiedenen Voraussetzungen. unit prüft die Value Objects und die reine Rechenlogik als gewöhnliche PHPUnit-Tests — sie brauchen weder Kernel noch Datenbank und laufen auch in einem Modul-Checkout ohne Projekt drumherum. Schwerpunkt sind die Stellen, an denen ein Fehler Geld kostet und trotzdem nicht auffällt: die Zerlegung der Straßenzeile, die Dateiendung des Etiketts, die Alles-oder-nichts-Regel der Maße und das Gewicht einer Bestellposition.

integration läuft über SapphireTest und braucht Config und Injector — dort steht das Verhalten des CarrierRegistry.

Was es bewusst nicht tut

Retourenetiketten, Packstation/Postfiliale, Auslandsversand mit Zollinhaltserklärung, Versandart-Auswahl an der Kasse, Live-Sendungsverfolgung. Das Datenmodell ist auf die Verfolgung vorbereitet (TrackingStatusText, TrackingPolledOn, DeliveredOn), der Abruf fehlt.