using Meezi.API.Models.Tables; using Meezi.Core.Entities; using Meezi.Core.Enums; using Meezi.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using QRCoder; namespace Meezi.API.Services; public interface ITableService { Task> GetTableBoardAsync( string cafeId, bool activeOnly = true, string? branchId = null, CancellationToken cancellationToken = default); Task> GetTablesAsync( string cafeId, string? branchId = null, CancellationToken cancellationToken = default); Task CreateTableAsync(string cafeId, CreateTableRequest request, CancellationToken cancellationToken = default); Task PatchTableAsync(string cafeId, string tableId, PatchTableRequest request, CancellationToken cancellationToken = default); Task SetTableCleaningAsync( string cafeId, string tableId, bool isCleaning, CancellationToken cancellationToken = default); Task> DeleteTableAsync( string cafeId, string tableId, CancellationToken cancellationToken = default); Task> GetBranchTableBoardAsync( string cafeId, string branchId, bool activeOnly = true, CancellationToken cancellationToken = default); Task?> GetBranchTablesAsync( string cafeId, string branchId, CancellationToken cancellationToken = default); Task> CreateBranchTableAsync( string cafeId, string branchId, CreateBranchTableRequest request, CancellationToken cancellationToken = default); Task> PatchBranchTableAsync( string cafeId, string branchId, string tableId, PatchBranchTableRequest request, CancellationToken cancellationToken = default); Task> DeleteBranchTableAsync( string cafeId, string branchId, string tableId, CancellationToken cancellationToken = default); Task?> GetBranchSectionsAsync( string cafeId, string branchId, CancellationToken cancellationToken = default); Task> CreateBranchSectionAsync( string cafeId, string branchId, CreateTableSectionRequest request, CancellationToken cancellationToken = default); Task> PatchBranchSectionAsync( string cafeId, string branchId, string sectionId, PatchTableSectionRequest request, CancellationToken cancellationToken = default); Task> DeleteBranchSectionAsync( string cafeId, string branchId, string sectionId, CancellationToken cancellationToken = default); Task CanAccessBranchAsync( string cafeId, string branchId, string? userId, EmployeeRole? role, CancellationToken cancellationToken = default); Task ResolveQrAsync(string qrCode, CancellationToken cancellationToken = default); Task GetQrPngAsync(string cafeId, string tableId, CancellationToken cancellationToken = default); } public class TableService : ITableService { private static readonly OrderStatus[] OpenOrderStatuses = [ OrderStatus.Pending, OrderStatus.Confirmed, OrderStatus.Preparing, OrderStatus.Ready ]; private readonly AppDbContext _db; private readonly IConfiguration _configuration; private readonly IKdsNotifier _kdsNotifier; private readonly IBranchIdentityService _identity; public TableService( AppDbContext db, IConfiguration configuration, IKdsNotifier kdsNotifier, IBranchIdentityService identity) { _db = db; _configuration = configuration; _kdsNotifier = kdsNotifier; _identity = identity; } public async Task> GetTableBoardAsync( string cafeId, bool activeOnly = true, string? branchId = null, CancellationToken cancellationToken = default) { var query = _db.Tables.Where(t => t.CafeId == cafeId); if (!string.IsNullOrEmpty(branchId)) query = query.Where(t => t.BranchId == branchId); if (activeOnly) query = query.Where(t => t.IsActive); var tables = await LoadTablesOrderedAsync(query, cancellationToken); return await BuildBoardDtosAsync(cafeId, tables, cancellationToken); } public Task> GetBranchTableBoardAsync( string cafeId, string branchId, bool activeOnly = true, CancellationToken cancellationToken = default) => GetTableBoardAsync(cafeId, activeOnly, branchId, cancellationToken); public async Task> GetTablesAsync( string cafeId, string? branchId = null, CancellationToken cancellationToken = default) { var query = _db.Tables.Where(t => t.CafeId == cafeId && t.IsActive); if (!string.IsNullOrEmpty(branchId)) query = query.Where(t => t.BranchId == branchId); var tables = await LoadTablesOrderedAsync(query, cancellationToken); return tables.Select(ToDto).ToList(); } public async Task?> GetBranchTablesAsync( string cafeId, string branchId, CancellationToken cancellationToken = default) { if (!await BranchExistsAsync(cafeId, branchId, cancellationToken)) return null; return await GetTablesAsync(cafeId, branchId, cancellationToken); } public async Task CreateTableAsync( string cafeId, CreateTableRequest request, CancellationToken cancellationToken = default) { var branchId = request.BranchId; if (string.IsNullOrEmpty(branchId)) branchId = await GetDefaultBranchIdAsync(cafeId, cancellationToken); if (branchId is null) return null; if (!await BranchExistsAsync(cafeId, branchId, cancellationToken)) return null; var entity = new Table { CafeId = cafeId, BranchId = branchId, Number = request.Number.Trim(), Capacity = request.Capacity, Floor = request.Floor?.Trim(), QrCode = Guid.NewGuid().ToString("N"), IsActive = request.IsActive }; _db.Tables.Add(entity); await _db.SaveChangesAsync(cancellationToken); return ToDto(entity); } public async Task> CreateBranchTableAsync( string cafeId, string branchId, CreateBranchTableRequest request, CancellationToken cancellationToken = default) { if (!await BranchExistsAsync(cafeId, branchId, cancellationToken)) return Fail("BRANCH_NOT_FOUND", "Branch not found."); if (!string.IsNullOrEmpty(request.SectionId)) { var sectionOk = await _db.TableSections.AnyAsync( s => s.Id == request.SectionId && s.CafeId == cafeId && s.BranchId == branchId, cancellationToken); if (!sectionOk) return Fail("SECTION_NOT_FOUND", "Section not found."); } var entity = new Table { CafeId = cafeId, BranchId = branchId, SectionId = request.SectionId, Number = request.Number.Trim(), Capacity = request.Capacity, Floor = request.Floor?.Trim(), SortOrder = request.SortOrder, QrCode = Guid.NewGuid().ToString("N"), IsActive = request.IsActive }; _db.Tables.Add(entity); await _db.SaveChangesAsync(cancellationToken); await _db.Entry(entity).Reference(t => t.Section).LoadAsync(cancellationToken); return Ok(ToDto(entity)); } public async Task PatchTableAsync( string cafeId, string tableId, PatchTableRequest request, CancellationToken cancellationToken = default) { var entity = await _db.Tables .Include(t => t.Section) .FirstOrDefaultAsync(t => t.Id == tableId && t.CafeId == cafeId, cancellationToken); if (entity is null) return null; if (request.Number is not null) entity.Number = request.Number.Trim(); if (request.Capacity.HasValue) entity.Capacity = request.Capacity.Value; if (request.Floor is not null) entity.Floor = request.Floor.Trim(); if (request.BranchId is not null && !string.IsNullOrEmpty(request.BranchId)) { if (!await BranchExistsAsync(cafeId, request.BranchId, cancellationToken)) return null; entity.BranchId = request.BranchId; } if (request.ImageUrl is not null) entity.ImageUrl = string.IsNullOrWhiteSpace(request.ImageUrl) ? null : request.ImageUrl; if (request.VideoUrl is not null) entity.VideoUrl = string.IsNullOrWhiteSpace(request.VideoUrl) ? null : request.VideoUrl; if (request.IsActive.HasValue) entity.IsActive = request.IsActive.Value; if (request.IsCleaning.HasValue) entity.IsCleaning = request.IsCleaning.Value; await _db.SaveChangesAsync(cancellationToken); return ToDto(entity); } public async Task> PatchBranchTableAsync( string cafeId, string branchId, string tableId, PatchBranchTableRequest request, CancellationToken cancellationToken = default) { var entity = await _db.Tables .Include(t => t.Section) .FirstOrDefaultAsync( t => t.Id == tableId && t.CafeId == cafeId && t.BranchId == branchId, cancellationToken); if (entity is null) return Fail("TABLE_NOT_FOUND", "Table not found."); if (request.SectionId is not null) { if (string.IsNullOrEmpty(request.SectionId)) entity.SectionId = null; else { var sectionOk = await _db.TableSections.AnyAsync( s => s.Id == request.SectionId && s.CafeId == cafeId && s.BranchId == branchId, cancellationToken); if (!sectionOk) return Fail("SECTION_NOT_FOUND", "Section not found."); entity.SectionId = request.SectionId; } } if (request.Number is not null) entity.Number = request.Number.Trim(); if (request.Capacity.HasValue) entity.Capacity = request.Capacity.Value; if (request.Floor is not null) entity.Floor = request.Floor.Trim(); if (request.SortOrder.HasValue) entity.SortOrder = request.SortOrder.Value; if (request.ImageUrl is not null) entity.ImageUrl = string.IsNullOrWhiteSpace(request.ImageUrl) ? null : request.ImageUrl; if (request.VideoUrl is not null) entity.VideoUrl = string.IsNullOrWhiteSpace(request.VideoUrl) ? null : request.VideoUrl; if (request.IsActive.HasValue) entity.IsActive = request.IsActive.Value; if (request.IsCleaning.HasValue) entity.IsCleaning = request.IsCleaning.Value; await _db.SaveChangesAsync(cancellationToken); await _db.Entry(entity).Reference(t => t.Section).LoadAsync(cancellationToken); return Ok(ToDto(entity)); } public async Task> DeleteTableAsync( string cafeId, string tableId, CancellationToken cancellationToken = default) { var branchId = await _db.Tables .Where(t => t.Id == tableId && t.CafeId == cafeId) .Select(t => t.BranchId) .FirstOrDefaultAsync(cancellationToken); if (string.IsNullOrEmpty(branchId)) return Fail("TABLE_NOT_FOUND", "Table not found."); return await DeleteBranchTableAsync(cafeId, branchId, tableId, cancellationToken); } public async Task> DeleteBranchTableAsync( string cafeId, string branchId, string tableId, CancellationToken cancellationToken = default) { var entity = await _db.Tables.FirstOrDefaultAsync( t => t.Id == tableId && t.CafeId == cafeId && t.BranchId == branchId, cancellationToken); if (entity is null) return Fail("TABLE_NOT_FOUND", "Table not found."); var hasOpenOrder = await _db.Orders.AnyAsync( o => o.CafeId == cafeId && o.TableId == tableId && OpenOrderStatuses.Contains(o.Status), cancellationToken); if (hasOpenOrder) return Fail("TABLE_HAS_OPEN_ORDER", "This table has an open order."); entity.DeletedAt = DateTime.UtcNow; entity.IsActive = false; entity.IsCleaning = false; await _db.SaveChangesAsync(cancellationToken); await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); return Ok(new { tableId }); } public async Task?> GetBranchSectionsAsync( string cafeId, string branchId, CancellationToken cancellationToken = default) { if (!await BranchExistsAsync(cafeId, branchId, cancellationToken)) return null; var sections = await _db.TableSections .Where(s => s.CafeId == cafeId && s.BranchId == branchId) .OrderBy(s => s.SortOrder) .ThenBy(s => s.Name) .ToListAsync(cancellationToken); var tableCounts = await _db.Tables .Where(t => t.CafeId == cafeId && t.BranchId == branchId && t.SectionId != null) .GroupBy(t => t.SectionId!) .Select(g => new { SectionId = g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.SectionId, x => x.Count, cancellationToken); return sections .Select(s => new TableSectionDto( s.Id, s.BranchId, s.Name, s.SortOrder, s.IsActive, tableCounts.GetValueOrDefault(s.Id))) .ToList(); } public async Task> CreateBranchSectionAsync( string cafeId, string branchId, CreateTableSectionRequest request, CancellationToken cancellationToken = default) { if (!await BranchExistsAsync(cafeId, branchId, cancellationToken)) return Fail("BRANCH_NOT_FOUND", "Branch not found."); var entity = new TableSection { CafeId = cafeId, BranchId = branchId, Name = request.Name.Trim(), SortOrder = request.SortOrder }; _db.TableSections.Add(entity); await _db.SaveChangesAsync(cancellationToken); return Ok(new TableSectionDto(entity.Id, entity.BranchId, entity.Name, entity.SortOrder, entity.IsActive, 0)); } public async Task> PatchBranchSectionAsync( string cafeId, string branchId, string sectionId, PatchTableSectionRequest request, CancellationToken cancellationToken = default) { var entity = await _db.TableSections.FirstOrDefaultAsync( s => s.Id == sectionId && s.CafeId == cafeId && s.BranchId == branchId, cancellationToken); if (entity is null) return Fail("SECTION_NOT_FOUND", "Section not found."); if (request.Name is not null) entity.Name = request.Name.Trim(); if (request.SortOrder.HasValue) entity.SortOrder = request.SortOrder.Value; if (request.IsActive.HasValue) entity.IsActive = request.IsActive.Value; await _db.SaveChangesAsync(cancellationToken); var tableCount = await _db.Tables.CountAsync( t => t.SectionId == sectionId && t.CafeId == cafeId, cancellationToken); return Ok(new TableSectionDto(entity.Id, entity.BranchId, entity.Name, entity.SortOrder, entity.IsActive, tableCount)); } public async Task> DeleteBranchSectionAsync( string cafeId, string branchId, string sectionId, CancellationToken cancellationToken = default) { var entity = await _db.TableSections.FirstOrDefaultAsync( s => s.Id == sectionId && s.CafeId == cafeId && s.BranchId == branchId, cancellationToken); if (entity is null) return Fail("SECTION_NOT_FOUND", "Section not found."); var hasTables = await _db.Tables.AnyAsync( t => t.SectionId == sectionId && t.CafeId == cafeId && t.BranchId == branchId, cancellationToken); if (hasTables) return Fail("TABLE_SECTION_HAS_TABLES", "Section has tables assigned."); entity.DeletedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); return Ok(new { sectionId }); } public async Task CanAccessBranchAsync( string cafeId, string branchId, string? userId, EmployeeRole? role, CancellationToken cancellationToken = default) { if (!await BranchExistsAsync(cafeId, branchId, cancellationToken)) return false; if (role is not EmployeeRole.Manager) return true; if (string.IsNullOrEmpty(userId)) return false; var employee = await _db.Employees.FirstOrDefaultAsync( e => e.Id == userId && e.CafeId == cafeId, cancellationToken); return employee?.BranchId == branchId; } public async Task SetTableCleaningAsync( string cafeId, string tableId, bool isCleaning, CancellationToken cancellationToken = default) { var entity = await _db.Tables.FirstOrDefaultAsync(t => t.Id == tableId && t.CafeId == cafeId, cancellationToken); if (entity is null) return null; entity.IsCleaning = isCleaning; await _db.SaveChangesAsync(cancellationToken); await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken); var board = await GetTableBoardAsync(cafeId, activeOnly: false, branchId: entity.BranchId, cancellationToken); return board.FirstOrDefault(t => t.Id == tableId); } public async Task ResolveQrAsync(string qrCode, CancellationToken cancellationToken = default) { var table = await _db.Tables .Include(t => t.Cafe) .Include(t => t.Branch) .FirstOrDefaultAsync( t => t.QrCode == qrCode && t.IsActive && t.DeletedAt == null, cancellationToken); if (table?.Cafe is null || table.Branch is null || !table.Branch.IsActive) return null; var identity = await _identity.GetEffectiveIdentityAsync( table.CafeId, table.BranchId, cancellationToken); return new QrResolveResponse( table.CafeId, table.Cafe.Slug, table.Id, table.Number, table.Number, table.BranchId, table.Branch.Name, table.Cafe.Name, identity?.PrimaryColor ?? "#0F6E56", identity?.LogoUrl ?? table.Cafe.LogoUrl, identity?.WelcomeText ?? "خوش آمدید", identity?.WifiPassword, identity?.Address, table.IsCleaning); } public async Task GetQrPngAsync(string cafeId, string tableId, CancellationToken cancellationToken = default) { var table = await _db.Tables .FirstOrDefaultAsync(t => t.Id == tableId && t.CafeId == cafeId, cancellationToken); if (table is null) return null; var url = BuildPublicQrUrl(table.QrCode); using var generator = new QRCodeGenerator(); using var data = generator.CreateQrCode(url, QRCodeGenerator.ECCLevel.Q); var png = new PngByteQRCode(data); return png.GetGraphic(20); } private static BranchTableOperationResult Ok(T data) => new(true, data, null, null); private static BranchTableOperationResult Fail(string code, string message) => new(false, default, code, message); private async Task BranchExistsAsync(string cafeId, string branchId, CancellationToken ct) => await _db.Branches.AnyAsync(b => b.Id == branchId && b.CafeId == cafeId, ct); private async Task GetDefaultBranchIdAsync(string cafeId, CancellationToken ct) => await _db.Branches .Where(b => b.CafeId == cafeId && b.IsActive) .OrderBy(b => b.CreatedAt) .Select(b => b.Id) .FirstOrDefaultAsync(ct); private static async Task> LoadTablesOrderedAsync( IQueryable query, CancellationToken ct) { var tables = await query.Include(t => t.Section).ToListAsync(ct); return tables .OrderBy(t => t.Section?.SortOrder ?? int.MaxValue) .ThenBy(t => t.SortOrder) .ThenBy(t => t.Number, StringComparer.Ordinal) .ToList(); } private async Task> BuildBoardDtosAsync( string cafeId, List
tables, CancellationToken cancellationToken) { if (tables.Count == 0) return []; var tableIds = tables.Select(t => t.Id).ToList(); var today = DateOnly.FromDateTime(DateTime.UtcNow); var openOrders = await _db.Orders .Include(o => o.Customer) .Where(o => o.CafeId == cafeId && o.TableId != null && tableIds.Contains(o.TableId) && OpenOrderStatuses.Contains(o.Status)) .ToListAsync(cancellationToken); var reservations = await _db.TableReservations .Where(r => r.CafeId == cafeId && r.TableId != null && tableIds.Contains(r.TableId!) && r.Date == today && r.Status != ReservationStatus.Cancelled) .ToListAsync(cancellationToken); return tables.Select(t => { var order = openOrders.FirstOrDefault(o => o.TableId == t.Id); var reservation = reservations.FirstOrDefault(r => r.TableId == t.Id && order is null); var status = t.IsCleaning ? TableBoardStatus.Cleaning : order is not null ? TableBoardStatus.Busy : reservation is not null ? TableBoardStatus.Reserved : TableBoardStatus.Free; TableCurrentOrderSummary? current = null; if (order is not null) { var guestLabel = !string.IsNullOrWhiteSpace(order.GuestName) ? order.GuestName : order.Customer?.Name; current = new TableCurrentOrderSummary( order.Id, order.Status, order.Total, guestLabel, order.Source); } else if (reservation is not null) { current = new TableCurrentOrderSummary( reservation.Id, OrderStatus.Pending, 0, reservation.GuestName); } return new TableBoardDto( t.Id, t.BranchId, t.SectionId, t.Section?.Name, t.SortOrder, t.Number, t.Capacity, t.Floor, t.QrCode, BuildPublicQrUrl(t.QrCode), t.ImageUrl, t.VideoUrl, t.IsActive, status, current, t.IsCleaning); }).ToList(); } private TableDto ToDto(Table t) => new( t.Id, t.BranchId, t.SectionId, t.Section?.Name, t.SortOrder, t.Number, t.Capacity, t.Floor, t.QrCode, BuildPublicQrUrl(t.QrCode), t.ImageUrl, t.VideoUrl, t.IsActive); private string BuildPublicQrUrl(string qrCode) { var baseUrl = _configuration["App:QrPublicBaseUrl"]?.TrimEnd('/') ?? "https://meezi.ir"; return $"{baseUrl}/q/{qrCode}"; } }