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:
@@ -0,0 +1,220 @@
|
||||
using Meezi.API.Models.Queue;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public interface IQueueService
|
||||
{
|
||||
Task<QueueBoardDto> GetTodayBoardAsync(string cafeId, string? branchId, CancellationToken ct = default);
|
||||
Task<(QueueTicketDto? Ticket, string? ErrorCode, string? Message)> IssuePublicAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
IssueQueueTicketRequest request,
|
||||
CancellationToken ct = default);
|
||||
Task<(QueueTicketDto? Ticket, string? Error)> IssueNextAsync(
|
||||
string cafeId,
|
||||
string? userId,
|
||||
IssueQueueTicketRequest request,
|
||||
CancellationToken ct = default);
|
||||
Task<(QueueTicketDto? Ticket, string? Error)> UpdateStatusAsync(
|
||||
string cafeId,
|
||||
string ticketId,
|
||||
QueueTicketStatus status,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class QueueService : IQueueService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
private readonly ISmsService _sms;
|
||||
|
||||
public QueueService(AppDbContext db, IPlatformCatalogService catalog, ISmsService sms)
|
||||
{
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
_sms = sms;
|
||||
}
|
||||
|
||||
public async Task<(QueueTicketDto? Ticket, string? ErrorCode, string? Message)> IssuePublicAsync(
|
||||
string cafeId,
|
||||
PlanTier planTier,
|
||||
IssueQueueTicketRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, planTier, "queue", ct))
|
||||
return (null, "PLAN_LIMIT_REACHED", "Queue is not available on your plan.");
|
||||
|
||||
var (ticket, error) = await IssueNextAsync(cafeId, null, request, ct);
|
||||
if (error is not null)
|
||||
return (null, error, error switch
|
||||
{
|
||||
"BRANCH_NOT_FOUND" => "Branch not found.",
|
||||
"ORDER_NOT_FOUND" => "Order not found.",
|
||||
_ => "Could not issue ticket."
|
||||
});
|
||||
|
||||
return (ticket, null, null);
|
||||
}
|
||||
|
||||
public async Task<QueueBoardDto> GetTodayBoardAsync(
|
||||
string cafeId,
|
||||
string? branchId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var today = IranCalendar.TodayInIran;
|
||||
var tickets = await FilterToday(_db.QueueTickets, cafeId, branchId, today)
|
||||
.OrderBy(q => q.Number)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var dtos = tickets.Select(Map).ToList();
|
||||
var waiting = tickets.Where(t => t.Status == QueueTicketStatus.Waiting).ToList();
|
||||
var nowServing = tickets
|
||||
.Where(t => t.Status == QueueTicketStatus.Called)
|
||||
.OrderByDescending(t => t.IssuedAt)
|
||||
.Select(t => (int?)t.Number)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (nowServing is null && waiting.Count > 0)
|
||||
nowServing = waiting[0].Number;
|
||||
|
||||
var lastIssued = tickets.Count > 0 ? tickets.Max(t => t.Number) : 0;
|
||||
|
||||
return new QueueBoardDto(
|
||||
today,
|
||||
nowServing,
|
||||
lastIssued,
|
||||
waiting.Count,
|
||||
dtos);
|
||||
}
|
||||
|
||||
public async Task<(QueueTicketDto? Ticket, string? Error)> IssueNextAsync(
|
||||
string cafeId,
|
||||
string? userId,
|
||||
IssueQueueTicketRequest request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(request.BranchId))
|
||||
{
|
||||
var branchOk = await _db.Branches.AnyAsync(
|
||||
b => b.Id == request.BranchId && b.CafeId == cafeId,
|
||||
ct);
|
||||
if (!branchOk) return (null, "BRANCH_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(request.OrderId))
|
||||
{
|
||||
var orderOk = await _db.Orders.AnyAsync(
|
||||
o => o.Id == request.OrderId && o.CafeId == cafeId,
|
||||
ct);
|
||||
if (!orderOk) return (null, "ORDER_NOT_FOUND");
|
||||
}
|
||||
|
||||
var today = IranCalendar.TodayInIran;
|
||||
var branchId = string.IsNullOrEmpty(request.BranchId) ? null : request.BranchId;
|
||||
|
||||
var maxNumber = await FilterToday(_db.QueueTickets, cafeId, branchId, today)
|
||||
.MaxAsync(q => (int?)q.Number, ct) ?? 0;
|
||||
|
||||
var entity = new QueueTicket
|
||||
{
|
||||
Id = $"qt_{Guid.NewGuid():N}"[..24],
|
||||
CafeId = cafeId,
|
||||
BranchId = branchId,
|
||||
ServiceDate = today,
|
||||
Number = maxNumber + 1,
|
||||
CustomerLabel = string.IsNullOrWhiteSpace(request.CustomerLabel)
|
||||
? null
|
||||
: request.CustomerLabel.Trim(),
|
||||
IssuedByUserId = userId,
|
||||
OrderId = request.OrderId,
|
||||
Status = QueueTicketStatus.Waiting,
|
||||
IssuedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.QueueTickets.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return (Map(entity), null);
|
||||
}
|
||||
|
||||
public async Task<(QueueTicketDto? Ticket, string? Error)> UpdateStatusAsync(
|
||||
string cafeId,
|
||||
string ticketId,
|
||||
QueueTicketStatus status,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var ticket = await _db.QueueTickets.FirstOrDefaultAsync(
|
||||
q => q.Id == ticketId && q.CafeId == cafeId,
|
||||
ct);
|
||||
if (ticket is null) return (null, "NOT_FOUND");
|
||||
if (ticket.ServiceDate != IranCalendar.TodayInIran)
|
||||
return (null, "TICKET_EXPIRED");
|
||||
|
||||
ticket.Status = status;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
if (status == QueueTicketStatus.Called && !string.IsNullOrWhiteSpace(ticket.CustomerLabel))
|
||||
{
|
||||
var cafe = await _db.Cafes.AsNoTracking()
|
||||
.Where(c => c.Id == cafeId)
|
||||
.Select(c => new { c.Name })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (cafe is not null)
|
||||
{
|
||||
var phone = ExtractPhone(ticket.CustomerLabel);
|
||||
if (phone is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sms.SendMessageAsync(
|
||||
phone,
|
||||
$"{cafe.Name}: نوبت شما فرا رسید — شماره {ticket.Number}",
|
||||
ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* SMS optional */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (Map(ticket), null);
|
||||
}
|
||||
|
||||
private static string? ExtractPhone(string label)
|
||||
{
|
||||
var digits = new string(label.Where(char.IsDigit).ToArray());
|
||||
if (digits.Length >= 10 && digits.StartsWith("09", StringComparison.Ordinal))
|
||||
return digits.Length > 11 ? digits[^11..] : digits;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IQueryable<QueueTicket> FilterToday(
|
||||
IQueryable<QueueTicket> source,
|
||||
string cafeId,
|
||||
string? branchId,
|
||||
DateOnly today)
|
||||
{
|
||||
var query = source.Where(q => q.CafeId == cafeId && q.ServiceDate == today);
|
||||
return string.IsNullOrEmpty(branchId)
|
||||
? query
|
||||
: query.Where(q => q.BranchId == branchId);
|
||||
}
|
||||
|
||||
private static QueueTicketDto Map(QueueTicket q) =>
|
||||
new(
|
||||
q.Id,
|
||||
q.BranchId,
|
||||
q.ServiceDate,
|
||||
q.Number,
|
||||
q.CustomerLabel,
|
||||
q.OrderId,
|
||||
q.Status,
|
||||
q.IssuedAt);
|
||||
}
|
||||
Reference in New Issue
Block a user