Yazdırma (print köprüsü)

Eklentin kupon, çek, etiket ya da kendi raporunu kasa yazıcısından bastırabilir — yazıcıya hiç dokunmadan. Belgeyi sen tarif edersin, basma işini panel yapar.

Eklenti yazıcıya DOKUNMAZ

İlk refleks window.print() olur ve çalışmaz — sebebini bilmek zaman kazandırır. Eklenti arayüzün ayrı bir origin'de, sandbox'lı iframe içinde çalışıyor:

  • allow-modals yok → window.print() çağrılamaz.
  • allow-popups yok → window.open + yazdır da çalışmaz.
  • Native köprüye (window.flutter_inappwebview) erişim yok.

Tek yol: belgeyi tarif et, basmayı panel yapsın. Sektörde aynı desen var — Clover PrintJob, Chrome chrome.printing, Odoo POS proxy: izin-kapılı, yapılandırılmış iş; ham bayt yok, hedefi host seçer.

Çıktı kasa yazıcısına (termal ESC/POS) gider. A4 için ayrı bir kanal YOKTUR — belge modeli 80 mm fiş için tasarlandı (12 kolonluk satır DSL'i). A4 rapor bekliyorsan bu köprü onu vermez.

Çağrı

print action'ı
// Köprü çağrısı — diğer action'larla aynı zarf.
// panelOrigin iframe'e query param olarak verilir; hedefi ONA pinleyin, "*" KULLANMAYIN.
const res = await bridgeCall("print", {
  printer: "Mutfak",                 // OPSİYONEL — yazıcı ADI (ip/port değil). Aşağıdaki nota bakın.
  elements: [
    { type: "text", text: "KUPON", size: "3", align: "center", bold: true },
    { type: "hr" },
    { type: "row", data: [
      { text: "Kod",    width: 6, align: "left"  },
      { text: "X-1234", width: 6, align: "right" },   // oranlar: 6/6
    ]},
    { type: "qr", text: "https://ornek.com/kupon/X-1234" },
    { type: "feed" },
  ],
});
Köprü yardımcısı (SDK'ya print eklenene kadar)
function bridgeCall(action, params) {
  const requestId = crypto.randomUUID();
  const target = readPanelOrigin();          // ?panelOrigin=… (doğrulanmış)
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => { cleanup(); reject(new Error("bridge-timeout")); }, 65000);
    function onMessage(e) {
      if (e.origin !== target) return;                        // origin doğrula
      if (e.data?.type !== "restomenum-bridge-response") return;
      if (e.data.requestId !== requestId) return;
      cleanup(); resolve(e.data.result);
    }
    function cleanup() { clearTimeout(timer); removeEventListener("message", onMessage); }
    addEventListener("message", onMessage);
    parent.postMessage({ type: "restomenum-bridge", requestId, action, params }, target);
  });
}

İzinler

AlanTipZorunluAçıklama
printer:printscope✓Yazdırma için zorunlu. Yoksa printDenied.
printer:drawerscope–Para çekmecesi (drawer elemanı) için ayrı scope. Yoksa iş tamamen reddedilir (printDrawerDenied) — o eleman atlanıp gerisi basılmaz.
Çekmece neden ayrı scope? Kasa açmak yazdırmadan farklı bir karardır: fiziksel ve mali bir işlemdir, geri alınamaz. Sektörde de ayrı tutulur (Clover,chrome.printing). İşletme "belge bastırabilir" ile "kasamı açabilir"i ayrı onaylar.
Fail-closed: onaylı scope listesi alınamazsa (ağ hatası) panel basmaz ve printScopeUnknown döner. "Bilinmiyor" = "izin var" değildir. Bu hatadan sonra körlemesine tekrar gönderme; kullanıcıya durumu göster.

Eleman şeması

İzinli tipler: text, row, hr, feed, qr, barcode, image, beep, cut (+ scope ile drawer). Başkası → printElementNotAllowed. line/divider YOK — çizgi hr'dir.

AlanTipZorunluAçıklama
texttext + stiller–Satır. En fazla 300 karakter.
hrtext?–Ayraç. text çizgi karakteridir, tek karaktere kırpılır.
feed—–Sabit 2 satır boşluk.
rowdata: Column[]–En fazla 12 sütun (fazlası kırpılır). width göreli ağırlıktır, platform 12 birime normalize eder.
qrtext, size, align–size 1–8 (vars. 4). İçerik en fazla 512 karakter. Boş içerik işi reddettirir.
barcodetext, symbology, barWidth, barHeight–symbology vars. code128. İçerik en fazla 64 karakter. Veri sembolojinin hane/karakter kuralına uymazsa yalnız o eleman düşer ve dropped'a sayılır — iş reddedilmez.
imagetext = URL–Yalnız https://, en fazla 500 karakter. Görseli cihaz indirir, 400px'e ölçeklenir.
beep—–Yazıcı sesi (fişin sonunda).
cutmode–"partial" · diğer değer → full.
drawerpin–"5" · diğer değer → "2". printer:drawer ister.

Ortak stil alanları

  • bold / underline — yalnız true geçer.
  • size — "1"–"8". Aralık dışı değer kırpılır (clamp), sayıya çevrilemeyen değer hiç taşınmaz — ikisi de fişi düşürmez, en fazla stil yok sayılır.
  • align — "left" | "right"; verilmezse center.
  • linesAfter — elemandan sonra boş satır; 0–20 arasına kırpılır.
  • codeTable göndermeyin — beyaz listede yoktur, hiçbir zaman yazıcıya ulaşmaz. Kod sayfası yazıcı ayarıdır, eklenti kararı değil.

row — sütun genişlikleri

Column
// row sütunu
interface Column {
  text: string;
  width: number;                  // GÖRELİ ağırlık — platform 12 birime normalize eder
  align?: "left" | "right";       // verilmezse center
  bold?: boolean;                 // yalnız true geçer
  underline?: boolean;            // yalnız true geçer
}

width mutlak bir birim değil, göreli bir ağırlıktır. Platform (printBridge) genişlikleri her koşulda toplamı 12 olan pozitif tamsayılara dağıtır — oransal ölçekleme + en büyük artık yöntemiyle. Yani:

  • Genişlik vermezsen → sütunlar eşit bölüşür.
  • Toplam 12 değilse → oransal olarak yeniden ölçeklenir (3+3 → 6+6).
  • 12'den fazla sütun → ilk 12'ye kırpılır ve uyarı üretilir.
12'yi hedeflemek zorunda değilsin — oranları okunabilirlik için bilinçli seç, gerisini platform halleder. Aşağıdaki örnekte 7/2/3 yazılmasının sebebi kuralı karşılamak değil, sütun genişliklerinin niyetini okunur kılmaktır.
Örnek
{ type: "row", data: [
  { text: "Ürün",  width: 7, align: "left"   },
  { text: "Adet",  width: 2, align: "center" },
  { text: "Tutar", width: 3, align: "right"  },   // oranlar: 7/2/3
]}

Native sözleşmesi ≠ eklenti yolu

Platformun ham yazıcı sözleşmesindeki katı kurallar sana ulaşmadan normalize edilir. Eklenti çağrın her zaman printBridge'den geçiyor ve orada sütun genişlikleri 12'ye dağıtılıyor, size 1–8'e kırpılıyor, align normalize ediliyor, codeTable hiç taşınmıyor.
Senin gerçek duvarların iki tanedir: izinler (scope) ve tavanlar (aşağıda). Başka bir yerde okuduğun "şu alan yanlışsa fiş hiç basılmaz" türü kurallar doğrudan yazıcı köprüsünü çağıran entegrasyonlar içindir, senin için değil.

Paralel çağrı güvenlidir — aynı anda birden çok print işi gönderebilirsin, sıraya alınırlar. Yine de success:false aldığında otomatik tekrar gönderme; sebebini ayırt et (aşağıdaki hata tablosu) ve indeterminate semantiğini gözet.

Hedef yazıcı

  • Yalnız yazıcı ADI verebilirsin; ip/port/kağıt eni POS ayarlarından gelir.
  • printer hiç gönderilmezse varsayılana gider: Electron/web'de host'un varsayılan yazıcısı; Flutter'da isDefault işaretli yazıcı, yoksa tek yazıcı varsa o. Birden fazla yazıcı var ve hiçbiri varsayılan değilse → printerMissing.
  • Tanımsız ad → printerNotFound. Sessizce başka yazıcıya basılmaz.
Önerilen desen: varsayılan olarak printer alanını hiç gönderme. Kendi ayarlarında opsiyonel bir "yazıcı adı" alanı sun; boşsa alanı payload'a ekleme. Böylece tek yazıcılı kurulumlar sıfır konfigürasyonla çalışır.

Yanıt: “bastı” ile “bastığını bilmiyoruz” ayrı şeylerdir

Yanıt biçimleri
{ success: true,  data: { jobId, printed, indeterminate, dropped } }
{ success: true,  data: { duplicate: true, printed: false } }      // KÂĞIT ÇIKMADI
{ success: false, message: "printDenied" | "printerNotFound" | … }
AlanTipZorunluAçıklama
printed: truebaşarı–İş gönderildi, host başarı bildirdi.
indeterminate: truebelirsiz–Sonuç güvenilir değil (eski native / Electron yolu bağlantı sonucunu döndürür, gerçek basımı değil). Kullanıcıya "yazıcıyı kontrol edin" de, otomatik tekrar gönderme — baytların bir kısmı ulaşmış olabilir, çift fiş çıkar. "Tekrar yazdır" seçeneği sun.
duplicate: true⚠️ başarı DEĞİL–Aynı içerik 5 sn içinde tekrar geldi → kâğıt çıkmadı. Yanıt success:true ama semantiği "basılmadı" — kullanıcıya "bastı" deme.
dropped: nsayı–Cihazın desteklemediği için düşen eleman sayısı (v1 native'de barcode/drawer/cut sessizce atlanır).
jobIdstring–Yerel denetim günlüğüne yazılan iş kimliği — destek talebinde bunu ilet.
Basımdan sonra panel kasiyere "{eklenti} bir belge yazdırdı" bildirimi gösterir. Fiziksel çıktı sessiz kalmaz — kullanıcının bunu göreceğini bilerek tasarla.

Hata kodu → ne yapmalı

AlanTipZorunluAçıklama
printDeniedkalıcı–Scope yok. Yazdır düğmesini gizle, tekrar deneme.
printScopeUnknowngeçici–İzin listesi alınamadı. Kullanıcıya bildir, tekrar denenebilir.
printerNotFound · printerMissingyapılandırma–Hedef çözülemedi — kullanıcıyı POS yazıcı ayarlarına yönlendir.
printRateLimitedkota–Geri çekil, döngüye girme.
printTooLarge · printEmpty · printInvalid* · printElementNotAllowed · printDrawerDeniedpayload–Geliştirici hatası — düzeltilmeli, tekrar denemek sonucu değiştirmez.
printFailedcihaz–Cihaz basamadı; data.error ham sebebi taşır. "Tekrar yazdır" seçeneği sun.

Tavanlar

  • 80 eleman · metin 300 karakter · toplam 32 KB
  • Dakikada 12 iş · iki iş arası ≥ 500 ms
  • Aynı içerik 5 sn içinde tekrarlanırsa basılmaz (duplicate)
80 eleman tavanı kırpma değil REDDİR (printTooLarge — iş tamamen düşer). Uzun listeli belgelerde kendi tarafında kırp ve kırpmayı çıktıya yaz ("26 fişin 18'i basıldı"). Sessiz kırpma, özellikle mali belgelerde kabul edilemez.
Ham bayt yasak. Metinlerden C0 kontrol karakterleri (ESC 0x1B dahil) sökülür — text içine ESC/POS komutu gömerek izin duvarını delmek mümkün değildir. Geçersiz eleman atlanmaz, iş tamamen reddedilir: eklentinin "bastım" sanıp kasiyerin eksik fiş görmesi böyle engellenir.

Bilinen iki eksik

  1. Yetenek/scope ön sorgusu YOK. Köprüde "bu kurulumda yazdırma var mı" diye soracağın bir action bulunmuyor (getContext yalnız serverId, pluginId, locale döner). Yani çalışmayacak bir düğmeyi önceden gizleyemezsin. Bugünkü yol: getSessionToken ile kendi backend'inden kurulumun onaylı scope'larını sor, ya da ilk printDenied sonrası düğmeyi gizle. Köprüye bir capabilities action'ı eklenmesi talebi kayıtlı.
  2. Onaylı scope listesi oturum başına bir kez çekilir ve yalnız eklenti kurulum/kaldırma/yükseltme akışında geçersizlenir. Test ederken seni en çok bu yanıltır: kasiyer oturumu açıkken yeni bir scope onaylarsan o oturumda printDenied almaya devam edersin. Sayfayı yenile ya da eklentiyi yükselt.

Tam örnek — gün sonu raporu

fiskaly TSE eklentisinin X-Bericht şablonundan türetildi; satır şablonlarının toplamları sahada test edildi.

Belgeyi kur, gönder, sonucu doğru anlat
// ── Platform sınırları (aşılırsa iş TAMAMEN reddedilir, kırpılmaz) ──
const MAX_ELEMENTS = 80;   // → printTooLarge
const RESERVE      = 8;    // kırpma notu + toplamlar + qr + feed için ayrılan pay

// ── Satır şablonları — width GÖRELİ ağırlıktır, platform 12 birime normalize eder ──
const pText = (text, opts = {}) => ({ type: "text", text, ...opts });
const pHr   = () => ({ type: "hr" });

const pPair = (label, value) => ({ type: "row", data: [
  { text: label, width: 8, align: "left"  },
  { text: value, width: 4, align: "right" },      // oranlar: 8/4
]});

const pQuad = (a, b, c, d) => ({ type: "row", data: [
  { text: a, width: 3, align: "left"  },
  { text: b, width: 3, align: "right" },
  { text: c, width: 3, align: "right" },
  { text: d, width: 3, align: "right" },          // oranlar: 3/3/3/3
]});

function buildRapor(rapor) {
  const el = [];
  el.push(pText("GÜN SONU RAPORU", { size: "2", align: "center", bold: true }));
  el.push(pText(rapor.isletmeAdi, { align: "center" }));
  el.push(pHr());

  el.push(pPair("Nakit",  rapor.nakit));
  el.push(pPair("Kart",   rapor.kart));
  el.push(pPair("TOPLAM", rapor.toplam));
  el.push(pHr());

  el.push(pText("KDV KIRILIMI", { bold: true }));
  el.push(pQuad("Oran", "Matrah", "KDV", "Top."));
  rapor.kdv.forEach((k) => el.push(pQuad(k.oran, k.matrah, k.vergi, k.toplam)));
  el.push(pHr());

  // 80 eleman bütçesine göre kırp
  const butce   = MAX_ELEMENTS - el.length - RESERVE;
  const basilan = rapor.fisler.slice(0, Math.max(0, butce));
  basilan.forEach((f) => el.push(pPair(f.no, f.tutar)));

  // ⚠️ Kırpma SESSİZ OLMAMALI — kâğıda yaz.
  if (basilan.length < rapor.fisler.length) {
    el.push(pText(`${rapor.fisler.length} fişin ${basilan.length}'i basıldı`, { bold: true }));
  }

  el.push(pHr());
  el.push({ type: "qr", text: rapor.dogrulamaUrl });
  el.push({ type: "feed" });   // "cut" KOYMA — aşağıdaki nota bakın

  return el;
}

// ── Gönder ve sonucu operatöre DOĞRU cümleyle anlat ──
async function yazdir(rapor) {
  // printer alanını HİÇ gönderme → varsayılan yazıcıya gider (tek yazıcılı kurulumda sıfır konfigürasyon)
  const res = await bridgeCall("print", { elements: buildRapor(rapor) });

  if (!res.success) {
    switch (res.message) {
      case "printDenied":       return { ok: false, gizleDugme: true, mesaj: "Yazdırma izni verilmemiş." };
      case "printScopeUnknown": return { ok: false, tekrarDenenebilir: true, mesaj: "İzin durumu okunamadı, tekrar deneyin." };
      case "printerNotFound":
      case "printerMissing":    return { ok: false, mesaj: "Yazıcı bulunamadı — POS yazıcı ayarlarını kontrol edin." };
      case "printRateLimited":  return { ok: false, mesaj: "Çok sık yazdırma isteği, biraz bekleyin." };
      default:                  return { ok: false, mesaj: "Yazdırılamadı." };
    }
  }
  // duplicate: success:true ama KÂĞIT ÇIKMADI — başarı sayma.
  if (res.data?.duplicate) return { ok: false, mesaj: "Aynı rapor az önce gönderildi, tekrar basılmadı." };

  // indeterminate: gönderildi ama basıldığı DOĞRULANAMADI. Otomatik tekrar YOK (çift fiş riski).
  if (res.data?.indeterminate) {
    return { ok: true, belirsiz: true, mesaj: "Rapor gönderildi, yazıcıyı kontrol edin.", tekrarYazdirSun: true };
  }

  const not = res.data?.dropped > 0
    ? ` (${res.data.dropped} öğe bu cihazda desteklenmediği için basılmadı)` : "";
  return { ok: true, mesaj: "Rapor yazdırıldı." + not };
}
cut bilinçli olarak yok. v1 native'de cut, barcode ve drawer sessizce düşüyor → her basımda dropped ≥ 1 üretir ve gerçek bir düşmeyi maskeler. feed ile bitirmek daha dürüst bir sinyal verir.

İlgili