tietge / silverstripe-shipping
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
Requires
- php: ^8.3
- lekoala/silverstripe-cms-actions: ^2
- psr/http-client: ^1
- psr/log: ^2 || ^3
- silvershop/core: ^6
- silverstripe/framework: ^6
Requires (Dev)
- phpunit/phpunit: ^11.3
Suggests
- guzzlehttp/guzzle: PSR-18-Client mit Zeitlimits für die Aufrufe der Paketdienste (die Provider-Module setzen ihn voraus).
- symbiote/silverstripe-queuedjobs: Versandbenachrichtigung als Queued Job statt im Klick des Backends.
- tietge/silverstripe-shipping-dhl: Anbindung an DHL Paket (Parcel DE Shipping API v2).
- tietge/silverstripe-shipping-dpd: Anbindung an DPD Deutschland (SOAP-Webdienste).
- tietge/silverstripe-shipping-ups: Anbindung an UPS (Shipping API, REST/OAuth2).
Provides
None
Conflicts
None
Replaces
None
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
Shipment | Eine Sendung: ein Etikett, ein Paket, eine Sendungsnummer. Eine Bestellung hat mehrere |
| Reiter „Versand" an der Bestellung | Erzeugte Sendungen, Vorgaben fürs nächste Etikett, Lieferanschrift |
| Knopf „Versandetikett erzeugen" | In der Aktionsleiste des Bestellformulars |
| Etikett-Ablage | Geschützt in assets/.protected, Download nur mit CMS-Rechten |
| Statuswechsel | Order.Status → Sent beim Etikettendruck (nur aus Paid/Processing, nie bei Testetiketten) |
| Versandmail | Deutsche Vorlage mit Sendungsnummer und Verfolgungslink, vom Modul verschickt — auch an Gäste |
| Schutz vor doppelten Etiketten | Sperre je Bestellung, Abgleich beim Klick, ungeklärte Aufträge bleiben als „Pending" sichtbar |
| Reiter „Versand" in der SiteConfig | Absender, Vorgaben, je Provider ein Unterreiter |
Testseite versandtest/<dienst> | „Verbindung testen" je Dienstleister |
| Storno je Sendung | Nur 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_StatusEmailfest und bieten keinen Hook. Der frühere Weg überupdateStatusChangeEmailfeuerte 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>_envaus 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.