Olay Sırası — sequence & sequenceScope ✓ Canlı

Teslim sıralı değildir: retry, kuyruk ve elle yeniden teslim yüzünden eski bir snapshot yeniden gelebilir. Zarftaki sequence (satışın kaçıncı durum değişikliği) ve sequenceScope (opak satış hattı kimliği) bayat teslimi tanımanı sağlar. İkisi de opsiyoneldir — gelmezlerse bugünkü davranışın sürer.

← Event Kataloğu · Hesap Yaşam Döngüsü

Kırıcı değişiklik yok. İki alan da zarfa eklendi; hiçbir alan kaldırılmadı, hiçbir tip değişmedi. Alanları okumayan mevcut eklentiler etkilenmez.

Nerede bulunur — üç yol

Aynı çift artık üç gövdede geliyor ve üçünde de aynı sözleşmedir:

YolKonumDurum
Webhook zarfıZarf kökü — data ile aynı seviyede✓ Canlı
table.close · packet.close hook'uHook gövdesi kökü⏳ Yakında
capability:fiscal.dePayload kökü⏳ Yakında
Gate gövdesinde
// kapanış hook'u
{
  "type": "hook", "event": "table.close", "stage": "before",
  "target": { "type": "table", "id": "masa-12" },
  "sequence": 14,
  "sequenceScope": "sq_5776130612ab",
  …
}
Hook ile zarfın numarası neden farklı olabilir? Kapanış hook'u kapanıştan önce çalışır; kapanışın kendisi bir mutasyondur ve sayacı ilerletir. Dolayısıyla aynı kapanış için zarftaki numara hook'takinden büyük olabilir. İkisi aynı scope içindedir ve ikisi de "hangi nesil" sorusunu yanıtlar.
Kapsam sınırı — fiscal.de: bu capability şu an yalnız sale.type: "packet" kabul eder. "table" bilinçli olarak desteklenmeyen tipler listesindedir ve ayrı bir hata koduyla reddedilir → masa satışları için capability yolu, dolayısıyla oradan gelen sıra numarası da, bugün mevcut değildir. (Masa mali bloğu kapanış gate'i üzerinden receiptExtras ile taşınır.)

İki garanti

GarantiAnlamı
sequenceScope yeniden kullanılmazScope yalnız kalıcı bir yazımda üretilir ve değeri rastgeledir. Aynı değerin ikinci kez doğması pratikte imkânsızdır.
sequence scope içinde monotondurSayaç yalnız ileri gider, asla geriye sarmaz. Atlanan numara olabilir (yazım yapmayan olaylar bellekte bir sonraki numarayı kullanır); tekrar eden ya da azalan numara olmaz.
Karşılaştırma her zaman AYNI scope içinde yapılır. Farklı scope'lar arasında sıra ilişkisi tanımlı değildir. Hat yeniden başlarsa (sayacı olmayan eski bir satışın ilk mutasyonu, ya da birleştirmede hedefin kendi hattı) yeni bir scope üretilir ve sayaç 1'den başlar — bu iki garantiyi bozmaz, ama scope'u yok sayan bir karşılaştırma yanlış sonuç verir.
İki alan da opsiyoneldir. Satışın sayacı çözülemezse (sayacı hiç olmayan eski kayıt, bozuk değer) alanlar hiç konmaz — null da gelmez. Uydurma bir sıra numarası yayınlamaktansa "bilinmiyor" demek doğrudur; alan yoksa o olay için sıralama karşılaştırmasını atla (sequence: 0 varsayma).

Neden occurredAt yetmiyor

occurredAt iki teslimi ayırt etmeye yetmez: sunucular arası saat kayması ve aynı milisaniyede yapılan iki yazım mümkündür. Bir hesabın durum değişiklikleri ise satış düzeyinde numaralanır — bayat bir snapshot'ı bu numaradan tanırsın.

Zarf — sequence + sequenceScope (data'nın DIŞINDA)
{
  "id": "evt_9f3a…",
  "type": "table.updated",
  "occurredAt": 1768818213617,
  "sequence": 7,                      // bu satışın kaçıncı durum değişikliği
  "sequenceScope": "sq_9f3ab27c…",    // sequence'in geçerli olduğu SATIŞ HATTI
  "data": { "tableId": "masa-5", "docNo": 12, "…": "…" }
}
AlanTipZorunluAçıklama
sequencenumber?–Satışın kaçıncı durum değişikliği olduğu. 1'den başlar ve satış düzeyindedir.
sequenceScopestring?–sq_ önekli opak satış hattı kimliği — numaranın hangi defterde geçerli olduğunu söyler.
İkisi birlikte anlamlıdır: scope olmadan numara "hangi satışın kaçıncısı?" sorusunu cevapsız bırakır. ⚠️ Alan yoksa sequence: 0 varsayma — yokluk "sıra bilinmiyor" demektir, "en baştaki olay" değil.

Kullanım algoritması — beş kural, hepsi zorunlu

  1. Defterini (tenantId, sequenceScope) ile anahtarla. Her scope için ayrı bir "son işlenen sequence" tut. Tek slotlu bir uygulama (yalnız son gördüğüm scope'u hatırla) yanlıştır: yeni satış başladıktan sonra gelen geç bir eski-satış olayı "scope farklı → karşılaştırma yok" diye kabul edilir ve bayat durumu yeninin üstüne yazar.
  2. Aynı scope içinde sequence, o scope için en son işlediğinden küçükse yok say.
  3. Eşit sequence sıra bilgisi taşımaz. Tekrar teslimi zarf id'si ile dedup et. Farklı içerikli eşit-numaralı iki olay, aralarında güvenilir bir sıra olmadığı anlamına gelir — son gelen kazanır.
  4. Terminal olaylar eşit numarada bile terminaldir. *.closed, *.cancelled, *.deleted, *.closed_deleted satışı sonlandırır. Bunlardan birini işledikten sonra aynı scope'ta gelen bir *.updated'ı — numarası eşit ya da büyük olsa bile — satışı yeniden açmak için kullanma. Yalnız *.reopened satışı geri açar.
  5. Defterini terminal olaydan hemen sonra silme. Scope kaydını en az yeniden-teslim penceresi kadar tut (öneri: 7 gün). Silersen, geç gelen bir ilk teslim (tekrar değil → id dedup'u çalışmaz) "bu scope defterde yok → kabul" yolundan geçip satışı diriltir.
Tüketici tarafı — SDK ile (/webhook içinde)
import {
  isSaleSequenceEvent, sequenceKey, sequenceVerdict, advanceSequenceCursor,
} from '@restomenum/plugin-sdk';

if (isSaleSequenceEvent(envelope.type)) {
  const key = sequenceKey(envelope.tenantId, envelope);   // (tenantId, sequenceScope) — TEK SLOT YASAK
  const cursor = key ? await ledger.get(key) : null;      // { sequence, terminal? } | null

  switch (sequenceVerdict(envelope, cursor)) {
    case 'stale':    return ok();     // daha küçük numara → bayat, YOK SAY (yine de 2xx dön)
    case 'terminal': return ok();     // satış sonlanmış; *.updated onu DİRİLTMEZ
    default: break;                   // 'apply' | 'unsequenced' → işle
  }

  await handle(envelope);
  if (key) await ledger.set(key, advanceSequenceCursor(cursor, envelope));  // ≥ 7 gün sakla
}
SDK kullanmıyorsan — aynı karar
// SDK kullanmıyorsan birebir aynı karar (kural 2–4):
const key = `${envelope.tenantId}:${envelope.sequenceScope}`;   // scope YOKSA karşılaştırma YAPMA
const cursor = await ledger.get(key);                            // { sequence, terminal }

if (envelope.sequence != null && envelope.sequenceScope && cursor) {
  if (envelope.sequence < cursor.sequence) return;               // 2) bayat
  const TERMINAL = /\.(closed|cancelled|deleted|closed_deleted)$/;
  const REOPEN   = /\.reopened$/;
  if (cursor.terminal && !TERMINAL.test(envelope.type) && !REOPEN.test(envelope.type)) return;  // 4)
}
// 3) eşit numara → sıra bilgisi yok: son gelen kazanır (tekrar teslimi zarf id'si ile dedup et)
Defter satırı (kendi deponda)
// Defter satırı — anahtar (tenantId, sequenceScope), TTL ≥ 7 gün
{
  "tenantId": "tnt_123",
  "sequenceScope": "sq_9f3ab27c",
  "sequence": 12,        // bu hat için İŞLENMİŞ en büyük numara
  "terminal": true,      // *.closed / *.cancelled / *.deleted / *.closed_deleted işlendi
  "updatedAt": 1768820000000
}
SDK kullanıyorsan @restomenum/plugin-sdk ≥ 3.0.0 şart. Daha eski sürümlerde parseEnvelope zarfı sabit bir alan listesinden yeniden kuruyordu: sunucu sequence / sequenceScope (ve actor / origin) gönderse bile alanlar tüketiciye ulaşmıyordu. 3.0.0'dan itibaren eşleyici tanımadığı üst-seviye alanları olduğu gibi taşır → platform zarfa yeni alan eklediğinde yeni bir SDK sürümü beklemen gerekmez. Yükseltemiyorsan alanları ham gövdeden oku (gövde imzalı olduğu için güvenlidir).
Atlanan teslimde de 2xx dön. Bayat/terminal eleme bir hata değildir; 4xx/5xx dönmek retry'a ve teslim sağlığının düşmesine yol açar.

Numara atlaması normaldir — ama kaybı DIŞLAMAZ

sequence bir satış düzeyi sayacıdır, "kaç olay aldım" sayacı değildir. Sayacı ilerleten ama olay yayınlamayan onlarca nokta vardır: teslim/hazır damgası, ödeme terminali yazımı, kampanya uygulaması, entegrasyon ödeme uçları, kapanış öncesi tutar onarımı. Ardışık iki olay arasında numara birden fazla artmış olabilir — tek başına boşluk, olay kaybettiğinin KANITI değildir. Tek kural: aynı scope içinde daha küçük numara = bayat.
⚠️ Ama boşluk kaybı DIŞLAMAZ da — ve sıra boşluğu kayıp olayın en ucuz dedektörüdür. "Atlama normaldir" cümlesi "atlama masumdur" demek değildir: bir olayın sana ulaşmaması platform tarafında da sessiz olabilir. Sahada ölçülen bir vakada (3 Eylül 2026) bir mali eklenti dokuz gün boyunca 19 geri açmanın 19'unu aldı, ertesi gün 5'inin 5'ini hiç almadı; ne hata, ne uyarı, ne teslim log'u kaydı vardı. Fark eden tek şey kendi defterlerindeki tutarsızlıktı — ve gün sonu raporu kapandığı için düzeltilemedi.

Mali / muhasebe eklentileri için en iyi pratik: defterinde bir scope'un son gördüğün numarasını zaten tutuyorsun (advanceSequenceCursor). Beklenmedik büyüklükte bir boşluk gördüğünde bunu bir hata sayma ama bir uzlaştırma sinyali say: o satışın güncel hâlini packets/get / tables/get ile çek ve kendi kaydınla karşılaştır. Okuma ucu her zaman gerçeği söyler; webhook yalnız bir bildirimdir. Bu, "sessiz kayıp" sınıfındaki her arızayı — platform kaynaklı olanlar dahil — gün sonu kapanmadan yakalamanı sağlar.
⚠️ Ama boşluğu tek başına ALARMA bağlama — gürültülü bir sinyaldir. Sayaç her satış-dokümanı yazımında ilerliyor, yalnız olay yayınında değil; yani meşru boşluk sık oluşur. Boşluk başına alarm kuran bir eklentide çekişlerin çoğu boşa gider, uyarı gürültüye dönüşür ve gerçek kayıp da onunla birlikte görmezden gelinir. Boşluğu bir tarama tetikleyicisi olarak kullan, kanıt olarak değil.
✅ Kesin (pozitif) dedektör: aynı satış hattında İKİNCİ bir *.closed. Bir satış hattı normalde bir kez kapanır. Aynı sequenceScope için ikinci bir table.closed/packet.closed aldıysan ve arada bir geri alma olayı işlemediysen, o geri alma sana ULAŞMAMIŞTIR — bu belirsiz bir sinyal değil,kesin bir çıkarımdır. Boşluk taramasının aksine yanlış alarm üretmez.

Uygulaması defterinde zaten var olan veriyle mümkün: kapanışı işlerken o hattı kapandı diye işaretle; aynı hattan ikinci kapanış gelirse önce geri alma gördün mü diye bak — görmediysen kayıp kaydı aç ve satışı okuma ucundan çekip uzlaştır. Sahada beş kayıp geri almanın beşini de ayırt eden sinyal buydu; boşluk taraması değil.

💡 Ayrı bir hat durumu tutmana bile gerek olmayabilir. Kapanışta satırlarını "şu belgeyle kapatıldı" diye işaretleyip geri almada bu işareti temizleyen bir tüketicide çıkarım kendiliğinden kurulur: ikinci kapanış geldiğinde satırlar hâlâ ayakta bir belgeye bağlı görünüyorsa, geri alma işlenmemiştir. Mali iz için zaten tuttuğun alan böylece çift iş görür.

Hat (scope) ne zaman değişir

DurumsequenceScopesequence
Masa / paket açılışıyeni hat1
Kalem, ödeme, indirim, kuver değişikliğiaynı hat+1
Masa bölme — yeni masayeni hat1
Masa bölme — kaynak masaaynı hat+1
Boş masaya taşıma — kaynak *.deletedkaynağınki korunurn+1
Boş masaya taşıma — hedef *.updatedkaynağınki korunurn+2
Dolu masaya taşıma (birleştirme) — hedefhedefin kendi hattı+1
Kapanış *.closedaynı hat (terminal)+1
Geri açma *.reopenedaynı hat sürer+1
Devir *.deleted · iptal packet.cancelled · *.closed_deletedaynı hat (terminal)+1
Aynı masada yeni satışyeni hat1
Paket birleştirme / başka işletmeye taşımahedefte yeni hat1

Kapanışta hesabın kimliği değişir (table.updated'daki tableId ile table.closed'daki farklıdır), geri açmada da yeni bir kimlik doğar (hesap yaşam döngüsü). Fazlar arasında kararlı kalan tek bağ sequenceScope'tur.

Taşıma bu kuralın en ince yeri. Tek işlem iki olay üretir: kaynak *.deleted, hedef *.updated. İkisi aynı numarayı alsaydı, geç teslim edilen silme olayı hedefteki açık satışı defterinden silerdi. Bu yüzden boş masaya taşımada hat korunur ama hedef iki adım ilerler (n+1 / n+2). Kısmi taşımada iki hesap da yaşar → her biri kendi hattını ilerletir. Sayacı hiç olmayan eski satışlarda silme olayı numara yayınlamaz (uydurma hat kimliği üretilmez) — alan yoksa o olay için karşılaştırmayı atla.
Kapanış artık kesin olarak sonra sıralanıyor. table.closed ve packet.closed son güncellemeden kesin olarak büyük numara taşır. Yine de kural 4 ikinci savunma hattın olarak kalmalı: "eşit numarada son gelen kazanır"ı harfiyen uygulayan mali bir tüketici, kapanıştan sonra geç gelen bayat bir güncellemeyle kapanmış satışı geri açıp kestiği fişi storno edebilirdi.

Alanların gelmediği durumlar

  • Bu değişiklikten önce açılmış, hâlâ açık duran satışlar — ilk mutasyonlarında hat kazanırlar.
  • Entegrasyon kanallarının (Getir / Yemeksepeti / Trendyol / Migros / Fuudy / WebStore) açtığı paketlerin packet.created'ı.
  • Başka işletmeye taşınmış paketin hedefteki packet.created'ı — hat bilerek sökülür ki kaynak işletmenin satışıyla karışmasın.

Alan yoksa karşılaştırma yapılamaz; bugünkü davranışını sürdür (occurredAt karşılaştırması + id dedup'u).

sequence / sequenceScope teslim edilen gövdelerin alanlarıdır (aşağıdaki üç yol); tables/get · packets/get gibi okuma uçlarının yanıtında yer almaz.

En iyi pratikler

  • Sıra kontrolü dedup'ın yerine geçmez. Önce imzayı ham gövde üzerinden doğrula (geçersizse 401), sonra zarf id'si ile dedup et, sonra sıra kararını ver.
  • Terminal bayrağını defterde tut. Kural 4 için "bu satış sonlandı mı" bilgisi gerekir; numara karşılaştırması tek başına yetmez.
  • Defteri işlemden SONRA ilerlet. Handler hata verirse kayıt ilerlemesin ki yeniden teslim işlensin.
  • Echo koruması ayrı bir iştir: kendi yazdığın değişikliğin olayı da sana gelir → isOwnEcho ile ele.