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>
This commit is contained in:
soroush.asadi
2026-05-27 21:33:48 +03:30
parent 03376b3ea1
commit ef15fd6247
472 changed files with 120358 additions and 0 deletions
+550
View File
@@ -0,0 +1,550 @@
using Microsoft.AspNetCore.SignalR;
using Meezi.API.Hubs;
using Meezi.API.Models.Notifications;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Meezi.API.Services;
public record IngredientDto(
string Id,
string Name,
string Unit,
decimal QuantityOnHand,
decimal ReorderLevel,
decimal UnitCost,
decimal ParLevel,
decimal LowStockWarningPercent,
decimal WarningThreshold,
decimal StockValueToman,
bool IsLowStock);
public record CreateIngredientRequest(
string Name,
string Unit,
decimal QuantityOnHand,
decimal ReorderLevel,
decimal UnitCost,
decimal ParLevel,
decimal LowStockWarningPercent,
decimal? TotalPaidToman = null,
string? BranchId = null);
public record UpdateIngredientRequest(
string? Name,
string? Unit,
decimal? ReorderLevel,
decimal? UnitCost,
decimal? ParLevel,
decimal? LowStockWarningPercent);
public record AdjustStockRequest(
decimal Delta,
string? Note,
decimal? TotalPaidToman = null,
string? BranchId = null);
public record InventoryPurchaseDto(
string Id,
string IngredientId,
string IngredientName,
decimal Delta,
string Unit,
decimal TotalPaidToman,
decimal UnitCostAfter,
DateTime CreatedAt,
string? ExpenseId);
public record InventoryPurchasesSummaryDto(
decimal TotalPaidToman,
int PurchaseCount,
IReadOnlyList<InventoryPurchaseDto> Recent);
public record RecipeLineDto(
string Id,
string IngredientId,
string IngredientName,
string Unit,
decimal QuantityPerUnit);
public record MenuItemRecipeDto(
string MenuItemId,
string MenuItemName,
IReadOnlyList<RecipeLineDto> Lines,
decimal MaterialCostPerUnitToman);
public record SetRecipeLineRequest(string IngredientId, decimal QuantityPerUnit);
public record SetMenuItemRecipeRequest(IReadOnlyList<SetRecipeLineRequest> Lines);
public record OrderDeductionResult(
bool Applied,
IReadOnlyList<string> LowStockIngredientNames);
public interface IInventoryService
{
Task<IReadOnlyList<IngredientDto>> ListAsync(string cafeId, CancellationToken ct = default);
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
Task<IngredientDto?> AdjustAsync(
string cafeId,
string ingredientId,
AdjustStockRequest request,
string? userId,
CancellationToken ct = default);
Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
string cafeId,
string branchId,
DateOnly from,
DateOnly to,
CancellationToken ct = default);
Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default);
Task<MenuItemRecipeDto?> SetRecipeAsync(string cafeId, string menuItemId, SetMenuItemRecipeRequest request, CancellationToken ct = default);
Task<OrderDeductionResult> DeductForOrderAsync(
string cafeId,
string orderId,
IReadOnlyList<(string MenuItemId, int Quantity)> lines,
CancellationToken ct = default);
}
public class InventoryService : IInventoryService
{
private readonly AppDbContext _db;
private readonly IHubContext<KdsHub> _kdsHub;
public InventoryService(AppDbContext db, IHubContext<KdsHub> kdsHub)
{
_db = db;
_kdsHub = kdsHub;
}
public async Task<IReadOnlyList<IngredientDto>> ListAsync(string cafeId, CancellationToken ct = default)
{
var rows = await _db.Ingredients.AsNoTracking()
.Where(i => i.CafeId == cafeId)
.OrderBy(i => i.Name)
.ToListAsync(ct);
return rows.Select(ToDto).ToList();
}
public async Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default)
{
var rows = await _db.Ingredients.Where(i => i.CafeId == cafeId).ToListAsync(ct);
return rows.Where(IsLowStock).Select(ToDto).ToList();
}
public async Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default)
{
var par = request.ParLevel > 0 ? request.ParLevel : request.QuantityOnHand;
var unitCost = ResolveUnitCost(request.QuantityOnHand, request.UnitCost, request.TotalPaidToman);
var entity = new Ingredient
{
Id = $"ing_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
Name = request.Name.Trim(),
Unit = string.IsNullOrWhiteSpace(request.Unit) ? "عدد" : request.Unit.Trim(),
QuantityOnHand = request.QuantityOnHand,
ReorderLevel = request.ReorderLevel,
UnitCost = unitCost,
ParLevel = par,
LowStockWarningPercent = ClampPercent(request.LowStockWarningPercent)
};
_db.Ingredients.Add(entity);
if (request.QuantityOnHand != 0)
{
var movement = NewMovement(
cafeId,
entity.Id,
request.QuantityOnHand,
request.TotalPaidToman > 0 ? StockMovementKind.Purchase : StockMovementKind.Manual,
null,
request.TotalPaidToman > 0 ? "خرید اولیه" : "موجودی اولیه",
request.TotalPaidToman,
request.BranchId);
_db.StockMovements.Add(movement);
if (request.TotalPaidToman > 0 && !string.IsNullOrWhiteSpace(request.BranchId))
{
var expense = await TryCreatePurchaseExpenseAsync(
cafeId,
request.BranchId,
request.TotalPaidToman.Value,
$"خرید انبار: {entity.Name} ({request.QuantityOnHand:N0} {entity.Unit})",
userId: null,
ct);
if (expense is not null)
movement.ExpenseId = expense.Id;
}
}
await _db.SaveChangesAsync(ct);
return ToDto(entity);
}
public async Task<IngredientDto?> UpdateAsync(
string cafeId,
string ingredientId,
UpdateIngredientRequest request,
CancellationToken ct = default)
{
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
if (entity is null) return null;
if (!string.IsNullOrWhiteSpace(request.Name)) entity.Name = request.Name.Trim();
if (!string.IsNullOrWhiteSpace(request.Unit)) entity.Unit = request.Unit.Trim();
if (request.ReorderLevel.HasValue) entity.ReorderLevel = request.ReorderLevel.Value;
if (request.UnitCost.HasValue) entity.UnitCost = request.UnitCost.Value;
if (request.ParLevel.HasValue) entity.ParLevel = request.ParLevel.Value;
if (request.LowStockWarningPercent.HasValue)
entity.LowStockWarningPercent = ClampPercent(request.LowStockWarningPercent.Value);
await _db.SaveChangesAsync(ct);
return ToDto(entity);
}
public async Task<IngredientDto?> AdjustAsync(
string cafeId,
string ingredientId,
AdjustStockRequest request,
string? userId,
CancellationToken ct = default)
{
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
if (entity is null) return null;
if (request.Delta > 0)
{
if (request.TotalPaidToman is null or <= 0)
throw new InvalidOperationException("TOTAL_PAID_REQUIRED");
if (string.IsNullOrWhiteSpace(request.BranchId))
throw new InvalidOperationException("BRANCH_ID_REQUIRED");
var oldQty = entity.QuantityOnHand;
var oldValue = oldQty * entity.UnitCost;
entity.QuantityOnHand += request.Delta;
entity.UnitCost = entity.QuantityOnHand > 0
? (oldValue + request.TotalPaidToman.Value) / entity.QuantityOnHand
: request.TotalPaidToman.Value / request.Delta;
var movement = NewMovement(
cafeId,
ingredientId,
request.Delta,
StockMovementKind.Purchase,
null,
request.Note?.Trim() ?? "خرید / ورود به انبار",
request.TotalPaidToman,
request.BranchId);
_db.StockMovements.Add(movement);
var expense = await TryCreatePurchaseExpenseAsync(
cafeId,
request.BranchId!,
request.TotalPaidToman.Value,
$"خرید انبار: {entity.Name} ({request.Delta:N0} {entity.Unit})",
userId,
ct);
if (expense is not null)
movement.ExpenseId = expense.Id;
}
else
{
entity.QuantityOnHand += request.Delta;
_db.StockMovements.Add(NewMovement(
cafeId,
ingredientId,
request.Delta,
StockMovementKind.Manual,
null,
request.Note?.Trim() ?? "تنظیم دستی",
null));
}
await _db.SaveChangesAsync(ct);
if (IsLowStock(entity))
await NotifyLowStockAsync(cafeId, [entity], ct);
return ToDto(entity);
}
public async Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
string cafeId,
string branchId,
DateOnly from,
DateOnly to,
CancellationToken ct = default)
{
var utcStart = from.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var utcEnd = to.AddDays(1).ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc);
var movements = await _db.StockMovements.AsNoTracking()
.Include(m => m.Ingredient)
.Where(m => m.CafeId == cafeId
&& m.BranchId == branchId
&& m.TotalCostToman != null
&& m.TotalCostToman > 0
&& m.CreatedAt >= utcStart
&& m.CreatedAt < utcEnd)
.OrderByDescending(m => m.CreatedAt)
.Take(50)
.ToListAsync(ct);
var total = movements.Sum(m => m.TotalCostToman ?? 0);
var recent = movements.Select(m => new InventoryPurchaseDto(
m.Id,
m.IngredientId,
m.Ingredient.Name,
m.Delta,
m.Ingredient.Unit,
m.TotalCostToman ?? 0,
m.Ingredient.UnitCost,
m.CreatedAt,
m.ExpenseId)).ToList();
return new InventoryPurchasesSummaryDto(total, recent.Count, recent);
}
public async Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default)
{
var item = await _db.MenuItems.AsNoTracking()
.FirstOrDefaultAsync(m => m.Id == menuItemId && m.CafeId == cafeId, ct);
if (item is null) return null;
var lines = await _db.MenuItemIngredients.AsNoTracking()
.Include(r => r.Ingredient)
.Where(r => r.CafeId == cafeId && r.MenuItemId == menuItemId)
.ToListAsync(ct);
return BuildRecipeDto(item, lines);
}
public async Task<MenuItemRecipeDto?> SetRecipeAsync(
string cafeId,
string menuItemId,
SetMenuItemRecipeRequest request,
CancellationToken ct = default)
{
var item = await _db.MenuItems.FirstOrDefaultAsync(m => m.Id == menuItemId && m.CafeId == cafeId, ct);
if (item is null) return null;
var existing = await _db.MenuItemIngredients
.Where(r => r.CafeId == cafeId && r.MenuItemId == menuItemId)
.ToListAsync(ct);
_db.MenuItemIngredients.RemoveRange(existing);
foreach (var line in request.Lines.Where(l => l.QuantityPerUnit > 0))
{
var ingOk = await _db.Ingredients.AnyAsync(i => i.Id == line.IngredientId && i.CafeId == cafeId, ct);
if (!ingOk) continue;
_db.MenuItemIngredients.Add(new MenuItemIngredient
{
Id = $"mii_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
MenuItemId = menuItemId,
IngredientId = line.IngredientId,
QuantityPerUnit = line.QuantityPerUnit
});
}
await _db.SaveChangesAsync(ct);
return await GetRecipeAsync(cafeId, menuItemId, ct);
}
public async Task<OrderDeductionResult> DeductForOrderAsync(
string cafeId,
string orderId,
IReadOnlyList<(string MenuItemId, int Quantity)> lines,
CancellationToken ct = default)
{
if (lines.Count == 0)
return new OrderDeductionResult(false, []);
var menuItemIds = lines.Select(l => l.MenuItemId).Distinct().ToList();
var recipes = await _db.MenuItemIngredients
.Where(r => r.CafeId == cafeId && menuItemIds.Contains(r.MenuItemId))
.ToListAsync(ct);
if (recipes.Count == 0)
return new OrderDeductionResult(false, []);
var usage = new Dictionary<string, decimal>(StringComparer.Ordinal);
foreach (var line in lines)
{
foreach (var recipe in recipes.Where(r => r.MenuItemId == line.MenuItemId))
{
var amount = recipe.QuantityPerUnit * line.Quantity;
usage[recipe.IngredientId] = usage.GetValueOrDefault(recipe.IngredientId) + amount;
}
}
if (usage.Count == 0)
return new OrderDeductionResult(false, []);
var ingredientIds = usage.Keys.ToList();
var ingredients = await _db.Ingredients
.Where(i => i.CafeId == cafeId && ingredientIds.Contains(i.Id))
.ToListAsync(ct);
foreach (var ing in ingredients)
{
if (!usage.TryGetValue(ing.Id, out var deduct)) continue;
ing.QuantityOnHand -= deduct;
_db.StockMovements.Add(NewMovement(
cafeId,
ing.Id,
-deduct,
StockMovementKind.OrderDeduction,
orderId,
$"سفارش {orderId[..Math.Min(8, orderId.Length)]}",
null));
}
await _db.SaveChangesAsync(ct);
var lowStock = ingredients.Where(IsLowStock).ToList();
if (lowStock.Count > 0)
await NotifyLowStockAsync(cafeId, lowStock, ct);
return new OrderDeductionResult(
true,
lowStock.Select(i => i.Name).ToList());
}
private async Task NotifyLowStockAsync(string cafeId, IReadOnlyList<Ingredient> items, CancellationToken ct)
{
if (items.Count == 0) return;
var names = string.Join("، ", items.Select(i => $"{i.Name} ({FormatQty(i)})"));
var notification = new CafeNotification
{
CafeId = cafeId,
Type = "inventory_low_stock",
Title = "کمبود مواد اولیه",
Body = names
};
_db.CafeNotifications.Add(notification);
await _db.SaveChangesAsync(ct);
var dto = new CafeNotificationDto(
notification.Id,
notification.Type,
notification.Title,
notification.Body,
notification.ReferenceId,
notification.TableNumber,
notification.IsRead,
notification.CreatedAt);
await _kdsHub.Clients.Group(KdsHub.GroupName(cafeId))
.SendAsync("NotificationReceived", dto, ct);
}
private async Task<Expense?> TryCreatePurchaseExpenseAsync(
string cafeId,
string branchId,
decimal amount,
string note,
string? userId,
CancellationToken ct)
{
var branch = await _db.Branches.FirstOrDefaultAsync(
b => b.Id == branchId && b.CafeId == cafeId && b.IsActive,
ct);
if (branch is null) return null;
var expense = new Expense
{
Id = $"exp_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
BranchId = branchId,
Category = ExpenseCategory.Supplies,
Amount = amount,
Note = note,
CreatedByUserId = userId ?? "system",
CreatedAt = DateTime.UtcNow
};
_db.Expenses.Add(expense);
return expense;
}
private static decimal ResolveUnitCost(decimal qty, decimal unitCost, decimal? totalPaid)
{
if (totalPaid is > 0 && qty > 0)
return totalPaid.Value / qty;
return unitCost;
}
private static StockMovement NewMovement(
string cafeId,
string ingredientId,
decimal delta,
StockMovementKind kind,
string? orderId,
string? note,
decimal? totalCostToman,
string? branchId = null) => new()
{
Id = $"stk_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
IngredientId = ingredientId,
BranchId = branchId,
Delta = delta,
Kind = kind,
OrderId = orderId,
Note = note,
TotalCostToman = totalCostToman > 0 ? totalCostToman : null
};
private static MenuItemRecipeDto BuildRecipeDto(MenuItem item, List<MenuItemIngredient> lines)
{
var recipeLines = lines.Select(r => new RecipeLineDto(
r.Id,
r.IngredientId,
r.Ingredient.Name,
r.Ingredient.Unit,
r.QuantityPerUnit)).ToList();
var cost = lines.Sum(r => r.QuantityPerUnit * r.Ingredient.UnitCost);
return new MenuItemRecipeDto(item.Id, item.Name, recipeLines, cost);
}
private static bool IsLowStock(Ingredient i)
{
var threshold = WarningThreshold(i);
return i.QuantityOnHand <= threshold;
}
private static decimal WarningThreshold(Ingredient i)
{
if (i.ParLevel > 0 && i.LowStockWarningPercent > 0)
return i.ParLevel * (i.LowStockWarningPercent / 100m);
return i.ReorderLevel;
}
private static IngredientDto ToDto(Ingredient i)
{
var threshold = WarningThreshold(i);
return new IngredientDto(
i.Id,
i.Name,
i.Unit,
i.QuantityOnHand,
i.ReorderLevel,
i.UnitCost,
i.ParLevel,
i.LowStockWarningPercent,
threshold,
i.QuantityOnHand * i.UnitCost,
i.QuantityOnHand <= threshold);
}
private static decimal ClampPercent(decimal p) => Math.Clamp(p, 1m, 100m);
private static string FormatQty(Ingredient i) =>
$"{i.QuantityOnHand:N0} {i.Unit}";
}