ef15fd6247
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>
221 lines
7.2 KiB
C#
221 lines
7.2 KiB
C#
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);
|
|
}
|