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

936 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
## Recommended Printers for Iran (tested with ESC/POS + TCP/9100)
- **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.*