Skip to content
%70 lansman · 10$/saat · Teklif al

OpenCart’ı ERP’ye bağlamak: gerçek trafiğe dayanan desenler

Kuyruklar, idempotency anahtarları ve mutabakat — demoda güzel görünen entegrasyonla kötü bir cumada hâlâ çalışan entegrasyon arasındaki fark.

BA
Berk Avcı
Principal Backend Engineer · · 9 dk okuma

İki sistem arasında akan sipariş verisi

Demo entegrasyonu ve gerçek olanı

Herkesin ilk ERP entegrasyonu çalışır. Ödeme akışının sonunda çağrılan, siparişi gönderip geri dönen bir fonksiyondur. İncelemeyi geçer, bir hafta sorunsuz döner; sonra ERP bir cuma akşamı habersiz bakıma girer ve birkaç düzine sipariş sessizce hiç ulaşmaz. Hiçbir şey kimsenin fark edeceği kadar yüksek sesle bozulmaz — ta ki pazartesi depo neden boş beklediğini sorana kadar.

Demo entegrasyonuyla üretim entegrasyonu arasındaki bütün fark budur: karşı taraf düştüğünde ne oluyor. Aşağıdaki desenler, OpenCart ile herhangi bir ERP arasına koyduğumuz katman; hangi ürün olduğu neredeyse önemsiz. Baştan yaklaşık bir günlük fazladan iş çıkarır ve bir günlük siparişi e-postalardan yeniden kurmakla geçireceğiniz haftayı kazandırır.

Kuyruk tablosu
oc_erp_outbox
Teslim garantisi
en az bir kez
Tüketici koşulu
idempotent

Onların API’sine değil, kendi tablonuza yazın

ERP’yi asla bir müşteriye hizmet eden isteğin içinden çağırmayın. Kendi veritabanınızdaki bir tabloya satır yazın, teslimatı ayrı bir işçi yapsın. ERP kapalıysa satır bekler. ERP yavaşsa ödeme akışı yavaşlamaz. Gönderdiğiniz veri hatalıysa elinizde hâlâ durur — başarısız bir HTTP isteği için aynısını söyleyemezsiniz.

CREATE TABLE `oc_erp_outbox` (
  `outbox_id`       INT(11)      NOT NULL AUTO_INCREMENT,
  `entity`          VARCHAR(32)  NOT NULL,   -- order, customer, product
  `entity_id`       INT(11)      NOT NULL,
  `event`           VARCHAR(32)  NOT NULL,   -- created, status_changed
  `idempotency_key` CHAR(64)     NOT NULL,
  `payload`         MEDIUMTEXT   NOT NULL,
  `status`          ENUM('pending','sent','failed') NOT NULL DEFAULT 'pending',
  `attempts`        TINYINT(3)   NOT NULL DEFAULT 0,
  `next_attempt`    DATETIME     NOT NULL,
  `last_error`      VARCHAR(255) NOT NULL DEFAULT '',
  `date_added`      DATETIME     NOT NULL,
  PRIMARY KEY (`outbox_id`),
  UNIQUE KEY `idem` (`idempotency_key`),
  KEY `due` (`status`, `next_attempt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Satırı, yamalanmış bir checkout kontrolcüsünden değil bir event handler’ından doldurun ki bir sonraki OpenCart güncellemesi entegrasyonunuzu silemesin. Sonra işçi, iki kopya aynı anda çalışsa bile bozulmayacak şekilde satır kapsın:

START TRANSACTION;

SELECT * FROM `oc_erp_outbox`
 WHERE `status` = 'pending'
   AND `next_attempt` <= NOW()
 ORDER BY `outbox_id`
 LIMIT 20
 FOR UPDATE SKIP LOCKED;   -- MySQL 8.0+ / MariaDB 10.6+
  • Daha eski MySQL sürümlerinde SKIP LOCKED yerine, satırlara işçi kimliği ve zaman damgası basan bir UPDATE kullanın; sonra damgaladığınız satırları yeniden seçin.
  • Varlık başına sıra önemlidir. Aynı siparişin iki durum güncellemesi ters sırayla ulaşmamalı; bu yüzden entity ve entity_id bazında, outbox_id sırasıyla işleyin ve ilk hatada o varlığın kuyruğunu ilerletmeden durdurun.
  • İşçiyi kısa ömürlü tutun. Çalışıp vadesi gelenleri boşaltan ve çıkan bir cron görevini anlamak, kimsenin yeniden başlatmayı hatırlamadığı bir servisi anlamaktan çok daha kolaydır.

Her mesajda bir idempotency anahtarı

Kuyruk size “en az bir kez” teslim verir; yani aynı mesajı bir kez daha göndereceksiniz: ERP’nin aslında aldığı bir zaman aşımı, API çağrısıyla durum güncellemesi arasında düşen bir işlem, bir olaydan sonra toplu yeniden kuyruğa alan bir operatör. Bu yüzden her mesaj, alıcının tekilleştirebileceği bir anahtar taşır — saatten ya da rastgele bir değerden değil, olayın kendisinden türetilmiş bir anahtar.

$key = hash('sha256', implode('|', [
    'order',            // varlik
    (int)$order_id,     // varlik id
    'status_changed',   // olay
    (int)$order_status_id  // bildirilen durum
]));

Anahtar deterministik olduğu için aynı olayı iki kez özetlemek aynı metni üretir ve outbox üzerindeki UNIQUE index kopyayı daha ağa çıkmadan reddeder. ERP de anahtarı tanıyorsa — çoğu Idempotency-Key başlığında kabul eder — iki tarafta da korunmuş olursunuz. Tanımıyorsa, sizin referansınızı kabul edip ikinci bir belge yaratmak yerine mevcut belgeyi döndüren bir uç nokta isteyin.

Geri çekilme ve vazgeçilecek bir yer

Yeniden denenebilir hatalarla kalıcı hataları, deneme mantığını yazmadan önce ayırın. Zaman aşımı, kopan bağlantı veya 503 büyük ihtimalle sonra başarılı olur. Vergi numarasının hatalı olduğunu söyleyen bir 422 ise asla başarılı olmayacaktır; onu her dakika yeniden denemek, log bölümü dolana kadar kimsenin fark etmediği bir meşgul döngüsüdür.

// Basarisiz denemeden sonra
$attempts = (int)$row['attempts'] + 1;
$backoff  = min(3600, (int)pow(2, $attempts) * 30);  // 60s, 120s, 240s ... 1 saatte sinirli
$delay    = $backoff + random_int(0, (int)($backoff / 5));  // jitter
$status   = $attempts >= 8 ? 'failed' : 'pending';

$this->db->query("UPDATE `oc_erp_outbox`
    SET `attempts` = " . $attempts . ",
        `status` = '" . $status . "',
        `next_attempt` = DATE_ADD(NOW(), INTERVAL " . (int)$delay . " SECOND),
        `last_error` = '" . $this->db->escape(mb_substr($error, 0, 255)) . "'
    WHERE `outbox_id` = " . (int)$row['outbox_id']);
Jitter süsleme değil. Olmazsa, ERP kesintisi boyunca başarısız olan ne varsa sistem toparlandığı saniyede aynı anda yeniden dener ve ERP’yi ayağa kalktığı anda tekrar yıkarsınız.

failed durumuna düşen bir satır bir insana ulaşmalı; operasyon adresine giden bir e-posta yeterli. Kimsenin okumadığı bir ölü mektup kuyruğu, kuyruk olmamasıyla aynı şeydir — sadece daha çok yer kaplar.

Stok: sayının sahibi kim

Bütün tasarımı tek bir soru belirler: stok adedinin sahibi kim? Sahibi ERP ise OpenCart o sayıyı ERP’den geleni uygulamak dışında asla yazmamalı ve mağaza yüzünde birkaç dakika eski bir sayıyı kabul etmelisiniz. Sahibi OpenCart ise ERP yalnızca raporlayan taraftır. Asla yürümeyen şey, ikisinin birden sahiplenmesidir; bir ürün aynı saat içinde iki kez böyle tükenir.

OpenCart, oc_product.subtract açık olan her üründe ödeme anında stoğu düşer; seçenekler de kendi adetlerini oc_product_option_value içinde taşır. ERP de bir zamanlamayla mutlak adet gönderiyorsa iki yazan taraf yarışa girer ve kazanan, en son yazandır. Ayakta kalan üç düzen var:

  • Stoğun sahibi ERP, OpenCart düşmeye devam eder. ERP mutlak değerleri gönderir ve bir sonraki eşitlemede onun sayısı kazanır. Biraz eski, basit ve çoğu katalog için yeterince doğru.
  • Stoğun sahibi ERP ve OpenCart’ın düşmesi kapalı; her siparişten sonra ERP’ye bildirim gider. Doğrudur, ama ERP yanıt verene kadar mağaza yüzü açıkta kalır.
  • Siparişte rezervasyon: adedi ERP tutar, OpenCart saklamak yerine uygunluğu okur. En çok iş çıkaran ama az stoklu, yüksek talepli kataloglarda doğru olan yol.
UPDATE `oc_product`
   SET `quantity` = ?, `date_modified` = NOW()
 WHERE `product_id` = ?
   AND `sku` = ?;   -- yalnizca id degil, ERP'nin kendi anahtari da eslessin
Stok beslemesini kendi kaynağına karşı koruyun. Sıfırlarla gelen tek bir bozuk dışa aktarım, bir katalogu tek işlemde boşaltabilir. Katalogun belirlediğiniz orandan fazlasını sıfıra çekecek her gönderimi reddedin ve bir insanın onaylamasını isteyin — on satırlık bu kontrol tam gün ticaret kurtardı.

Geri besleme döngüsü olmadan sipariş durumu

oc_order_history denetim izidir: her durum değişikliği için yorum ve bildirim bayrağı taşıyan bir satır; oc_order.order_status_id de onu izler. Bir siparişi ilerletmenin doğru yolu geçmiş satırı eklemektir; durum sütununu doğrudan yazmak, o zaman çizelgesini bir sonraki okuyana yalan söyler hâle getirir.

Buradaki klasik hata döngüdür. ERP bir durum yazar, OpenCart event tetikler, outbox status_changed mesajı kuyruğa alır, işçi mesajı gönderir, ERP durumu geri yansıtır ve sipariş bir gecede yüz tane aynı geçmiş satırı biriktirir. Çözüm: her yazmanın kaynağını etiketleyin ve zaten ERP’den gelen bir değişikliği kuyruğa hiç almayın.

// ERP'den gelen durumu uygulamak - bilerek kuyruga alinmiyor
$this->load->model('checkout/order');

$this->model_checkout_order->addHistory(   // 3.0.x'te adi addOrderHistory()
    (int)$order_id,
    (int)$status_map[$erp_status],  // oc_order_status'ten cozulur, asla sabit yazilmaz
    'ERP ' . $erp_document_no,      // siparis zaman cizelgesinde gorunur
    false                          // notify: musteriye zaten ERP e-posta gonderdi
);

Açıkça söylenmesi gereken iki ayrıntı var. Durum id’leri sabit değil, mağaza verisidir — yayına alırken oc_order_status’ü okuyup haritayı ondan kurun; çünkü 2 ile 3 arasına “Stok bekliyor” eklemiş bir mağaza kimsenin sabit listesine uymaz. Ve oc_order içinde order_status_id = 0 olan satırlar satış değil, onaylanmamış sepettir; ERP beslemesine asla girmemeliler.

Sessizliği görünür kılan gece raporu

Her kuyruk er geç bir şey kaybeder: iyi niyetli bir temizlik betiğinin sildiği bir satır, ERP’nin kabul edip düşürdüğü bir mesaj, sunucu taşıması sırasında cron’un kapatıldığı bir hafta. Kuyruk, hiç sahip olmadığı mesajları size anlatamaz. Bir mutabakat işi anlatabilir.

Geceleri çalıştırın ve dünü iki tarafta karşılaştırın: sipariş adedi ve ciro toplamı, stoğu sıfırdan farklı ürün sayısı, yeni müşteriler. Farkları sayı olarak değil id olarak raporlayın: “3 sipariş eksik” bir alarmdır; “10412, 10419 ve 10433 numaralı siparişler eksik” ise birinin öğleden önce bitirebileceği bir iştir.

-- Gece karsilastirmasinin OpenCart tarafi
SELECT DATE(`date_added`)      AS day,
       COUNT(*)                AS orders,
       ROUND(SUM(`total`), 2)  AS revenue
FROM `oc_order`
WHERE `order_status_id` > 0
  AND `date_added` >= CURDATE() - INTERVAL 1 DAY
  AND `date_added` <  CURDATE()
GROUP BY day;

Rapor temiz olduğunda da gönderin. Yalnızca bir şey bozulduğunda gelen rapor, gelmeyi bıraktığını kimsenin fark etmediği rapordur.

İki yön, iki saat

OpenCart’tan ERP’ye giden yol sizin outbox’ınız ve işçinizdir. ERP’den OpenCart’a gelen yol ise bir webhook’tur ve webhook, üretici ona ne derse desin herkese açık bir uç noktadır. Dört kural onu güvenli kılar:

  1. 1Ham istek gövdesi üzerinde, paylaşılan bir gizli anahtarla HMAC imzasını doğrulayın ve karşılaştırmayı == ile değil hash_equals() ile yapın.
  2. 2Zaman damgası birkaç dakikadan eski olan her isteği reddedin ve tekrar saldırılarını engelleyecek kadar mesaj kimliği saklayın.
  3. 3Gövdeyi kaydedin, hemen 2xx dönün ve işi sonradan aynı kuyruk üzerinden yapın. İşini istek içinde yapan bir webhook er geç zaman aşımına uğrar, gönderen yeniden dener ve elinizde açıklanacak kopyalar olur.
  4. 4Her ham gövdeyi sınırlı bir süre saklayın. Üretici “biz kesinlikle gönderdik” dediğinde tartışmayı bitiren tek şey o logdur.

Zamanlama tarafında OpenCart 4 kendi cron tablosu ve kontrolcüsüyle geliyor; hafif bakım işleri için yeterli. Ama ERP işçisi için hâlâ küçük bir CLI betiği çalıştıran sistem cron’unu tercih ediyoruz: HTTP zaman aşımı yok, kendi bellek sınırı var ve çalışması için birinin siteyi ziyaret etmesine bağlı değil.

# /etc/cron.d/opencart-erp
*  * * * *  store  /usr/bin/php8.1 /home/store/erp/worker.php    >> /home/store/erp/worker.log 2>&1
15 3 * * *  store  /usr/bin/php8.1 /home/store/erp/reconcile.php >> /home/store/erp/recon.log  2>&1

Ne zaman bize yazın

Mağazanızda “çoğunlukla çalışan” bir ERP bağlantısı zaten varsa, faydalı ilk adım küçüktür: entegrasyonu okur, bu beş desenden hangisinin eksik olduğunu ve her birinin ne kadar süreceğini söyleriz. Lansman ücretiyle saatlik 10 $ + KDV üzerinden çalışırız, iş başlamadan saat sayısını veririz ve ilk mesajları Pazartesi–Cumartesi 09:00–22:00 arasında iki saat içinde yanıtlarız.

BA
Berk Avcı
Principal Backend Engineer

REST, GraphQL ve gRPC backend mimarisi; kod incelemesi ve ADR.