Özgür Işık Damar
12 dk okuma

45 durumlu bir depo FSM'i: iki dil ve bir gümrük dairesiyle

Sınır ötesi bir paketin gerçek yaşam döngüsünü modellemekten saha notları — Üsküp deposu, Makedon operatörler, Türk şefler, sizden nefret eden bir gümrük API'si ve 45 durumun on bir boolean bayraklı 12 durumdan neden daha sağlıklı olduğu.

cross-borderwarehousefsmgo

Ekim 2023'te Üsküp deposuna ilk gittiğimde duvardaki beyaz tahtada 47 yapışkan not vardı. Hepsi bir paketin durumlarıydı: "alındı", "tartıldı", "fotoğraflandı", "beyan edildi", "gümrük reddetti", "satıcıya iade edildi". Operasyon şefi tek tek elle çizmişti. Üçünün üstüne kahve dökülmüştü. Fotoğrafını çektim, İstanbul'a döndüm ve modellemeye oturdum.

İki notun aslında aynı durum olduğu ortaya çıktı, Makedonca'da bir harf farkla yazılmıştı. Bir tanesi de artık kimsenin kullanmadığı ölü bir durumdu. Geriye kırk beş kaldı.

Stork tam olarak ne yapıyor

Stork sınır ötesi bir pazaryeri. Türkiye'deki alıcı Kuzey Makedonya'daki satıcıdan bir şey sipariş ediyor, ya da tersi. Mallar fiziksel olarak Üsküp'teki depomuzdan geçiyor. Her paket kabul, tartım, fotoğraf, gümrük beyanı, gümrük çıkışı, yeniden paketleme ve sevkiyat aşamalarından geçiyor. Mutlu yol bu.

Mutsuz yollar daha ilginç. İadeler, varışta hasarlılar, gümrüğün yeniden inceleme için işaretledikleri, satıcının SLA'yı kaçırdığı için açtığımız kısmi iadeler. Sınır ötesi iadeler köşe vakası değil — toplam hacmin belki beşte biri.

Depodaki operatörler Makedonca konuşuyor. Şefleri Makedonca ve Türkçe konuşuyor. Ben Türkçe ve İngilizce konuşuyorum. Gümrük dairesi Makedonca konuşuyor, çok faks atıyor ve bir şey ters gittiğinde bazen doğrudan Üsküp'teki ülke müdürümüzü arıyor.

Hiçbirine yalan söylemeyen bir modele ihtiyacım vardı.

Neden FSM, neden status flag değil

Eski depo uygulamasında on iki değerli bir status kolonu ve yedi boolean bayrak vardı: is_weighed, is_photographed, is_declared, customs_cleared, awaiting_repack, is_returned, is_partial. Nereye gittiği belli.

İlk iki ayda production'da yedi yasadışı geçiş logladım:

  • is_declared = false iken customs_cleared = true işaretlenmiş paketler. Bir şef takılan bir sevkiyatı açmak için satırı elle düzenlemişti.
  • Hem is_returned = true hem awaiting_repack = true olan paketler. Bu kombinasyon hiçbir şey ifade etmiyor.
  • Daha önce true olan bir paketin customs_cleared = false haline gelmesi. Gümrükten biri aynı beyanı iki kez girmişti, webhook bayrağı geri çevirdi.

Hiçbiri katı anlamda bug değildi. Kod tam istendiği gibi davrandı. Modelin sadece hangi kombinasyonların yasal olduğu konusunda bir görüşü yoktu.

FSM'in görüşü var. weighed_pending_photo durumundaki bir paket yalnızca photographed_pending_declaration, damaged veya removed_from_intake durumlarına geçebilir. Başka hiçbir şey. Uygulama sunucusu gerisini 409 ile reddediyor ve kimsenin susturma yetkisi olmayan bir log satırı düşüyor.

// FSM tanımı tek bir dosyada yaşar. Her durum bir sabittir.
// Her geçiş (from, to, requiredRole, requiredMetadata) tuple'ıdır.
// Başka hiçbir yerde Package.State'in doğrudan mutate edilmesine izin verilmez.
 
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 tane daha
)
 
type Transition struct {
    From             State
    To               State
    RequiredRole     Role
    RequiredFields   []string // geçişten önce var olması gereken metadata
}

Durum sayısının 15 değil de 45 olmasının sebebi basit: depoda gerçekten o kadar ayırt edilebilir durum var. Onları birbirine karıştırmayı bıraktığınızda gizemli bug'lar da bitiyor.

Yinelenmiş gibi görünen ama olmayan durumlar

Modeli ilk gözden geçirenlerden ikisi aynı soruyu sordu: hem weighed_pending_photo hem photographed_pending_weight neden var? Aynı hedefe giden iki yol değil mi?

Öyle. Ama operatörler sabit bir sırayla çalışmıyor. Bazıları önce tartıyor çünkü terazi kabul kapısına yakın. Bazıları önce fotoğraflıyor çünkü terazinin boşalmasını bekliyor. İki yol da ready_for_declaration'da birleşiyor. Bu iki ara durumu tek bir weighed_or_photographed durumuna sıkıştırırsanız, Salı sabahı saat 10:00'da otuz paketin tartıldığını ama fotoğraflanmadığını ve fotoğraf istasyonunda kuyruk olduğunu söyleme yeteneğinizi kaybedersiniz.

UI meselesi gibi duruyor. Değil. Vardiya sonu raporu FSM'den okuyor. Şef o raporla katı dolaşıyor. Rapor tartılmış-ama-fotoğraflanmamışı, fotoğraflanmış-ama-tartılmamıştan ayırt edemiyorsa, şef de ayırt edemiyor. Fotoğraf istasyonunun kuyruğu birisi bağırana kadar görünmez kalıyor.

Yinelenmiş gibi görünen başka bir çift: customs_rejected_appealable ve customs_rejected_final. Üsküp gümrüğü belirli redler için on dört günlük itiraz penceresi veriyor. On dört günden sonra red kesinleşiyor ve paket satıcıya geri gidiyor. Bu iki durumun aşağı akış geçişleri ve şef izinleri tamamen farklı. Zamanlayıcılı aynı durum değiller.

İki dilli UI bir state özelliği, çeviri dosyası değil

Her durumun iki etiketi var — Makedonca ve Türkçe — ve durum tanımının yanında duruyorlar. Ayrı bir i18n bundle'ında değil.

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",
    },
    // Bu etiketlerin neden i18n.json'da değil burada olduğunun nedeni:
    // yeni bir durum eklendiğinde, geliştirici her iki etiketi de
    // vermeyi unutamaz. Derleyici onlarsız başlamayı reddediyor.
}

Operatörler her istasyonda bir tablet kullanıyor. Tablet mevcut durumu büyük puntoda Makedonca gösteriyor, altında izin verilen sonraki geçişleri buton olarak yine Makedonca veriyor. Şefin back office görünümünde iki etiket üst üste duruyor. İstanbul'la telefon görüşmesi sırasında şefin kafadan çeviri yapması gerekmiyor.

Sistemi yayına aldığımız ilk hafta, Stork'tan önce de orada olan kıdemli bir operatör ülke müdürüme uygulamanın sonunda "onun dilini konuştuğunu" söylemiş. Küçük bir şeydi. Aynı zamanda haftada yaklaşık yirmi destek talebini de kesti.

Gümrük dairesi: sahibi olmadığınız harici bir state machine

Makedon gümrük API'si başka bir yazının konusu. Kısacası: bir beyan POST'luyoruz, takip ID'si geri alıyoruz, sonra webhook'u bekliyoruz. Webhook paketimizi declared_pending_customs'tan üç durumdan birine çevirebiliyor: 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("gümrük webhook'u parse edilemedi", "err", err)
        // Yine de ack veriyoruz. Aksi takdirde gümrük sonsuza dek retry yapıyor.
        w.WriteHeader(http.StatusOK)
        return
    }
 
    pkg, err := h.repo.GetByDeclarationID(r.Context(), payload.DeclarationID)
    if err != nil {
        // Bilinmeyen declaration ID — logla ve ack ver. Manuel kuyruk alır.
        h.log.Warn("bilinmeyen beyan için gümrük webhook'u", "id", payload.DeclarationID)
        w.WriteHeader(http.StatusOK)
        return
    }
 
    target := mapCustomsOutcomeToState(payload.Outcome)
    // FSM kapı bekçisi. Gümrük bizi mevcut durumumuzdan
    // yasal olarak ulaşamayacağımız bir duruma çevirmeye çalışırsa,
    // denemeyi kaydediyoruz ve manuel uzlaştırma için şef
    // panosunda gösteriyoruz.
    if err := h.fsm.Transition(r.Context(), pkg, target, RoleSystem); err != nil {
        h.log.Error("yasadışı gümrük geçişi", "pkg", pkg.ID, "from", pkg.State, "to", target)
        h.queue.PushManualReview(pkg.ID, "gümrük webhook'u yasadışı geçiş denedi")
    }
    w.WriteHeader(http.StatusOK)
}

İlginç durum şu: gümrük bize hiç beyan etmediğimiz bir paket için çıkış gönderiyor. Çünkü faks onayı geldiğinde paketi "beyan edildi" olarak işaretlemeyi unutmuşuz. FSM'den önce o webhook bir weighed_pending_photo paketini sessizce doğrudan customs_cleared'a çevirirdi. Birkaç gün sonra biri depoda o paketi fotoğrafsız ve evraksız bulurdu. FSM'den sonra webhook reddediliyor, şef kuyrukta bir öğe görüyor ve eksik kabul adımını insan kovalıyor.

Üretimde dört ay neye benzedi

FSM'i Şubat 2024'te yayına aldım. Haziran 2024'e gelindiğinde:

  • Üretim loglarında sıfır yasadışı geçiş. "Düşük" değil, "nadir" değil — sıfır. Geçiş doğrulayıcı bunu yapısal olarak imkansız kıldı.
  • Lansmandan beri eklenen on iki yeni durum. Her biri öngörmediğimiz gerçek bir depo durumuydu, mevcut bir durumun refactor'u değil. Her yeni durum gelen ve giden geçişlerini açıkça tanımlamak zorunda olduğu için model hiçbir şeyi kırmadan onları barındırdı.
  • Lansmandan önceki üç aylık rolling baseline'a kıyasla operatör destek taleplerinde %31 düşüş. Kaybolanların çoğu "uygulama X diyor ama paket aslında Y" diyen taleplerdi. Eskiden takılı bir boolean bayrak anlamına gelirdi, şimdi hiçbir anlama gelmiyor çünkü uygulamayla paketin aynı fikirde olmaması mümkün değil.

Bir akşam, lansmandan altı ay sonra, şef bana Üsküp'teki bir izah toplantısının ardından mesaj attı. "Operatörler artık uygulamaya güveniyor" dedi. "Yalan söyleyemiyor." O cümleyi README'nin başına yazdım.

Yeni başlayan birine ne derdim

Durumlarınızı şık görünmek için sıkıştırmayın. Depo şık değil. Gümrük dairesi şık değil. Operatörler sabit bir sırayla çalışmıyor ve onları zorlayamıyorsunuz. 45 ayırt edilebilir durumunuz varsa 45 durum modelleyin.

Etiketleri durumun yanına koyun. Derleyiciye her durumun her etikete sahip olduğunu zorlatın. Çeviri dosyaları, durumların tutarsızlık büyüttüğü yerlerdir.

FSM'i durumu değiştirebilecek tek şey yapın. "Konvansiyon olarak" değil — mimari olarak. Diğer her yol, konuşmadığınız bir dilde yazılmayı bekleyen bir yapışkan nottur.

// madem buradasın