Skip to content
كل المقالات

طباعة أوردرات المطبخ من تطبيق على السحابة بجسر Node من 89 سطر

إزاي أوردر اتعمل من الموبايل بيوصل لطابعة حرارية جوه الكافيه: جسر بيسأل السيرفر من غير أي مكتبات، وتذاكر مرسومة كصور عشان العربي يطلع صح، وطابور ما بيضيعش لو حاجة وقعت.

أحمد عبد المجيد

قراءة 3 دقايق

شارك:WhatsAppLinkedIn

قهوتي منصة طلبات للكافيهات: تطبيق للعميل، لوحة تحكم للمحل، و API واحد. الـ API شغال على السحابة. طابعة المطبخ موجودة على شبكة المحل نفسه، ورا الراوتر. الأوردر اللي اتعمل من الموبايل لازم يطلع من الطابعة دي بعد ثانيتين تقريباً. ده الشرح، في أربع حتت.

شاشة متابعة الأوردر في قهوتي، من الاستلام لحد ما يجهز
العميل بيتابع الأوردر من أول ما يتستلم لحد ما يجهز. والمطبخ بيوصله تذكرة ورق.

١. المحل هو اللي بيسأل، محدش بيدخل عليه#

السحابة ما تقدرش تفتح اتصال على طابعة في شبكة خاصة، ومش منطقي أطلب من صاحب كافيه يفتح بورتات في الراوتر. فيه سكريبت صغير بيشتغل على أي كمبيوتر في الفرع، PC أو Mac أو Raspberry Pi، وبيسأل الـ API عن شغل جديد كل ثانيتين. كل اللي بيعمله طلبات خارجة، ومش محتاج أي بورت مفتوح. الجسر كله ملف واحد 89 سطر من غير أي مكتبات npm: الـ fetch و net و fs اللي جوه Node كفاية.

const headers = { "X-Print-Token": token, "Content-Type": "application/json" };
const res = await fetchImpl(`${apiUrl}/print-agent/jobs?connection=network,device&limit=5`, { headers });
const { data: jobs } = await res.json();
for (const job of jobs) {
  let result;
  try {
    await printJob(job, { dryRun, outDir });
    result = { ok: true };
  } catch (err) {
    result = { ok: false, error: String(err.message).slice(0, 300) };
  }
  await fetchImpl(`${apiUrl}/print-agent/jobs/${job.id}`, { method: "POST", headers, body: JSON.stringify(result) });
}

كل فرع ليه توكن خاص بيه، وكل سؤال بيتحسب كمان نبضة: لوحة التحكم بتعتبر طباعة الفرع شغالة لو سمعت منه في آخر دقيقة. ولو الـ API مش واصل، وقت الانتظار بيتضاعف لحد 30 ثانية ويرجع لأصله مع أول نجاح.

الإرسال للطابعة هو أسهل حتة. الطابعات الحرارية اللي على الشبكة بتسمع على بورت TCP 9100 وبتطبع أي بايتات توصلها.

export function sendToNetwork(host, port, bytes, timeoutMs = 10_000) {
  return new Promise((resolve, reject) => {
    const socket = net.createConnection({ host, port });
    const fail = (err) => {
      socket.destroy();
      reject(err);
    };
    socket.setTimeout(timeoutMs, () => fail(new Error(`timed out talking to ${host}:${port}`)));
    socket.once("error", fail);
    socket.once("connect", () => socket.end(bytes, () => resolve()));
  });
}

٢. ارسم التذكرة كصورة#

الطابعات الحرارية بتيجي بخطوط جاهزة و code pages، والعربي محتاج أكتر من كده: الحروف لازم تتوصّل ببعض وتتكتب من اليمين للشمال. قهوتي بيتجاهل خطوط الطابعة خالص. السيرفر بيرسم التذكرة على canvas بخط Cairo، فمحرّك نصوص حقيقي هو اللي بيشكّل العربي، وبعدين بيحوّل الصورة لنقط أبيض وأسود ويبعتها كصورة raster.

const lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];
if (data[i + 3] > 128 && lum < 150) bits[y * bytesPerRow + (x >> 3)] |= 0x80 >> (x & 7);
// ...
const one = [Buffer.from([0x1b, 0x40])];
for (let y = 0; y < height; y += 256) {
  const rows = Math.min(256, height - y);
  one.push(Buffer.from([0x1d, 0x76, 0x30, 0x00, bytesPerRow & 0xff, bytesPerRow >> 8, rows & 0xff, rows >> 8]));
  one.push(bits.subarray(y * bytesPerRow, (y + rows) * bytesPerRow));
}
one.push(Buffer.from([0x1b, 0x64, 0x04, 0x1d, 0x56, 0x42, 0x00]));

ورق 80 مم عرضه 576 نقطة و58 مم عرضه 384، فالـ canvas بيتعمل بنفس العرض بالظبط. البايتات بتقول: صفّر الطابعة، دي الصورة على شرايح كل واحدة لحد 256 صف، قدّم أربع سطور، اقطع. وعشان السيرفر هو اللي بيرسم، الجسر بيوصّل بايتات جاهزة وبس، والمعاينة اللي في لوحة التحكم هي نفس الصورة اللي بتطلع من الطابعة.

الجسر من غير مكتبات عشان مش بيفكّر: السيرفر بيرسم، والجسر بيوصّل.

٣. طابور ما بيضيعش لو حاجة وقعت#

أوامر الطباعة متخزّنة في جدول PostgreSQL. لما الجسر يسأل، الـ API بياخد أقدم الأوامر المستنية في جملة واحدة، و SKIP LOCKED معناها إن اتنين بيسألوا في نفس اللحظة عمرهم ما ياخدوا نفس الأمر.

UPDATE print_jobs j SET status = 'printing', claimed_at = now(), attempts = attempts + 1
 WHERE j.id IN (SELECT j2.id FROM print_jobs j2 JOIN printers p ON p.id = j2.printer_id
                 WHERE j2.branch_id = $1 AND j2.status = 'queued' AND p.is_active AND p.connection = ANY($2)
                 ORDER BY j2.created_at LIMIT $3 FOR UPDATE OF j2 SKIP LOCKED)
 RETURNING j.*

الأمر اللي اتاخد ومحدش رد عليه، عشان الجسر وقع أو النت قطع، بيرجع الطابور بعد دقيقتين، وبعد تلات محاولات بيتعلّم إنه فشل. والأمر اللي الطابعة رفضته، عشان مفصولة أو العنوان غلط، بيتعلّم فشل على طول ومعاه السبب، والموظف بيعيده من لوحة التحكم.

فيه حد لازم أكون صريح فيه. مفيش حاجة بتتقري من الطابعة، فالجسر عارف إن البايتات اتبعتت، مش إن الورقة طلعت. ولو الجسر وقع بعد الطباعة وقبل ما يبلّغ، التذكرة دي هتتطبع مرتين. وفي المطبخ، تذكرة متكررة مشكلة أصغر من تذكرة ضايعة.

٤. التذكرة الصح في المحطة الصح#

الفرع ممكن يبقى فيه كذا طابعة: طابعة فواتير عند الكاشير وطابعة مطبخ لكل محطة. طابعة المطبخ ممكن تتحدد بأقسام من المنيو، فالإسبريسو يتطبع عند البار مش عند المخبوزات، والمحطة اللي مالهاش حاجة تعملها ما بيوصلهاش تذكرة أصلاً. ولو المحل ما عندوش كمبيوتر زيادة، الطابعة ممكن تتظبط إنها تطبع من المتصفح: صفحة في لوحة التحكم على تابلت المحل بتاخد نفس الأوامر وتطبع نفس الصورة.

نصيحتي لأي حد بيبني حاجة زي كده#

  • خلّي المحل هو اللي يسأل السحابة عن الشغل، وما تطلبش من كافيه يفتح بورت.
  • ارسم التذاكر كصور، والعربي يبطّل يبقى مشكلة طابعة.
  • خلّي الطابور في قاعدة البيانات وخد الأوامر بـ SKIP LOCKED.
  • اعرف "اتطبع" معناها إيه بالظبط عندك، وفضّل تذكرة متكررة على تذكرة ضايعة.
شارك:WhatsAppLinkedIn

Keyboard shortcuts