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 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 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 FilterToday( IQueryable 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); }