Events
Stockly veröffentlicht, was im Lager geschieht, damit eine Integration seine Tabellen nicht abfragen oder,
schlimmer, in product.stock schreiben muss, das dem Modul gehört und von ihm überschrieben wird.
Der Katalog unten ist nach Ebene gruppiert. bin/console p2lab-stockly:events druckt den lebenden Katalog samt
Listener-Zahlen, und das ist zugleich der schnellste Weg, „ist mein Listener tatsächlich angeschlossen” zu
beantworten.
Wie zu abonnieren ist
Abschnitt betitelt „Wie zu abonnieren ist“Abonnieren Sie über den Namen des Events, lesen Sie die Nutzdaten über ententypisierte Getter. Nirgends ein
use einer Stockly-Klasse, keine Abhängigkeit in composer.json; das Plugin funktioniert dann ohne Stockly,
mit Stockly und über Versionen hinweg, die eine Klasse verschoben haben.
public static function getSubscribedEvents(): array{ return ['p2lab_stockly.backorder.opened' => 'onBackorderOpened'];}
public function onBackorderOpened(object $event): void{ if (!method_exists($event, 'getPayload')) { return; }
$data = $event->getPayload(); // $data['orderId'], $data['lineItemId'], $data['shortfallQuantity'], …}Jedes Event trägt denselben Umschlag, eine allgemeine Brücke (Webhook, Warteschlange, Prüfprotokoll) braucht also kein Wissen über irgendeine konkrete Klasse und arbeitet auch für später hinzugefügte Events weiter:
public function forward(object $event): void{ if (!method_exists($event, 'getPayload') || !method_exists($event, 'getName')) { return; }
$this->http->post($this->url, ['json' => [ 'event' => $event->getName(), 'id' => $event->getEventId(), // Schlüssel zur Entduplizierung 'at' => $event->getOccurredAt()->format(DATE_ATOM), 'data' => $event->getPayload(), ]]);}Der Umschlag
Abschnitt betitelt „Der Umschlag“| Getter | Bedeutung |
|---|---|
getName() | stabiler öffentlicher Name, z. B. p2lab_stockly.demand.declared |
getEventId() | eindeutige 32 Zeichen Hex — damit entduplizieren |
getSchemaVersion() | nur erhöht, wenn ein Nutzdatenschlüssel entfernt wird oder seine Bedeutung ändert |
getOccurredAt() | ein DateTimeImmutable |
getCorrelationId() | bindet eine Arbeitseinheit zusammen (Bestell-Id, Einkaufsbestell-Id) |
getActor() | ['type' => user|storefront|cli|system, 'id' => ?string, 'name' => ?string] |
getPayload() | die ganze Tatsache, als JSON-serialisierbare Skalare und Arrays |
getContext() | der Shopware-Context |
Nutzdaten sind Skalare, Nulls und Arrays davon, nie Entitäten, Structs oder DateTime-Objekte; Daten sind
ISO-8601-Zeichenketten. Jede Id ist 32 Zeichen Hex in Kleinbuchstaben ohne Bindestriche, bereit für UNHEX().
Zwei Schreibweisen, und sie sind nicht austauschbar
Abschnitt betitelt „Zwei Schreibweisen, und sie sind nicht austauschbar“| Schreibweise | Art | Sie |
|---|---|---|
p2lab.stockly.… — ein Punkt nach dem Anbieter | ein Hook | beantworten ihn |
p2lab_stockly.… — ein Unterstrich | eine Tatsache | beobachten sie |
Die Namen mit Punkt gehören zu den Versand- und Druck-Hooks: p2lab.stockly.parcels.plan,
p2lab.stockly.label.print und ihren Geschwistern, beschrieben unter
Stockly anbinden.
Bedarf — was eine Bestellung will und was ihn deckt
Abschnitt betitelt „Bedarf — was eine Bestellung will und was ihn deckt“| Event | Wann | Wichtige Nutzdaten |
|---|---|---|
p2lab_stockly.demand.declared | der Bedarf einer Position wird erstmals erfasst | quantity, allocatedQuantity, shortfallQuantity, warehouseIds[], productId, orderedProductId |
p2lab_stockly.demand.changed | Bedarf oder Deckung einer bestehenden Position hat sich bewegt | das Obige plus previousQuantity, previousAllocated, previousShortfall |
p2lab_stockly.demand.deducted | Ware hat das Regal für die Bestellung körperlich verlassen | totalQuantity, lines[] mit warehouseId / binLocationId / batchId / handlingUnitId |
p2lab_stockly.demand.released | die Bestellung fordert nicht mehr | releasedQuantity, reasonCode (cancel / delete / edit), returnMode |
p2lab_stockly.sourcing.decided | eine Position wurde gegen einen Standort gebucht, auf einen anderen verschoben oder ließ sich gar nicht buchen | chosenWarehouseId, previousWarehouseId, rule, ruleSource, degraded, candidateCount, candidates[], triggerSource, requirement, settingsFingerprint |
quantity = allocatedQuantity + shortfallQuantity gilt auf declared und changed immer.
sourcing.decided greift nur, wenn das Ergebnis eine Meldung wert ist: Der Standort hat gewechselt, die
Entscheidung wurde herabgestuft, oder der Bedarf ließ sich nicht buchen. Eine wiederholte gleiche Entscheidung
erhöht stattdessen einen Zähler auf der Zuordnungszeile, ein Listener sieht also die Geschichte der Leitung statt
des Rauschens von Bestelländerungen.
productId ist das Produkt, das den Bestand trägt; orderedProductId ist das auf der Bestellposition. Sie
unterscheiden sich, wenn ein zusammenarbeitendes Plugin Bestand umleitet, etwa ein Bundle, das aus seinen
Bestandteilen zieht.
Rückstand — was geschuldet wird
Abschnitt betitelt „Rückstand — was geschuldet wird“| Event | Wann | Wichtige Nutzdaten |
|---|---|---|
p2lab_stockly.backorder.opened | was einer Position geschuldet wird, ist gestiegen | shortfallQuantity (die neue Summe), previousShortfall (0 = eine echte Eröffnung), totalOutstanding |
p2lab_stockly.backorder.covered | Einheiten wurden einer wartenden Position vorgemerkt | coveredQuantity, remainingShortfall, triggerReason (receipt / transfer / release / correction) |
p2lab_stockly.backorder.closed | die Position schuldet nichts mehr | closedQuantity, closeReason (fulfilled / cancelled) |
covered greift auch bei teilweiser Deckung; remainingShortfall ist die Zahl, auf die es ankommt. Auf eine
vollständige Deckung folgt closed.
Eine Position, deren Zuordnungszeile rundweg gelöscht wird, erzeugt überhaupt kein closed, weil die Zeile, auf
der die Fehlmenge lebte, mit ihr ging. Beobachten Sie für diesen Fall demand.released.
Ware — was hereinkam
Abschnitt betitelt „Ware — was hereinkam“| Event | Wann | Wichtige Nutzdaten |
|---|---|---|
p2lab_stockly.goods.received | ein Wareneingang zu einer Bestellung wurde gebucht | purchaseOrderId, supplierId, warehouseId, totalQuantity, items[] mit Menge, Lagerplatz, Charge, Ablaufdatum, Ladungsträger und Einstandskosten |
Ein Event je Wareneingang, nicht je Position, denn ein Wareneingang ist eine Entscheidung. Die Aufteilung steckt in
items[].
Bestand — was sich bewegt hat und was es mit der Verfügbarkeit tat
Abschnitt betitelt „Bestand — was sich bewegt hat und was es mit der Verfügbarkeit tat“| Event | Wann | Wichtige Nutzdaten |
|---|---|---|
p2lab_stockly.stock.moved | Ware traf ein, ging hinaus oder bewegte sich | movementType, quantity, delta, quantityAfter, from / to {warehouseId, binLocationId, lpCode}, batchId, expiresAt, orderId, purchaseOrderId, operationId |
p2lab_stockly.stock.availability_changed | was verkauft werden kann, hat sich geändert | previousAtp, currentAtp, shelfTotal, shortfallTotal, isNegative, previousAvailable, currentAvailable |
Die Richtung liegt in from / to: Ein Zugang hat from = null und ein positives delta, ein Abgang hat
to = null und ein negatives, eine Bewegung hat beides und delta = 0, weil sich die Lagersumme nicht geändert hat.
Wareneingang, Einlagerung, Zählen und Palettieren sind alle stock.moved mit einem anderen movementType.
Eine Tatsache, ein Event: Filtern Sie nach dem Typ, statt ein eigenes Event je Vorgang zu suchen.
availability_changed ist mit Absicht getrennt. Eine Reservierung bewegt die Verfügbarkeit, ohne dass sich Ware
bewegt, und ein Umräumen zwischen Lagerplätzen bewegt Ware, ohne die Verfügbarkeit anzutasten. Es wird je Produkt
und Arbeitseinheit entdupliziert, currentAtp ist also immer die gebuchte Zahl und nie eine Zwischengröße.
Sie darf negativ sein und wird unbegrenzt veröffentlicht: drei mehr verkauft, als vorhanden sind, ist eine
Antwort, keine Beschädigung.
Arbeit — was die Menschen im Gebäude getan haben
Abschnitt betitelt „Arbeit — was die Menschen im Gebäude getan haben“| Event | Wann | Wichtige Nutzdaten |
|---|---|---|
p2lab_stockly.work.pick_wave_planned | Arbeit wurde einem Kommissionierer übergeben | die Welle und ihre Positionen |
p2lab_stockly.work.short_pick_reported | ein Kommissionierer fand ein Fach leer oder zu leer | expectedQuantity, foundQuantity, missingQuantity, reasonCode, stockCorrected, varianceHeld, reallocatedQuantity |
p2lab_stockly.work.short_pick_withdrawn | diese Meldung wurde zurückgenommen — die Ware war doch da | expectedQuantity, missingQuantity, restoredQuantity, earmarkRestored |
p2lab_stockly.work.pick_line_completed | Stücke kamen in den Karton | quantity, quantityPicked, quantityRequired, isComplete, pickLineId |
p2lab_stockly.work.stocktake_committed | eine Bestandskontrollsitzung wurde gebucht | lineCount, varianceLineCount, totalVarianceUnits, lines[], linesTruncated |
p2lab_stockly.work.quality_decided | eine Charge in Quarantäne wurde freigegeben oder abgelehnt | decision, quantity, batchNumber, failAction, reason |
p2lab_stockly.work.transfer_state_changed | eine Umlagerung wurde versandt, kam an, wurde abgeschlossen oder storniert | fromState, toState, sourceWarehouseId, targetWarehouseId, lines[] |
short_pick_reported greift, ob das Abweichungstor das Fach korrigiert hat oder nicht; die Sichtung ist so oder
so eine Veröffentlichung wert. short_pick_withdrawn ist ein eigenes Event statt einer Meldung mit umgedrehten
Zahlen: Wer auf die Fehlmenge hin gehandelt hat, muss erfahren, dass sie zurückgenommen wurde, und
restoredQuantity / earmarkRestored sagen, was die Ausgleichsbuchung tatsächlich gebucht hat.
stocktake_committed greift auch, wenn nichts abwich, weil eine saubere Zählung die Zahl ist, an der
Genauigkeit gemessen wird. Es begrenzt lines[] auf 500, größte Abweichung zuerst, und sagt das in linesTruncated.
pick_line_completed greift nur für angenommene Scans; Doppelte und Konflikte veröffentlichen nichts, denn sie
haben niemandes Arbeit gutgeschrieben.
Versand — was das Gebäude verlassen hat
Abschnitt betitelt „Versand — was das Gebäude verlassen hat“| Event | Wann | Wichtige Nutzdaten |
|---|---|---|
p2lab_stockly.shipping.shipment_booked | ein Paket hat eine Sendungsnummer bekommen | orderNumber, parcelId, sequence, trackingCode, carrierProfile, weightKg, parcelCount, remainingParcels |
p2lab_stockly.shipping.order_shipped | jedes Paket einer Bestellung hat eine Sendungsnummer | orderNumber, parcelCount, trackingCodes[], trackingCodeList |
p2lab_stockly.shipping.shipment_failed | der Dienstleister hat abgelehnt und die Wiederholungen sind erschöpft | orderNumber, parcelId, sequence, carrierProfile, attempts, error |
p2lab_stockly.shipping.batch_handed_over | ein Fahrer hat einen Satz Pakete genommen und dafür unterschrieben | code, carrierProfile, warehouseId, shipDate, parcelCount, orderCount, manifestExternalId, closedByName |
remainingParcels ist das, was eine Teilsendung ausdrückbar macht: Die Lieferzustandsmaschine von Shopware hat
„teilweise versendet”, und ohne den Zähler könnte ein Flow immer nur „versendet” sagen, was unwahr ist, bis das
letzte Paket gebucht ist.
order_shipped darf für dieselbe Bestellung mehr als einmal greifen, und das ist so gewollt. Ein Paket zu
einer bereits versandten Bestellung hinzuzufügen und es zu buchen erfüllt die Bedingung erneut, und das ist eine
zweite Sendung und kein Fehler. Ein darauf reagierender Flow sollte den Statuswechsel erzwingen, sonst scheitert
der zweite Lauf an einer Bestellung, die bereits im Zielstatus ist.
batch_handed_over trägt mit Absicht keine einzelne Bestellung, weil ein Stapel so viele Bestellungen umspannt,
wie auf den Wagen passten. Automatisierung je Bestellung gehört an die anderen beiden.
Retouren — was zurückkam
Abschnitt betitelt „Retouren — was zurückkam“| Event | Wann | Wichtige Nutzdaten |
|---|---|---|
p2lab_stockly.returns.announced | jemand hat eine Retoure angelegt; noch hat sich nichts bewegt | returnNumber, orderId, type (withdrawal / complaint / cancellation), source (storefront / admin / system), contactEmail, requestedResolution, lineCount, totalQuantity, reasonCodes[] |
p2lab_stockly.returns.received | die Ware traf ein und wurde in den Retouren-Haltebereich gebucht | returnNumber, warehouseId, binLocationId, receivedQuantity, announcedQuantity, outstandingQuantity, complete, lines[] |
p2lab_stockly.returns.dispositioned | jemand hat entschieden, was mit den Einheiten geschieht | returnNumber, lineId, productId, disposition (restock / scrap / hold), quantity, reasonCode, liability, remainingOnHold, documentComplete |
p2lab_stockly.returns.refunded | Geld ging an den Kunden zurück | returnNumber, orderId, amount (diese Zahlung; negativ bei einer Rückbuchung), refundedTotal, refundState, source (manual / payment), comment |
returns.refunded folgt aus keinem der anderen drei. Eine Anmeldung wird regelmäßig angenommen, entschieden und
geschlossen, während die Überweisung noch aussteht, deshalb hat das Geld eine eigene Statusachse. Es
greift einmal je Zahlung, eine in zwei Raten beglichene Anmeldung sendet es also zweimal: amount ist diese
Rate, refundedTotal ist, wo die Anmeldung danach steht.
source und type stehen auf den Nutzdaten, weil drei verschiedene Wege denselben Beleg erzeugen: der
Assistent im Shop-Frontend, ein Mitarbeiter, der ein Telefonat annimmt, und der Stornierungsweg ganz ohne
Menschen. Ein Flow, der „wir haben Ihre Retourenanmeldung erhalten” verschickt, will den ersten und nicht die
anderen zwei.
announcedQuantity reist mit Absicht neben receivedQuantity: Dass die beiden auseinandergehen, ist der
gewöhnliche Fall und meist das, worauf zu reagieren lohnt.
Integrität
Abschnitt betitelt „Integrität“| Event | Wann |
|---|---|
p2lab_stockly.stock_integrity_critical | eine Prüfung fand mehr offene kritische Befunde als die eingestellte Schwelle |
p2lab_stockly.external_stock_write_detected | jemand hat product.stock von außen geschrieben, und der Wächter hat es erfasst |
external_stock_write_detected trägt productId, expectedValue (was Stockly hielt), foundValue (was der
Schreiber hinterließ), delta, source (dal für eine API- oder DAL-Schreibung, sql für eine unmittelbar in
der Datenbank) und policyApplied (off / correct / restore). Eine Auslösung je Produkt und Arbeitseinheit,
nach dem Commit. Siehe externe Änderungen.
stock_integrity_critical ist für den Flow Builder geschrieben und trägt skalare Werte statt des Umschlags;
jedes andere Event auf dieser Seite beantwortet getPayload().
Über den Klassennamen abonniert
Abschnitt betitelt „Über den Klassennamen abonniert“Zwei Events sind älter als das Namensschema und werden unter ihrem voll qualifizierten Klassennamen ausgelöst. Das wird sich nicht ändern, denn Plugins sind bereits darauf abonniert.
| Klasse | Wann |
|---|---|
P2Lab\Stockly\Event\StockChangedEvent | ein billiges „dieses Produkt hat sich geändert, lies es neu”-Signal |
P2Lab\Stockly\Event\BeforeStockOperationEvent | vor Zugang, Abgang oder Bewegung — der Veto-Hook, beschrieben unter Policy Hooks |
Zusicherungen und was nicht zugesichert ist
Abschnitt betitelt „Zusicherungen und was nicht zugesichert ist“Tatsachen werden nach dem Commit veröffentlicht. Was ein Event beschreibt, ist bereits geschehen und wird nachträglich nicht zurückgerollt.
Ein scheiternder Listener kann das Lager nicht kaputtmachen. Exceptions aus Tatsachen-Listenern werden protokolliert und geschluckt. Symfony hat allerdings keine Isolierung je Listener, ein werfender Listener schneidet die dahinter eingereihten also ab. Policy-Listener sind das Gegenteil: Ihre Exceptions pflanzen sich fort, denn das Ergebnis zu beeinflussen ist der Zweck.
Es gibt keine Zustellgarantie. Events sind Funktionsaufrufe im Prozess. Stirbt der Prozess zwischen dem Commit und der Auslösung, steckt die Änderung in der Datenbank, und das Event hat niemanden erreicht, und es wird später nicht wiederholt, denn der Abgleich ist idempotent, und der nächste Durchgang sieht nichts zu tun. Eine Integration, die nicht auseinanderlaufen darf, braucht einen eigenen regelmäßigen Abgleich, nicht nur die Events.
Es gibt keine Reihenfolgegarantie zwischen Ebenen. In einer Arbeitseinheit dürfen ein Rückstands-Event und
ein Bedarfs-Event in beliebiger Reihenfolge ankommen. getCorrelationId() plus getOccurredAt() erlaubt es,
sie zu gruppieren und zu sortieren.
Ein Listener ist nicht in Stocklys Transaktion. Die Schreibvorgänge eines Listeners zurückzurollen rollt die von Stockly nicht zurück.
Beim Lesen wird nichts veröffentlicht. Berichte und Listen geben keine Events aus.
Flow Builder und Webhooks des App Systems
Abschnitt betitelt „Flow Builder und Webhooks des App Systems“Dreiundzwanzig Events werden als Flow-Builder-Auslöser angeboten, ein Händler kann sie also ohne eine Zeile Code
an eine Mail, einen Webhook oder ein Tag hängen: jedes Event von oben außer den fünf, die je Position, je
Bewegung oder je Scan greifen, nämlich demand.declared, demand.changed, sourcing.decided, stock.moved und
work.pick_line_completed.
Dieselben Events erreichen Webhooks des App Systems. Shopware behandelt jedes Flow-fähige Event als hakbar, eine App kann also abonnieren, und die Zustellung läuft über das eigene Webhook-Protokoll und die Nachrichtenwarteschlange des Kerns, mit Wiederholung und Aufräumen. Für ein externes System ist das der verlässliche Weg, und er braucht nichts von diesem Plugin.
Zwei Regeln bestimmen die Liste, und beide gibt es, um sie ehrlich zu halten.
Ein Lebenszyklus wird ganz angeboten. opened / covered / closed reisen zusammen, und deducted reist
mit released. Ein halber Lebenszyklus im Flow Builder ist schlimmer als keiner: Er lässt einen Händler eine
Automatisierung bauen, die einem Kunden sagt, seine Ware sei unterwegs, nachdem er um eine Erstattung gebeten
hat.
Hochfrequente Events bleiben draußen. Die fünf oben greifen je Bestellposition, je Bewegung und je Scan. Sie
in der Oberfläche anzubieten endet mit einer Automatisierung, die tausendmal am Tag mailt. Wer sie wirklich
braucht, schreibt ein Plugin und abonniert über den Namen, wo es keine solche Grenze gibt.
stock.availability_changed steht trotz seiner geschäftigen Ebene auf der Liste, und die Entduplizierung ist der
Grund: Es greift, wenn die Verfügbarkeit sich tatsächlich bewegt hat, nicht wenn irgendetwas geschah.
Verträglichkeit
Abschnitt betitelt „Verträglichkeit“Ereignisnamen ändern sich nie. Konstruktoren bekommen nur nachgestellte optionale Parameter hinzu.
getPayload() darf ohne Ankündigung Schlüssel hinzubekommen; ein Schlüssel wird nur mit einer Erhöhung von
getSchemaVersion() entfernt oder umdefiniert.
- Policy Hooks — die Events, die beantwortet statt beobachtet werden
- Bestand in einen Lagerplatz legen — was die Bestands-Events veröffentlicht