Files
meezi/src/Meezi.API/Services/QueueService.cs
T

221 lines
7.2 KiB
C#
Raw Normal View History

2026-05-27 21:33:48 +03:30
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);
}