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>
This commit is contained in:
@@ -0,0 +1,935 @@
|
||||
# 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.*
|
||||
Reference in New Issue
Block a user