Files
meezi/docs/MEEZI_PRINTER_PLAN.md
soroush.asadi 03376b3ea1 feat(docker): multi-stage Dockerfiles with npmmirror registry
Rewrites dashboard and finder Dockerfiles to use a clean multi-stage
build (deps → builder → runner) that installs npm packages inside
Alpine Linux, avoiding the SWC musl binary issue when building from
Windows host. Uses registry.npmmirror.com for reliable installs from
restricted networks (Iran).

- docker/api/Dockerfile: .NET 10 multi-stage build
- docker/web/Dockerfile: Node 20-alpine multi-stage, npmmirror
- docker/finder/Dockerfile: Node 20-alpine multi-stage, npmmirror
- docker/website/Dockerfile: marketing website build
- scripts/: PowerShell helper scripts for local dev

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-05-27 21:33:29 +03:30

36 KiB
Raw Permalink Blame History

Meezi — Printer Support Plan

Copy-paste this into Cursor. Stack: ASP.NET Core 10, Next.js 14, Flutter 3 Existing packages: QuestPDF 2024.12.3, QRCoder 1.6.0


Architecture Overview

Three print paths — build all three:

PATH 1: Network Printer (API → TCP → Printer)
  Browser/Mobile → POST /api/print/receipt → API → TCP:9100 → Thermal printer
  Use for: POS receipts, kitchen tickets, table bills
  Requires: WiFi/Ethernet printer on same LAN as server

PATH 2: PDF via QuestPDF (API → PDF → Download/Print)
  Browser → GET /api/print/report/{id}.pdf → stream PDF → browser print dialog
  Use for: End-of-day reports, formal invoices, management summaries
  Requires: Nothing extra — QuestPDF already installed

PATH 3: QZ Tray Bridge (Browser WebSocket → localhost:8181 → USB Printer)
  Browser → WebSocket localhost:8181 → QZ Tray → USB thermal printer
  Use for: Cafés with USB-only printers (cheaper hardware)
  Requires: QZ Tray installed on café's Windows machine (one-time setup)

PROMPT 1 — Network Thermal Printer: Backend ESC/POS Service

Context: Meezi POS, ASP.NET Core 10. BranchSettings entity exists.
Goal: Print thermal receipts by sending ESC/POS bytes over TCP to a
      network printer. No extra library needed — raw TCP socket.

────────────────────────────────────────────────────────────────
STEP 1 — Add printer config to BranchSettings
────────────────────────────────────────────────────────────────

File: src/Meezi.Core/Entities/BranchSettings.cs — add fields:

  // Network printer config (TCP/IP)
  public string? ReceiptPrinterIp { get; set; }      // e.g. "192.168.1.100"
  public int? ReceiptPrinterPort { get; set; }        // default 9100
  public string? KitchenPrinterIp { get; set; }       // separate kitchen printer
  public int? KitchenPrinterPort { get; set; }
  public int PaperWidthMm { get; set; } = 80;         // 58 or 80
  public bool AutoCutEnabled { get; set; } = true;
  public string? ReceiptLogoBase64 { get; set; }      // optional small logo

EF migration:
  dotnet ef migrations add AddPrinterSettings \
    --project src/Meezi.Infrastructure \
    --startup-project src/Meezi.API

────────────────────────────────────────────────────────────────
STEP 2 — ESC/POS builder utility
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/Printing/EscPosBuilder.cs (new)

Do NOT use any external ESC/POS library. Build the byte sequences directly.
ESC/POS is a simple byte protocol — only need these commands:

public class EscPosBuilder
{
    private readonly List<byte> _buffer = new();

    // ESC/POS constants
    private static readonly byte[] ESC = { 0x1B };
    private static readonly byte[] GS  = { 0x1D };

    public EscPosBuilder Initialize()
    {
        // ESC @ — initialize printer
        _buffer.AddRange(new byte[] { 0x1B, 0x40 });
        return this;
    }

    public EscPosBuilder SetEncoding()
    {
        // ESC t 37 — set code page to UTF-8 compatible
        // For Persian: use code page that supports UTF-8
        // Most modern Epson/Bixolon support ESC t with PC720 or UTF-8
        _buffer.AddRange(new byte[] { 0x1B, 0x74, 0x25 });
        return this;
    }

    public EscPosBuilder AlignCenter()
    {
        _buffer.AddRange(new byte[] { 0x1B, 0x61, 0x01 });
        return this;
    }

    public EscPosBuilder AlignRight()
    {
        _buffer.AddRange(new byte[] { 0x1B, 0x61, 0x02 });
        return this;
    }

    public EscPosBuilder AlignLeft()
    {
        _buffer.AddRange(new byte[] { 0x1B, 0x61, 0x00 });
        return this;
    }

    public EscPosBuilder Bold(bool on)
    {
        _buffer.AddRange(new byte[] { 0x1B, 0x45, on ? (byte)1 : (byte)0 });
        return this;
    }

    public EscPosBuilder DoubleHeight(bool on)
    {
        _buffer.AddRange(new byte[] { 0x1B, 0x21, on ? (byte)0x10 : (byte)0x00 });
        return this;
    }

    public EscPosBuilder Text(string text)
    {
        // Encode as UTF-8 — modern thermal printers support it
        _buffer.AddRange(System.Text.Encoding.UTF8.GetBytes(text));
        return this;
    }

    public EscPosBuilder Line(string text = "")
    {
        return Text(text + "\n");
    }

    public EscPosBuilder Separator(int width = 48, char ch = '-')
    {
        return Line(new string(ch, width));
    }

    // Print two columns (right-aligned second column)
    // e.g. "کالا × 2" and "25,000 تومان" on same line
    public EscPosBuilder TwoColumns(string left, string right, int totalWidth = 48)
    {
        var padded = left.PadRight(totalWidth - right.Length - 1) + right;
        return Line(padded);
    }

    public EscPosBuilder Feed(int lines = 3)
    {
        // GS V — feed and cut  (lines before cut)
        for (int i = 0; i < lines; i++)
            _buffer.Add(0x0A);
        return this;
    }

    public EscPosBuilder Cut()
    {
        // GS V 66 3 — partial cut with feed
        _buffer.AddRange(new byte[] { 0x1D, 0x56, 0x42, 0x03 });
        return this;
    }

    public byte[] Build() => _buffer.ToArray();
}

────────────────────────────────────────────────────────────────
STEP 3 — Receipt template builder
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/Printing/ReceiptBuilder.cs (new)

Uses EscPosBuilder to compose a full receipt from an OrderDto + BranchSettings.

public class ReceiptBuilder
{
    public byte[] BuildReceipt(Order order, BranchEffectiveSettingsDto settings,
                                string cafeName, string branchName)
    {
        var b = new EscPosBuilder();
        int width = settings.PaperWidthMm == 58 ? 32 : 48;

        b.Initialize()
         .SetEncoding();

        // Header
        b.AlignCenter()
         .Bold(true)
         .DoubleHeight(true)
         .Line(cafeName)
         .DoubleHeight(false)
         .Bold(false)
         .Line(branchName);

        if (!string.IsNullOrEmpty(settings.ReceiptHeader))
            b.Line(settings.ReceiptHeader);

        // Order info
        var shamsiDate = ToShamsi(order.CreatedAt); // helper method
        b.AlignRight()
         .Line($"شماره سفارش: {order.OrderNumber}")
         .Line($"تاریخ: {shamsiDate}")
         .Line($"میز: {order.TableName ?? "—"}");

        if (!string.IsNullOrEmpty(order.GuestName))
            b.Line($"مهمان: {order.GuestName}");

        b.Separator(width)
         .AlignRight();

        // Items
        foreach (var item in order.Items.Where(i => !i.IsVoided))
        {
            var itemTotal = FormatCurrency(item.UnitPrice * item.Quantity);
            var itemLine = $"{item.ProductName} × {item.Quantity}";
            b.TwoColumns(itemLine, itemTotal, width);
        }

        b.Separator(width);

        // Totals
        if (order.TaxAmount > 0)
            b.TwoColumns("مالیات", FormatCurrency(order.TaxAmount), width);

        if (order.ServiceCharge > 0)
            b.TwoColumns("سرویس", FormatCurrency(order.ServiceCharge), width);

        b.Bold(true)
         .TwoColumns("مجموع کل", FormatCurrency(order.TotalAmount), width)
         .Bold(false);

        // Payments
        foreach (var payment in order.Payments ?? [])
        {
            var methodLabel = payment.Method switch {
                "Cash"   => "نقد",
                "Card"   => "کارت",
                "Credit" => "اعتبار",
                _        => payment.Method
            };
            b.TwoColumns(methodLabel, FormatCurrency(payment.Amount), width);
        }

        b.Separator(width);

        // Footer
        b.AlignCenter();
        if (!string.IsNullOrEmpty(settings.WifiPassword))
            b.Line($"WiFi: {settings.WifiPassword}");
        if (!string.IsNullOrEmpty(settings.ReceiptFooter))
            b.Line(settings.ReceiptFooter);
        b.Line("ممنون از انتخاب شما");

        b.Feed(3)
         .Cut();

        return b.Build();
    }

    private static string FormatCurrency(decimal amount)
        => $"{amount:N0} تومان";

    private static string ToShamsi(DateTime dt)
    {
        // Use System.Globalization.PersianCalendar
        var pc = new System.Globalization.PersianCalendar();
        return $"{pc.GetYear(dt)}/{pc.GetMonth(dt):D2}/{pc.GetDayOfMonth(dt):D2} " +
               $"{dt:HH:mm}";
    }
}

────────────────────────────────────────────────────────────────
STEP 4 — Network printer sender
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/Printing/NetworkPrinterService.cs (new)

public interface IPrinterService
{
    Task<PrintResult> PrintReceiptAsync(Guid orderId, CancellationToken ct);
    Task<PrintResult> PrintKitchenTicketAsync(Guid orderId, CancellationToken ct);
    Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct);
}

public class NetworkPrinterService : IPrinterService
{
    private readonly IOrderService _orders;
    private readonly IEffectiveSettingsService _settings;
    private readonly ReceiptBuilder _receiptBuilder;
    private readonly ILogger<NetworkPrinterService> _logger;

    public async Task<PrintResult> PrintReceiptAsync(Guid orderId, CancellationToken ct)
    {
        var order = await _orders.GetOrderAsync(orderId, ct);
        var settings = await _settings.GetEffectiveSettingsAsync(
            order.CafeId, order.BranchId, ct);

        if (string.IsNullOrEmpty(settings.ReceiptPrinterIp))
            return PrintResult.Fail("PRINTER_NOT_CONFIGURED");

        var bytes = _receiptBuilder.BuildReceipt(order, settings,
            order.CafeName, order.BranchName);

        return await SendToPrinterAsync(
            settings.ReceiptPrinterIp,
            settings.ReceiptPrinterPort ?? 9100,
            bytes,
            ct);
    }

    public async Task<PrintResult> PrintKitchenTicketAsync(Guid orderId, CancellationToken ct)
    {
        var order = await _orders.GetOrderAsync(orderId, ct);
        var settings = await _settings.GetEffectiveSettingsAsync(
            order.CafeId, order.BranchId, ct);

        if (string.IsNullOrEmpty(settings.KitchenPrinterIp))
            return PrintResult.Fail("KITCHEN_PRINTER_NOT_CONFIGURED");

        var bytes = BuildKitchenTicket(order, settings);
        return await SendToPrinterAsync(
            settings.KitchenPrinterIp,
            settings.KitchenPrinterPort ?? 9100,
            bytes, ct);
    }

    private static byte[] BuildKitchenTicket(Order order, BranchEffectiveSettingsDto settings)
    {
        var b = new EscPosBuilder();
        int width = settings.PaperWidthMm == 58 ? 32 : 48;
        var pc = new System.Globalization.PersianCalendar();

        b.Initialize()
         .SetEncoding()
         .AlignCenter()
         .Bold(true)
         .DoubleHeight(true)
         .Line("آشپزخانه")
         .DoubleHeight(false)
         .AlignRight()
         .Line($"میز: {order.TableName ?? "—"} | #{order.OrderNumber}")
         .Line($"{DateTime.Now:HH:mm}")
         .Separator(width);

        foreach (var item in order.Items.Where(i => !i.IsVoided))
        {
            b.Bold(true)
             .Line($"× {item.Quantity}  {item.ProductName}")
             .Bold(false);

            if (!string.IsNullOrEmpty(item.Notes))
                b.Line($"  ← {item.Notes}");
        }

        b.Feed(4).Cut();
        return b.Build();
    }

    private async Task<PrintResult> SendToPrinterAsync(
        string ip, int port, byte[] data, CancellationToken ct)
    {
        try
        {
            using var client = new System.Net.Sockets.TcpClient();
            client.SendTimeout = 3000;
            client.ReceiveTimeout = 3000;

            await client.ConnectAsync(ip, port, ct);
            await using var stream = client.GetStream();
            await stream.WriteAsync(data, ct);
            await stream.FlushAsync(ct);

            _logger.LogInformation("Printed {Bytes} bytes to {Ip}:{Port}", data.Length, ip, port);
            return PrintResult.Ok();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Print failed to {Ip}:{Port}", ip, port);
            return PrintResult.Fail("PRINTER_CONNECTION_FAILED", ex.Message);
        }
    }
}

public record PrintResult(bool Success, string? ErrorCode, string? ErrorDetail)
{
    public static PrintResult Ok() => new(true, null, null);
    public static PrintResult Fail(string code, string? detail = null) => new(false, code, detail);
}

────────────────────────────────────────────────────────────────
STEP 5 — Print controller
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Controllers/PrintController.cs (new)
Base: CafeApiControllerBase (inherits [Authorize])

POST /api/cafes/{cafeId}/print/receipt/{orderId}
  → Prints receipt to branch receipt printer
  → Returns 200 OK or ApiError with PRINTER_NOT_CONFIGURED / PRINTER_CONNECTION_FAILED

POST /api/cafes/{cafeId}/print/kitchen/{orderId}
  → Prints kitchen ticket to branch kitchen printer

POST /api/cafes/{cafeId}/print/test
  Body: { printerIp, port }
  → Prints test page: "Meezi Test Print ✓" + date
  → Owner/Manager only — used from settings page to verify connection

────────────────────────────────────────────────────────────────
STEP 6 — Register services in DI
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Extensions/ServiceCollectionExtensions.cs

services.AddScoped<EscPosBuilder>();
services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>();

────────────────────────────────────────────────────────────────
STEP 7 — Auto-print after payment
────────────────────────────────────────────────────────────────

In OrderService.ProcessPaymentAsync, after order is closed:

// Fire-and-forget print — don't block payment on print success
_ = Task.Run(async () => {
    try {
        await _printerService.PrintReceiptAsync(order.Id, CancellationToken.None);
    } catch (Exception ex) {
        _logger.LogWarning(ex, "Auto-print failed for order {OrderId}", order.Id);
    }
});

// Also print kitchen ticket when new items are added (on append)
// In OrderService.AppendItemsAsync — after saving:
_ = Task.Run(async () => {
    try {
        await _printerService.PrintKitchenTicketAsync(order.Id, CancellationToken.None);
    } catch (Exception ex) {
        _logger.LogWarning(ex, "Kitchen print failed for order {OrderId}", order.Id);
    }
});

────────────────────────────────────────────────────────────────
STEP 8 — Dashboard: Print button + printer settings
────────────────────────────────────────────────────────────────

In pos-pay-panel.tsx — after payment success, add manual print button:
  <button onClick={() => printReceipt(orderId)}>{t("pos.printReceipt")}</button>

Where printReceipt calls:
  POST /api/cafes/{cafeId}/print/receipt/{orderId}

Show toast on success/failure:
  Success: t("print.success") — "رسید چاپ شد"
  Failure PRINTER_NOT_CONFIGURED: t("print.notConfigured") — "پرینتر تنظیم نشده"
  Failure PRINTER_CONNECTION_FAILED: t("print.connectionFailed") — "خطا در اتصال به پرینتر"

In settings page (web/dashboard/src/components/settings/):
  Add "تنظیمات پرینتر" section:
    - Receipt printer IP input
    - Receipt printer port (default 9100)
    - Kitchen printer IP input
    - Paper width selector (58mm / 80mm)
    - Auto-cut toggle
    - WiFi password field (shown on receipt)
    - "تست پرینت" button → POST /print/test

────────────────────────────────────────────────────────────────
TESTS (add to tests/Meezi.API.Tests/PrintingTests.cs)
────────────────────────────────────────────────────────────────

For unit tests, mock IPrinterService — don't test actual TCP.
Test ReceiptBuilder and EscPosBuilder directly (they're pure logic).

✓ ReceiptBuilder_ExcludesVoidedItems
✓ ReceiptBuilder_AppliesPersianCalendarDate
✓ ReceiptBuilder_ShowsTaxLine_WhenTaxNonZero
✓ ReceiptBuilder_80mm_Uses48CharWidth
✓ ReceiptBuilder_58mm_Uses32CharWidth
✓ KitchenTicket_IncludesItemNotes
✓ PrintController_NoPrinterConfigured_ReturnsPrinterNotConfigured
✓ PrintController_AfterPayment_AutoPrintFires (mock IPrinterService)
✓ EscPosBuilder_Cut_AppendsCorrectBytes
✓ EscPosBuilder_TwoColumns_PadsCorrectly

i18n strings:
fa.json under "print":
{
  "printReceipt": "چاپ رسید",
  "printKitchen": "ارسال به آشپزخانه",
  "success": "رسید با موفقیت چاپ شد",
  "notConfigured": "آدرس پرینتر تنظیم نشده است",
  "connectionFailed": "خطا در اتصال به پرینتر",
  "testPrint": "تست پرینت",
  "printerSettings": "تنظیمات پرینتر",
  "receiptPrinter": "پرینتر رسید",
  "kitchenPrinter": "پرینتر آشپزخانه",
  "paperWidth": "عرض کاغذ",
  "autoCut": "برش خودکار"
}

PROMPT 2 — PDF Formal Invoices via QuestPDF

Context: Meezi POS, ASP.NET Core 10. QuestPDF 2024.12.3 already installed.
Goal: Generate professional PDF invoices for orders and end-of-day reports.
      These are for formal billing, accounting export, and management — not thermal.

────────────────────────────────────────────────────────────────
STEP 1 — Order Invoice PDF
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/Printing/OrderInvoiceDocument.cs (new)

Use QuestPDF fluent API to build an A4 invoice.

Key sections:
  Header:
    - Café logo (if stored) or café name large
    - Branch name, address, phone
    - "فاکتور رسمی" title
    - Invoice number (= order number), date in Shamsi

  Customer section:
    - Guest name / customer name if linked
    - Table number, server name

  Items table:
    Columns: ردیف | نام آیتم | تعداد | قیمت واحد | جمع
    - Voided items excluded
    - Alternating row shading

  Totals section:
    - Subtotal
    - Tax (if applicable)
    - Service charge (if applicable)
    - Total (bold, larger)
    - Payment method(s)

  Footer:
    - Thank you message
    - WiFi password if set
    - QR code linking to digital receipt (use QRCoder)

QuestPDF document class:
public class OrderInvoiceDocument : IDocument
{
    private readonly Order _order;
    private readonly BranchEffectiveSettingsDto _settings;
    private readonly string _cafeName;

    public DocumentMetadata GetMetadata() => DocumentMetadata.Default with
    {
        Title = $"فاکتور #{_order.OrderNumber}",
        Author = _cafeName
    };

    public void Compose(IDocumentContainer container)
    {
        container.Page(page => {
            page.Size(PageSizes.A4);
            page.MarginHorizontal(30);
            page.MarginVertical(20);
            page.ContentFromRightToLeft(); // RTL for Persian

            page.Header().Element(ComposeHeader);
            page.Content().Element(ComposeContent);
            page.Footer().Element(ComposeFooter);
        });
    }

    // Implement ComposeHeader, ComposeContent, ComposeFooter
    // following QuestPDF fluent API patterns
}

────────────────────────────────────────────────────────────────
STEP 2 — Daily Report PDF
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Services/Printing/DailyReportDocument.cs (new)

A4 PDF for end-of-day / end-of-shift report.

Sections:
  Header: café name, branch, date, shift info
  KPI summary: total revenue, orders, avg order, net income — as large stat boxes
  Payment breakdown: Cash / Card / Credit as a simple table
  Top 10 products: table with rank, name, qty, revenue
  Expense list: category, amount, note
  Shift reconciliation: opening cash, expected, actual, discrepancy
  Staff signature line (for physical printing)

────────────────────────────────────────────────────────────────
STEP 3 — PDF endpoints
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Controllers/PrintController.cs — add:

GET /api/cafes/{cafeId}/print/invoice/{orderId}.pdf
  → Generates and streams PDF
  → Content-Type: application/pdf
  → Content-Disposition: inline (opens in browser) or attachment (downloads)
  → Query param: ?disposition=inline|attachment
  → Authorization: any authenticated staff

GET /api/cafes/{cafeId}/reports/daily/{date}/pdf?branchId=
  → Generates DailyReport PDF for given date
  → Authorization: Manager+

Implementation:
  var pdf = Document.Create(doc => new OrderInvoiceDocument(order, settings, cafeName).Compose(doc));
  var bytes = pdf.GeneratePdf();
  return File(bytes, "application/pdf", $"invoice-{order.OrderNumber}.pdf");

────────────────────────────────────────────────────────────────
STEP 4 — Dashboard PDF download buttons
────────────────────────────────────────────────────────────────

In pos-receipt-modal.tsx — add second button:
  <button onClick={() => window.open(`/api/.../invoice/${orderId}.pdf?disposition=inline`)}>
    {t("print.formalInvoice")}
  </button>

In reports page — add "دانلود PDF" button per daily report row:
  → uses lib/api/download.ts downloadFile() helper

i18n additions:
fa.json:
  "print.formalInvoice": "فاکتور رسمی (PDF)"
  "print.downloadReport": "دانلود گزارش PDF"

PROMPT 3 — QZ Tray Bridge (USB Printer Support)

Context: Meezi dashboard (Next.js 14). Some cafés have USB thermal printers.
Goal: Support USB printing via QZ Tray — a background service the café installs once.
      The dashboard communicates with it via WebSocket on localhost:8181.

────────────────────────────────────────────────────────────────
STEP 1 — Install QZ Tray client library
────────────────────────────────────────────────────────────────

cd web/dashboard
npm install qz-tray

────────────────────────────────────────────────────────────────
STEP 2 — QZ Tray print service
────────────────────────────────────────────────────────────────

File: web/dashboard/src/lib/printing/qz-bridge.ts (new)

import qz from "qz-tray";

let connected = false;

export async function connectQZ(): Promise<boolean> {
  if (connected) return true;
  try {
    await qz.websocket.connect();
    connected = true;
    return true;
  } catch {
    return false; // QZ Tray not running — fall back to API print
  }
}

export async function disconnectQZ() {
  if (connected) {
    await qz.websocket.disconnect();
    connected = false;
  }
}

export async function printWithQZ(
  printerName: string,
  escPosHex: string[]   // hex strings of ESC/POS bytes from API
): Promise<boolean> {
  const ok = await connectQZ();
  if (!ok) return false;

  const config = qz.configs.create(printerName);
  const data = escPosHex.map(hex => ({
    type: "raw" as const,
    format: "hex" as const,
    data: hex
  }));

  await qz.print(config, data);
  return true;
}

export async function listQZPrinters(): Promise<string[]> {
  const ok = await connectQZ();
  if (!ok) return [];
  return await qz.printers.find();
}

────────────────────────────────────────────────────────────────
STEP 3 — Backend: add hex export endpoint
────────────────────────────────────────────────────────────────

File: src/Meezi.API/Controllers/PrintController.cs — add:

GET /api/cafes/{cafeId}/print/receipt/{orderId}/raw
  → Same as receipt builder, but instead of sending to TCP printer,
    returns the ESC/POS bytes as hex string array in JSON
  → Used by QZ Tray bridge in browser

Response:
{
  "printerName": "EPSON TM-T88VI",  // from branch settings
  "data": ["1b40", "1b74", ...]     // ESC/POS bytes as hex
}

In BranchSettings, add:
  public string? UsbPrinterName { get; set; }  // Windows printer name for QZ

────────────────────────────────────────────────────────────────
STEP 4 — Smart print dispatcher in dashboard
────────────────────────────────────────────────────────────────

File: web/dashboard/src/lib/printing/print-dispatcher.ts (new)

Tries QZ Tray first, falls back to API network print.

export async function printReceipt(
  cafeId: string,
  orderId: string
): Promise<{ success: boolean; method: "qz" | "network" | "failed" }> {

  // Try QZ Tray first (USB printer)
  const qzOk = await connectQZ();
  if (qzOk) {
    try {
      const res = await apiClient.get(
        `/cafes/${cafeId}/print/receipt/${orderId}/raw`
      );
      const printed = await printWithQZ(res.printerName, res.data);
      if (printed) return { success: true, method: "qz" };
    } catch {
      // fall through
    }
  }

  // Fall back to network print via API
  try {
    await apiClient.post(`/cafes/${cafeId}/print/receipt/${orderId}`);
    return { success: true, method: "network" };
  } catch {
    return { success: false, method: "failed" };
  }
}

────────────────────────────────────────────────────────────────
STEP 5 — Printer settings page: detect QZ Tray
────────────────────────────────────────────────────────────────

In settings page printer section, add a QZ Tray detector:

const [qzAvailable, setQzAvailable] = useState<boolean | null>(null);
const [availablePrinters, setAvailablePrinters] = useState<string[]>([]);

useEffect(() => {
  connectQZ().then(ok => {
    setQzAvailable(ok);
    if (ok) listQZPrinters().then(setAvailablePrinters);
  });
}, []);

UI:
  if qzAvailable === true:
    → Green badge: "QZ Tray متصل است ✓"
    → Dropdown: select USB printer from availablePrinters
    → Save selection to branchSettings.UsbPrinterName

  if qzAvailable === false:
    → Yellow badge: "QZ Tray نصب نشده"
    → Link: "دانلود QZ Tray" → https://qz.io
    → Info: "برای استفاده از پرینترهای USB، QZ Tray را یک‌بار روی این کامپیوتر نصب کنید"
    → Alternative: "در صورت داشتن پرینتر شبکه، آدرس IP را وارد کنید"

  if qzAvailable === null:
    → Spinner: "در حال بررسی..."

PROMPT 4 — Flutter Bluetooth/Network Print (meezi_pos)

Context: Flutter 3, mobile/meezi_pos. 
Goal: Print from the Flutter POS app to Bluetooth or network thermal printers.

────────────────────────────────────────────────────────────────
STEP 1 — Add packages to pubspec.yaml
────────────────────────────────────────────────────────────────

dependencies:
  esc_pos_utils_plus: ^2.0.2    # ESC/POS command builder for Dart
  flutter_bluetooth_printer: ^3.0.0  # Bluetooth printing (Android/iOS)
  # For network printing, use dart:io TcpSocket directly (no extra package)

────────────────────────────────────────────────────────────────
STEP 2 — Print service abstraction
────────────────────────────────────────────────────────────────

File: mobile/meezi_pos/lib/services/print_service.dart

abstract class PrintService {
  Future<bool> printReceipt(OrderModel order, BranchSettings settings);
  Future<bool> printKitchenTicket(OrderModel order);
  Future<List<PrinterDevice>> discoverPrinters();
}

class NetworkPrintService implements PrintService {
  @override
  Future<bool> printReceipt(OrderModel order, BranchSettings settings) async {
    final bytes = _buildReceiptBytes(order, settings);
    return await _sendToNetworkPrinter(
      settings.receiptPrinterIp!,
      settings.receiptPrinterPort ?? 9100,
      bytes
    );
  }

  Future<bool> _sendToNetworkPrinter(String ip, int port, List<int> bytes) async {
    try {
      final socket = await Socket.connect(ip, port,
          timeout: const Duration(seconds: 3));
      socket.add(bytes);
      await socket.flush();
      await socket.close();
      return true;
    } catch (e) {
      debugPrint("Network print error: $e");
      return false;
    }
  }
}

class BluetoothPrintService implements PrintService {
  @override
  Future<bool> printReceipt(OrderModel order, BranchSettings settings) async {
    // Use flutter_bluetooth_printer
    // Find paired printer → send ESC/POS bytes
    final bytes = _buildReceiptBytes(order, settings);
    return await FlutterBluetoothPrinter.printBytes(
      address: settings.bluetoothPrinterAddress!,
      data: Uint8List.fromList(bytes),
    );
  }

  @override
  Future<List<PrinterDevice>> discoverPrinters() async {
    return await FlutterBluetoothPrinter.discover();
  }
}

────────────────────────────────────────────────────────────────
STEP 3 — ESC/POS receipt builder in Dart
────────────────────────────────────────────────────────────────

File: mobile/meezi_pos/lib/services/receipt_builder.dart

Use esc_pos_utils_plus to build the receipt — same structure as
the C# ReceiptBuilder (header, items, totals, footer, cut).

PaperSize based on settings.paperWidthMm:
  58mm → PaperSize.mm58
  80mm → PaperSize.mm80

Persian text: most modern BT printers support UTF-8.
If printer doesn't support Persian, fall back to transliteration
or use image-mode printing (render receipt as image, print as bitmap).

────────────────────────────────────────────────────────────────
STEP 4 — Printer settings in Flutter POS
────────────────────────────────────────────────────────────────

File: mobile/meezi_pos/lib/screens/printer_settings_screen.dart

Tabs:
  1. "بلوتوث" — scan + pair Bluetooth printers
  2. "شبکه" — enter IP:port manually or auto-discover via mDNS

On pair/save → store in SharedPreferences or Drift local DB.
Add "تست پرینت" button — prints test page.

────────────────────────────────────────────────────────────────
STEP 5 — Auto-print after POS payment
────────────────────────────────────────────────────────────────

In order payment flow, after API confirms payment:

final printService = ref.read(printServiceProvider);
final printed = await printService.printReceipt(order, branchSettings);
if (!printed) {
  showSnackBar(context, "خطا در اتصال به پرینتر");
}

Summary: Which Approach for Which Scenario

Scenario Solution Effort
Café has WiFi/Ethernet printer PROMPT 1 (API → TCP) Build first
Need formal A4 invoice PDF PROMPT 2 (QuestPDF) Build second
Café has USB printer on Windows PC PROMPT 3 (QZ Tray) Build third
Mobile waiter tablet → Bluetooth print PROMPT 4 (Flutter) Flutter sprint

New Error Codes

Code Meaning
PRINTER_NOT_CONFIGURED No printer IP set in branch settings
KITCHEN_PRINTER_NOT_CONFIGURED No kitchen printer IP set
PRINTER_CONNECTION_FAILED TCP connection to printer failed
PRINTER_TIMEOUT Printer connected but didn't respond
  • Epson TM-T82III-i — built-in WiFi, widely available, best support
  • Bixolon SRP-350plusIII — network version, very reliable
  • Sewoo LK-TE112NR — cheaper, good for budget cafés
  • 80mm paper recommended over 58mm — more readable

Start with PROMPT 1 — it has zero dependencies and works immediately with any network printer.