feat(api): .NET 10 multi-tenant REST API

Full backend implementation:
- Multi-tenant cafe/restaurant management (menus, orders, tables, staff)
- POS order flow with ZarinPal and Snappfood payment integration
- OTP authentication via Kavenegar SMS
- QR digital menu with public discover/finder endpoints
- Customer loyalty, coupons, CRM
- PostgreSQL via EF Core, Redis for caching/sessions
- Background jobs, webhook handlers
- Full migration history

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
@@ -0,0 +1,92 @@
using System.Text;
namespace Meezi.API.Services.Printing;
public class EscPosBuilder
{
private readonly List<byte> _buffer = [];
public EscPosBuilder Initialize()
{
_buffer.AddRange([0x1B, 0x40]);
return this;
}
public EscPosBuilder SetEncoding()
{
_buffer.AddRange([0x1B, 0x74, 0x25]);
return this;
}
public EscPosBuilder AlignCenter()
{
_buffer.AddRange([0x1B, 0x61, 0x01]);
return this;
}
public EscPosBuilder AlignRight()
{
_buffer.AddRange([0x1B, 0x61, 0x02]);
return this;
}
public EscPosBuilder AlignLeft()
{
_buffer.AddRange([0x1B, 0x61, 0x00]);
return this;
}
public EscPosBuilder Bold(bool on)
{
_buffer.AddRange([0x1B, 0x45, on ? (byte)1 : (byte)0]);
return this;
}
public EscPosBuilder DoubleHeight(bool on)
{
_buffer.AddRange([0x1B, 0x21, on ? (byte)0x10 : (byte)0x00]);
return this;
}
public EscPosBuilder Text(string text)
{
_buffer.AddRange(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, Math.Min(width, 64)));
}
public EscPosBuilder TwoColumns(string left, string right, int totalWidth = 48)
{
var safeLeft = left.Length > totalWidth ? left[..totalWidth] : left;
var safeRight = right.Length > totalWidth ? right[^totalWidth..] : right;
var pad = Math.Max(1, totalWidth - safeLeft.Length - safeRight.Length);
var line = safeLeft + new string(' ', pad) + safeRight;
if (line.Length > totalWidth)
line = line[..totalWidth];
return Line(line);
}
public EscPosBuilder Feed(int lines = 3)
{
for (var i = 0; i < lines; i++)
_buffer.Add(0x0A);
return this;
}
public EscPosBuilder Cut()
{
_buffer.AddRange([0x1D, 0x56, 0x42, 0x03]);
return this;
}
public byte[] Build() => [.. _buffer];
}
@@ -0,0 +1,256 @@
using System.Net.Sockets;
using Meezi.API.Models.Orders;
using Meezi.API.Models.Printing;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Services.Printing;
public record PrintResult(bool Success, string? ErrorCode, string? ErrorDetail = null)
{
public static PrintResult Ok() => new(true, null);
public static PrintResult Fail(string code, string? detail = null) => new(false, code, detail);
}
public interface IPrinterService
{
Task<PrintResult> PrintReceiptAsync(string cafeId, string orderId, CancellationToken ct = default);
Task<PrintResult> PrintKitchenTicketAsync(
string cafeId,
string orderId,
CancellationToken ct = default);
Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default);
}
public class NetworkPrinterService : IPrinterService
{
private readonly AppDbContext _db;
private readonly IOrderService _orders;
private readonly ReceiptBuilder _receiptBuilder;
private readonly ILogger<NetworkPrinterService> _logger;
public NetworkPrinterService(
AppDbContext db,
IOrderService orders,
ReceiptBuilder receiptBuilder,
ILogger<NetworkPrinterService> logger)
{
_db = db;
_orders = orders;
_receiptBuilder = receiptBuilder;
_logger = logger;
}
public async Task<PrintResult> PrintReceiptAsync(string cafeId, string orderId, CancellationToken ct = default)
{
var ctx = await BuildContextAsync(cafeId, orderId, ct);
if (ctx is null)
return PrintResult.Fail("ORDER_NOT_FOUND");
if (string.IsNullOrWhiteSpace(ctx.Value.branch.ReceiptPrinterIp))
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
var bytes = _receiptBuilder.BuildReceipt(ctx.Value.printCtx);
return await SendToPrinterAsync(
ctx.Value.branch.ReceiptPrinterIp!,
ctx.Value.branch.ReceiptPrinterPort ?? 9100,
bytes,
ct);
}
public async Task<PrintResult> PrintKitchenTicketAsync(
string cafeId,
string orderId,
CancellationToken ct = default)
{
var ctx = await BuildContextAsync(cafeId, orderId, ct);
if (ctx is null)
return PrintResult.Fail("ORDER_NOT_FOUND");
var order = ctx.Value.printCtx.Order;
var activeItems = order.Items.Where(i => !i.IsVoided).ToList();
if (activeItems.Count == 0)
return PrintResult.Ok();
var menuItemIds = activeItems.Select(i => i.MenuItemId).Distinct().ToList();
var categoryStations = await (
from m in _db.MenuItems.AsNoTracking()
join c in _db.MenuCategories.AsNoTracking() on m.CategoryId equals c.Id
where menuItemIds.Contains(m.Id) && m.CafeId == cafeId
select new { m.Id, c.KitchenStationId }
).ToListAsync(ct);
var stationIds = categoryStations
.Select(x => x.KitchenStationId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct()
.ToList();
var stations = stationIds.Count == 0
? []
: await _db.KitchenStations
.AsNoTracking()
.Where(s => stationIds.Contains(s.Id) && s.CafeId == cafeId)
.ToListAsync(ct);
var groups = activeItems
.GroupBy(item =>
{
var cat = categoryStations.FirstOrDefault(c => c.Id == item.MenuItemId);
return cat?.KitchenStationId;
})
.ToList();
PrintResult? lastFail = null;
var anyPrinted = false;
foreach (var group in groups)
{
var station = string.IsNullOrEmpty(group.Key)
? null
: stations.FirstOrDefault(s => s.Id == group.Key);
string? ip;
int port;
string? stationLabel = null;
if (station is not null && !string.IsNullOrWhiteSpace(station.PrinterIp))
{
ip = station.PrinterIp;
port = station.PrinterPort;
stationLabel = station.Name;
}
else if (!string.IsNullOrWhiteSpace(ctx.Value.branch.KitchenPrinterIp))
{
ip = ctx.Value.branch.KitchenPrinterIp;
port = ctx.Value.branch.KitchenPrinterPort ?? 9100;
}
else
{
lastFail = PrintResult.Fail("KITCHEN_PRINTER_NOT_CONFIGURED");
continue;
}
var itemsOnly = group.ToList();
var bytes = _receiptBuilder.BuildKitchenTicket(
ctx.Value.printCtx with { StationName = stationLabel },
itemsOnly);
var result = await SendToPrinterAsync(ip!, port, bytes, ct);
if (result.Success)
anyPrinted = true;
else
lastFail = result;
}
return anyPrinted ? PrintResult.Ok() : lastFail ?? PrintResult.Fail("KITCHEN_PRINTER_NOT_CONFIGURED");
}
public async Task<PrintResult> TestPrintAsync(string printerIp, int port, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(printerIp))
return PrintResult.Fail("PRINTER_NOT_CONFIGURED");
var bytes = _receiptBuilder.BuildTestPage();
return await SendToPrinterAsync(printerIp.Trim(), port, bytes, ct);
}
private async Task<(Branch branch, ReceiptPrintContext printCtx)?> BuildContextAsync(
string cafeId,
string orderId,
CancellationToken ct)
{
var order = await _orders.GetOrderAsync(cafeId, orderId, ct);
if (order is null || string.IsNullOrEmpty(order.BranchId))
return null;
var branch = await _db.Branches
.AsNoTracking()
.Include(b => b.Cafe)
.FirstOrDefaultAsync(b => b.Id == order.BranchId && b.CafeId == cafeId, ct);
if (branch is null)
return null;
var print = new ReceiptPrintContext(
order,
branch.Cafe.Name,
branch.Name,
branch.ReceiptHeader,
branch.ReceiptFooter,
branch.WifiPassword,
branch.PaperWidthMm is 58 or 80 ? branch.PaperWidthMm : 80,
branch.AutoCutEnabled);
return (branch, printCtx: print);
}
private async Task<PrintResult> SendToPrinterAsync(
string ip,
int port,
byte[] data,
CancellationToken ct)
{
try
{
using var client = new TcpClient();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
await client.ConnectAsync(ip, port, timeout.Token);
await using var stream = client.GetStream();
await stream.WriteAsync(data, timeout.Token);
await stream.FlushAsync(timeout.Token);
_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 static class PrinterBackgroundJobs
{
public static void QueueReceiptPrint(IServiceScopeFactory scopeFactory, string cafeId, string orderId)
{
_ = Task.Run(async () =>
{
await using var scope = scopeFactory.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<NetworkPrinterService>>();
try
{
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
var result = await printer.PrintReceiptAsync(cafeId, orderId, CancellationToken.None);
if (!result.Success)
logger.LogWarning("Auto-print receipt failed for {OrderId}: {Code}", orderId, result.ErrorCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Auto-print receipt failed for order {OrderId}", orderId);
}
});
}
public static void QueueKitchenPrint(IServiceScopeFactory scopeFactory, string cafeId, string orderId)
{
_ = Task.Run(async () =>
{
await using var scope = scopeFactory.CreateAsyncScope();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<NetworkPrinterService>>();
try
{
var printer = scope.ServiceProvider.GetRequiredService<IPrinterService>();
var result = await printer.PrintKitchenTicketAsync(cafeId, orderId, CancellationToken.None);
if (!result.Success)
logger.LogWarning("Kitchen print failed for {OrderId}: {Code}", orderId, result.ErrorCode);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Kitchen print failed for order {OrderId}", orderId);
}
});
}
}
@@ -0,0 +1,132 @@
using Meezi.API.Models.Orders;
namespace Meezi.API.Services.Printing;
public class ReceiptBuilder
{
public byte[] BuildReceipt(ReceiptPrintContext ctx)
{
var order = ctx.Order;
var width = ReceiptPrintFormatting.LineWidth(ctx.PaperWidthMm);
var b = new EscPosBuilder();
b.Initialize().SetEncoding();
b.AlignCenter()
.Bold(true)
.DoubleHeight(true)
.Line(ctx.CafeName)
.DoubleHeight(false)
.Bold(false)
.Line(ctx.BranchName);
if (!string.IsNullOrWhiteSpace(ctx.ReceiptHeader))
b.Line(ctx.ReceiptHeader.Trim());
var orderNo = ReceiptPrintFormatting.OrderNumberLabel(order.DisplayNumber);
b.AlignRight()
.Line($"شماره سفارش: {orderNo}")
.Line($"تاریخ: {ReceiptPrintFormatting.ToShamsi(order.CreatedAt)}")
.Line($"میز: {order.TableNumber ?? ""}");
if (!string.IsNullOrWhiteSpace(order.GuestName))
b.Line($"مهمان: {order.GuestName}");
else if (!string.IsNullOrWhiteSpace(order.CustomerName))
b.Line($"مشتری: {order.CustomerName}");
b.Separator(width).AlignRight();
foreach (var item in order.Items.Where(i => !i.IsVoided))
{
var itemTotal = ReceiptPrintFormatting.FormatCurrency(item.UnitPrice * item.Quantity);
var itemLine = $"{item.MenuItemName} × {item.Quantity}";
b.TwoColumns(itemLine, itemTotal, width);
}
b.Separator(width);
if (order.DiscountAmount > 0)
b.TwoColumns("تخفیف", ReceiptPrintFormatting.FormatCurrency(order.DiscountAmount), width);
if (order.TaxTotal > 0)
b.TwoColumns("مالیات", ReceiptPrintFormatting.FormatCurrency(order.TaxTotal), width);
b.Bold(true)
.TwoColumns("مجموع کل", ReceiptPrintFormatting.FormatCurrency(order.Total), width)
.Bold(false);
foreach (var payment in order.Payments)
{
var label = ReceiptPrintFormatting.PaymentMethodLabel(payment.Method);
b.TwoColumns(label, ReceiptPrintFormatting.FormatCurrency(payment.Amount), width);
}
b.Separator(width).AlignCenter();
if (!string.IsNullOrWhiteSpace(ctx.WifiPassword))
b.Line($"WiFi: {ctx.WifiPassword.Trim()}");
if (!string.IsNullOrWhiteSpace(ctx.ReceiptFooter))
b.Line(ctx.ReceiptFooter.Trim());
b.Line("ممنون از انتخاب شما");
b.Feed(3);
if (ctx.AutoCutEnabled)
b.Cut();
return b.Build();
}
public byte[] BuildKitchenTicket(ReceiptPrintContext ctx, IReadOnlyList<OrderItemDto>? itemsOnly = null)
{
var order = ctx.Order;
var items = itemsOnly ?? order.Items.Where(i => !i.IsVoided).ToList();
var width = ReceiptPrintFormatting.LineWidth(ctx.PaperWidthMm);
var b = new EscPosBuilder();
b.Initialize()
.SetEncoding()
.AlignCenter()
.Bold(true)
.DoubleHeight(true)
.Line(string.IsNullOrWhiteSpace(ctx.StationName) ? "آشپزخانه" : ctx.StationName!)
.DoubleHeight(false)
.Bold(false)
.AlignRight()
.Line($"میز: {order.TableNumber ?? ""} | #{ReceiptPrintFormatting.OrderNumberLabel(order.DisplayNumber)}")
.Line($"{DateTime.Now:HH:mm}")
.Separator(width);
foreach (var item in items)
{
b.Bold(true)
.Line($"× {item.Quantity} {item.MenuItemName}")
.Bold(false);
if (!string.IsNullOrWhiteSpace(item.Notes))
b.Line($" ← {item.Notes}");
}
b.Feed(4);
if (ctx.AutoCutEnabled)
b.Cut();
return b.Build();
}
public byte[] BuildTestPage()
{
var b = new EscPosBuilder();
b.Initialize()
.SetEncoding()
.AlignCenter()
.Bold(true)
.Line("Meezi Test Print ✓")
.Bold(false)
.Line(ReceiptPrintFormatting.ToShamsi(DateTime.Now))
.Feed(3)
.Cut();
return b.Build();
}
}
@@ -0,0 +1,61 @@
using Meezi.API.Models.Orders;
using Meezi.Core.Enums;
namespace Meezi.API.Services.Printing;
public record ReceiptPrintContext(
OrderDto Order,
string CafeName,
string BranchName,
string? ReceiptHeader,
string? ReceiptFooter,
string? WifiPassword,
int PaperWidthMm,
bool AutoCutEnabled,
string? StationName = null);
public static class ReceiptPrintFormatting
{
public static int LineWidth(int paperWidthMm) => paperWidthMm == 58 ? 32 : 48;
public static string FormatCurrency(decimal amount) =>
$"{amount:N0} ت";
public static string ToShamsi(DateTime dt)
{
var pc = new System.Globalization.PersianCalendar();
return $"{pc.GetYear(dt)}/{pc.GetMonth(dt):D2}/{pc.GetDayOfMonth(dt):D2} {dt:HH:mm}";
}
public static string OrderNumberLabel(int displayNumber) =>
displayNumber > 0 ? displayNumber.ToString() : "0";
public static string OrderNumberLabel(string orderId) =>
OrderNumberLabel(StableDisplayNumberFromId(orderId));
public static int StableDisplayNumberFromId(string orderId)
{
if (string.IsNullOrWhiteSpace(orderId)) return 0;
var hex = orderId.Replace("-", "", StringComparison.Ordinal);
if (hex.Length == 32
&& ulong.TryParse(hex.AsSpan(0, 16), System.Globalization.NumberStyles.HexNumber, null, out var hi)
&& ulong.TryParse(hex.AsSpan(16, 16), System.Globalization.NumberStyles.HexNumber, null, out var lo))
{
return (int)((hi ^ lo) % 9_999_999) + 1;
}
var digits = new string(orderId.Where(char.IsDigit).ToArray());
if (digits.Length > 0 && int.TryParse(digits.Length > 9 ? digits[^9..] : digits, out var parsed))
return parsed;
return (int)((uint)Math.Abs(StringComparer.Ordinal.GetHashCode(hex)) % 999_999) + 1;
}
public static string PaymentMethodLabel(PaymentMethod method) => method switch
{
PaymentMethod.Cash => "نقد",
PaymentMethod.Card => "کارت",
PaymentMethod.Credit => "اعتبار",
_ => method.ToString()
};
}