03376b3ea1
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>
36 KiB
36 KiB
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.