Özgür Işık Damar
12 Min Lesezeit

Eine 45-Zustands-FSM für ein Lager — mit zwei Sprachen und einem Zollamt

Feldnotizen aus der Modellierung eines echten grenzüberschreitenden Paketlebenszyklus — Lager in Skopje, mazedonische Lageristen, türkische Schichtleiter, eine Zoll-API, die einen hasst, und warum 45 Zustände gesünder sind als ein 12-Zustands-Modell mit elf Boolean-Flags.

cross-borderwarehousefsmgo

Die Wandtafel in unserem Lager in Skopje hatte 47 Klebezettel, als ich im Oktober 2023 zum ersten Mal dort war. Die Zettel waren die Zustände eines Pakets: "angenommen", "gewogen", "fotografiert", "deklariert", "vom Zoll abgelehnt", "an Verkäufer zurückgesandt" und so weiter. Der Operations-Lead hatte sie von Hand gezeichnet. Jemand hatte Kaffee über drei davon verschüttet. Ich machte ein Foto und flog nach Istanbul zurück, um es zu modellieren.

Zwei der Zettel stellten sich als derselbe Zustand heraus, nur leicht unterschiedlich auf Mazedonisch geschrieben. Einer war ein Zustand, den niemand mehr tatsächlich benutzte. Damit blieben fünfundvierzig übrig.

Das hier ist die Geschichte dessen, was ich aus jenem Foto gebaut habe, und warum ich inzwischen glaube, dass eine 45-Zustands-FSM weniger verrückt ist als ein 12-Zustands-Modell, an dem elf Boolean-Flags hängen.

Was Stork eigentlich tut

Stork ist ein grenzüberschreitender Marktplatz. Ein Käufer in der Türkei bestellt etwas bei einem Verkäufer in Nordmazedonien, oder umgekehrt. Die Ware geht physisch durch unser Lager in Skopje. Jedes Paket läuft durch Annahme, Wiegen, Fotografieren, Zollanmeldung, Zollabfertigung (oder Ablehnung), Umpacken, Versand — und das ist der glückliche Pfad.

Die unglücklichen Pfade sind die interessanten. Retouren. Bei Ankunft beschädigt. Vom Zoll zur Nachprüfung markiert. Verkäufer hat das SLA verfehlt, also leiten wir eine Teilrückerstattung ein. Grenzüberschreitende Retouren sind kein Sonderfall; sie machen vielleicht ein Fünftel des Gesamtvolumens aus.

Die Lageristen in Skopje sprechen Mazedonisch. Ihre Schichtleiter sprechen Mazedonisch und Türkisch. Ich spreche Türkisch und Englisch. Das Zollamt spricht Mazedonisch, schickt viele Faxe und ruft gelegentlich direkt unseren Country Manager an, wenn etwas nicht stimmt.

Ich brauchte ein Modell, das keinem von ihnen lügen würde.

Warum FSM, nicht Status-Flags

Bevor ich es umgeschrieben habe, hatte die Lager-App eine status-Spalte mit zwölf Werten und sieben Boolean-Flags: is_weighed, is_photographed, is_declared, customs_cleared, awaiting_repack, is_returned, is_partial. Sie ahnen, wohin das führt.

In den ersten zwei Monaten loggte ich sieben illegale Übergänge in Produktion:

  • Pakete, die customs_cleared = true markiert waren, während is_declared = false. (Ein Schichtleiter hatte die Zeile manuell editiert, um eine festsitzende Sendung zu entsperren.)
  • Pakete mit gleichzeitig is_returned = true UND awaiting_repack = true, was sinnlos ist.
  • Ein Paket, das nach vorherigem true zu customs_cleared = false wurde — jemand auf der Zollseite hatte dieselbe Anmeldung zweimal eingereicht, und unser Webhook drehte das Bit zurück.

Keiner davon war im engen Sinn ein Bug. Der Code tat genau, was verlangt war. Das Modell hatte einfach keine Meinung darüber, welche Kombinationen erlaubt waren.

Eine FSM hat eine Meinung. Ein Paket im Zustand weighed_pending_photo kann nur nach photographed_pending_declaration, damaged oder removed_from_intake übergehen. Das war's. Der Anwendungsserver lehnt alles andere mit einem 409 ab und einer Log-Zeile, die niemand stummschalten darf.

// Die FSM-Definition liegt in einer Datei. Jeder Zustand ist eine Konstante.
// Jeder Übergang ist ein Tupel (from, to, requiredRole, requiredMetadata).
// Wir erlauben nirgendwo sonst im Code, Package.State direkt zu mutieren.
 
type State string
 
const (
    StateReceived              State = "received"
    StateWeighedPendingPhoto   State = "weighed_pending_photo"
    StatePhotographedPending   State = "photographed_pending_weight"
    StateReadyForDeclaration   State = "ready_for_declaration"
    StateDeclaredPending       State = "declared_pending_customs"
    StateCustomsCleared        State = "customs_cleared"
    StateCustomsRejected       State = "customs_rejected"
    // ...40 weitere
)
 
type Transition struct {
    From             State
    To               State
    RequiredRole     Role
    RequiredFields   []string // Metadaten, die vor dem Übergang vorhanden sein müssen
}

Der Grund, warum es fünfundvierzig Zustände sind und nicht fünfzehn, ist, dass das Lager tatsächlich so viele unterscheidbare Situationen hat, und wenn man aufhört, sie zu vermischen, hören die mysteriösen Bugs auf.

Die Zustände, die wie Duplikate aussehen, aber keine sind

Zwei der frühen Reviewer des Modells stellten dieselbe Frage: Warum gibt es sowohl weighed_pending_photo als auch photographed_pending_weight? Sind das nicht einfach zwei Wege zum selben Ziel?

Sind sie. Aber Lageristen arbeiten nicht in einer festen Reihenfolge. Manche wiegen zuerst, weil die Waage näher am Annahmetor ist. Manche fotografieren zuerst, weil sie warten, bis die Waage frei wird. Beide Wege münden in ready_for_declaration. Wenn man die beiden Zwischenzustände zu einem einzigen namens weighed_or_photographed zusammenführt, verliert man die Fähigkeit, an einem Dienstagmorgen zu sagen, dass dreißig Pakete gewogen, aber nicht fotografiert sind und an der Kamerastation eine Schlange steht.

Das klingt nach einer UI-Sorge, nicht nach einer State-Machine-Sorge. Ist es nicht. Der Schichtwechselbericht am Ende des Tages liest aus der FSM. Die Schichtleiterin läuft mit diesem Bericht durch die Halle. Wenn der Bericht gewogen-aber-nicht-fotografiert nicht von fotografiert-aber-nicht-gewogen unterscheiden kann, kann sie es auch nicht, und die Schlange an der Kamerastation bleibt unsichtbar, bis jemand laut wird.

Ein weiteres Paar, das wie ein Duplikat aussieht: customs_rejected_appealable und customs_rejected_final. Das Zollamt in Skopje gibt einem ein vierzehntägiges Einspruchsfenster für bestimmte Ablehnungen. Nach vierzehn Tagen ist die Ablehnung endgültig und das Paket geht an den Verkäufer zurück. Diese beiden Zustände haben völlig unterschiedliche Folge-Übergänge und völlig unterschiedliche Schichtleiter-Berechtigungen. Sie sind nicht derselbe Zustand mit einem Timer.

Die zweisprachige UI ist eine Zustands-Eigenschaft, keine Übersetzungsdatei

Jeder Zustand hat zwei Labels — Mazedonisch und Türkisch — und sie wohnen neben der Zustandsdefinition, nicht in einem separaten i18n-Bundle.

var stateLabels = map[State]struct {
    MK string
    TR string
}{
    StateWeighedPendingPhoto: {
        MK: "Измерен, чека фотографија",
        TR: "Tartıldı, fotoğraf bekliyor",
    },
    StateDeclaredPending: {
        MK: "Декларирано, чека царина",
        TR: "Beyan edildi, gümrük bekliyor",
    },
    // Warum diese Labels hier und nicht in i18n.json wohnen:
    // wenn ein neuer Zustand hinzugefügt wird, kann der Entwickler
    // nicht vergessen, ihm beide Labels zu geben. Der Compiler weigert
    // sich, ohne sie zu starten.
}

Die Lageristen benutzen an jeder Station ein Tablet. Das Tablet zeigt den aktuellen Zustand in großer Schrift auf Mazedonisch und darunter die nächsten erlaubten Übergänge — ebenfalls auf Mazedonisch, als Knöpfe. Die Backoffice-Ansicht der Schichtleiterin zeigt beide Labels übereinander. Sie muss während eines Telefonats mit Istanbul nichts live übersetzen.

In der ersten Woche nach dem Rollout sagte eine erfahrene Lageristin in Skopje — sie war schon vor Stork dort — meinem Country Manager, die App spreche endlich "ihre Sprache". Eine kleine Sache. Sie stoppte aber auch etwa zwanzig Support-Tickets pro Woche.

Das Zollamt ist eine externe State Machine, die einem nicht gehört

Die mazedonische Zoll-API ist Stoff für einen anderen Beitrag. Die Kurzversion: Wir POSTen eine Anmeldung, bekommen eine Tracking-ID zurück und warten dann auf einen Webhook. Der Webhook kann unser Paket von declared_pending_customs in einen von drei Zuständen umwandeln: customs_cleared, customs_rejected_appealable, customs_rejected_final.

func (h *CustomsWebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
    var payload customsWebhookPayload
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        h.log.Warn("Zoll-Webhook nicht parsebar", "err", err)
        // Wir bestätigen trotzdem. Sonst retried der Zoll ewig.
        w.WriteHeader(http.StatusOK)
        return
    }
 
    pkg, err := h.repo.GetByDeclarationID(r.Context(), payload.DeclarationID)
    if err != nil {
        // Unbekannte Declaration-ID — loggen und ack. Manuelle Queue greift es auf.
        h.log.Warn("Zoll-Webhook für unbekannte Anmeldung", "id", payload.DeclarationID)
        w.WriteHeader(http.StatusOK)
        return
    }
 
    target := mapCustomsOutcomeToState(payload.Outcome)
    // Die FSM ist der Türsteher. Wenn der Zoll versucht, uns in einen
    // Zustand zu schieben, den wir aus unserem aktuellen Zustand legal
    // nicht erreichen können, protokollieren wir den Versuch und
    // zeigen ihn im Schichtleiter-Dashboard zur manuellen Abstimmung.
    if err := h.fsm.Transition(r.Context(), pkg, target, RoleSystem); err != nil {
        h.log.Error("illegaler Zoll-Übergang", "pkg", pkg.ID, "from", pkg.State, "to", target)
        h.queue.PushManualReview(pkg.ID, "Zoll-Webhook versuchte illegalen Übergang")
    }
    w.WriteHeader(http.StatusOK)
}

Der interessante Fall ist, wenn der Zoll uns eine Freigabe für ein Paket schickt, das wir nie angemeldet haben — weil, Sie ahnen es, wir vergessen hatten, es nach der Fax-Bestätigung als angemeldet zu markieren. Vor der FSM hätte dieser Webhook ein weighed_pending_photo-Paket stillschweigend direkt auf customs_cleared umgestellt, und ein paar Tage später hätte jemand es im Lager ohne Foto und ohne Papiere gefunden. Nach der FSM wird der Webhook abgelehnt, die Schichtleiterin sieht ein Queue-Item und der Mensch jagt den fehlenden Annahme-Schritt nach.

Wie vier Monate Produktion aussahen

Ich habe die FSM im Februar 2024 ausgerollt. Bis Juni:

  • Null illegale Übergänge in Produktionslogs. Nicht "wenig", nicht "selten" — null. Der Übergangsvalidator machte es strukturell unmöglich.
  • Zwölf neue Zustände seit dem Launch hinzugefügt. Jeder war eine echte Lagersituation, die wir nicht vorhergesehen hatten, kein Refactoring eines bestehenden Zustands. Das Modell konnte sie aufnehmen, ohne irgendetwas zu brechen, weil jeder neue Zustand explizit definierte ein- und ausgehende Übergänge braucht.
  • 31 % Rückgang bei Operator-Support-Tickets, gemessen am rollierenden Dreimonats-Baseline vor dem Launch. Das meiste Verschwundene waren Tickets der Form "die App sagt X, aber das Paket ist eigentlich Y" — das bedeutete früher ein hängendes Boolean-Flag und bedeutet jetzt nichts, weil App und Paket nicht uneins sein können.

Die Schichtleiterin sagte mir später, die FSM habe die Lageristen zum ersten Mal der App vertrauen lassen. "Sie kann jetzt nicht mehr lügen", sagte sie. Den Satz habe ich in die README geschrieben.

Was ich jemandem sagen würde, der gerade anfängt

Komprimieren Sie Ihre Zustände nicht, damit sie elegant aussehen. Das Lager ist nicht elegant. Das Zollamt ist nicht elegant. Lageristen arbeiten nicht in einer festen Reihenfolge, und Sie können sie nicht dazu zwingen. Wenn Sie 45 unterscheidbare Situationen haben, modellieren Sie 45 Zustände.

Legen Sie die Labels neben den Zustand. Erzwingen Sie über den Compiler, dass jeder Zustand jedes Label hat. Übersetzungsdateien sind die Orte, an denen Zustände Inkonsistenzen ansetzen.

Machen Sie die FSM zum einzigen Ding, das Zustand mutieren darf. Nicht "per Konvention". Per Architektur. Jeder andere Pfad ist ein Klebezettel, der nur darauf wartet, in einer Sprache geschrieben zu werden, die Sie nicht sprechen.

// wenn du schon hier bist