Files
meezi/src/Meezi.API/Services/TableService.cs
T
soroush.asadi ef15fd6247 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>
2026-05-27 21:33:48 +03:30

1256 lines
25 KiB
C#

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<IReadOnlyList<TableBoardDto>> GetTableBoardAsync(
string cafeId,
bool activeOnly = true,
string? branchId = null,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<TableDto>> GetTablesAsync(
string cafeId,
string? branchId = null,
CancellationToken cancellationToken = default);
Task<TableDto?> CreateTableAsync(string cafeId, CreateTableRequest request, CancellationToken cancellationToken = default);
Task<TableDto?> PatchTableAsync(string cafeId, string tableId, PatchTableRequest request, CancellationToken cancellationToken = default);
Task<TableBoardDto?> SetTableCleaningAsync(
string cafeId,
string tableId,
bool isCleaning,
CancellationToken cancellationToken = default);
Task<BranchTableOperationResult<object>> DeleteTableAsync(
string cafeId,
string tableId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<TableBoardDto>> GetBranchTableBoardAsync(
string cafeId,
string branchId,
bool activeOnly = true,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<TableDto>?> GetBranchTablesAsync(
string cafeId,
string branchId,
CancellationToken cancellationToken = default);
Task<BranchTableOperationResult<TableDto>> CreateBranchTableAsync(
string cafeId,
string branchId,
CreateBranchTableRequest request,
CancellationToken cancellationToken = default);
Task<BranchTableOperationResult<TableDto>> PatchBranchTableAsync(
string cafeId,
string branchId,
string tableId,
PatchBranchTableRequest request,
CancellationToken cancellationToken = default);
Task<BranchTableOperationResult<object>> DeleteBranchTableAsync(
string cafeId,
string branchId,
string tableId,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<TableSectionDto>?> GetBranchSectionsAsync(
string cafeId,
string branchId,
CancellationToken cancellationToken = default);
Task<BranchTableOperationResult<TableSectionDto>> CreateBranchSectionAsync(
string cafeId,
string branchId,
CreateTableSectionRequest request,
CancellationToken cancellationToken = default);
Task<BranchTableOperationResult<TableSectionDto>> PatchBranchSectionAsync(
string cafeId,
string branchId,
string sectionId,
PatchTableSectionRequest request,
CancellationToken cancellationToken = default);
Task<BranchTableOperationResult<object>> DeleteBranchSectionAsync(
string cafeId,
string branchId,
string sectionId,
CancellationToken cancellationToken = default);
Task<bool> CanAccessBranchAsync(
string cafeId,
string branchId,
string? userId,
EmployeeRole? role,
CancellationToken cancellationToken = default);
Task<QrResolveResponse?> ResolveQrAsync(string qrCode, CancellationToken cancellationToken = default);
Task<byte[]?> 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<IReadOnlyList<TableBoardDto>> 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<IReadOnlyList<TableBoardDto>> GetBranchTableBoardAsync(
string cafeId,
string branchId,
bool activeOnly = true,
CancellationToken cancellationToken = default) =>
GetTableBoardAsync(cafeId, activeOnly, branchId, cancellationToken);
public async Task<IReadOnlyList<TableDto>> 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<IReadOnlyList<TableDto>?> 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<TableDto?> 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<BranchTableOperationResult<TableDto>> CreateBranchTableAsync(
string cafeId,
string branchId,
CreateBranchTableRequest request,
CancellationToken cancellationToken = default)
{
if (!await BranchExistsAsync(cafeId, branchId, cancellationToken))
return Fail<TableDto>("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<TableDto>("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<TableDto?> 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<BranchTableOperationResult<TableDto>> 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<TableDto>("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<TableDto>("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<BranchTableOperationResult<object>> 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<object>("TABLE_NOT_FOUND", "Table not found.");
return await DeleteBranchTableAsync(cafeId, branchId, tableId, cancellationToken);
}
public async Task<BranchTableOperationResult<object>> 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<object>("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<object>("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<object>(new { tableId });
}
public async Task<IReadOnlyList<TableSectionDto>?> 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<BranchTableOperationResult<TableSectionDto>> CreateBranchSectionAsync(
string cafeId,
string branchId,
CreateTableSectionRequest request,
CancellationToken cancellationToken = default)
{
if (!await BranchExistsAsync(cafeId, branchId, cancellationToken))
return Fail<TableSectionDto>("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<BranchTableOperationResult<TableSectionDto>> 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<TableSectionDto>("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<BranchTableOperationResult<object>> 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<object>("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<object>("TABLE_SECTION_HAS_TABLES", "Section has tables assigned.");
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return Ok<object>(new { sectionId });
}
public async Task<bool> 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<TableBoardDto?> 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<QrResolveResponse?> 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<byte[]?> 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<T> Ok<T>(T data) =>
new(true, data, null, null);
private static BranchTableOperationResult<T> Fail<T>(string code, string message) =>
new(false, default, code, message);
private async Task<bool> BranchExistsAsync(string cafeId, string branchId, CancellationToken ct) =>
await _db.Branches.AnyAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
private async Task<string?> 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<List<Table>> LoadTablesOrderedAsync(
IQueryable<Table> 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<IReadOnlyList<TableBoardDto>> BuildBoardDtosAsync(
string cafeId,
List<Table> 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}";
}
}