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>
936 lines
36 KiB
Markdown
936 lines
36 KiB
Markdown
# 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.*
|