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 Recent); public record RecipeLineDto( string Id, string IngredientId, string IngredientName, string Unit, decimal QuantityPerUnit); public record MenuItemRecipeDto( string MenuItemId, string MenuItemName, IReadOnlyList Lines, decimal MaterialCostPerUnitToman); public record SetRecipeLineRequest(string IngredientId, decimal QuantityPerUnit); public record SetMenuItemRecipeRequest(IReadOnlyList Lines); public record OrderDeductionResult( bool Applied, IReadOnlyList LowStockIngredientNames); public interface IInventoryService { Task> ListAsync(string cafeId, CancellationToken ct = default); Task> LowStockAsync(string cafeId, CancellationToken ct = default); Task CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default); Task UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default); Task DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default); Task AdjustAsync( string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default); Task GetPurchasesSummaryAsync( string cafeId, string branchId, DateOnly from, DateOnly to, CancellationToken ct = default); Task GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default); Task SetRecipeAsync(string cafeId, string menuItemId, SetMenuItemRecipeRequest request, CancellationToken ct = default); Task 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; public InventoryService(AppDbContext db, IHubContext kdsHub) { _db = db; _kdsHub = kdsHub; } public async Task> 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> 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 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 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 DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default) { var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct); if (entity is null) return false; // Soft delete: Ingredient has a global DeletedAt query filter, so it (and its // recipe lines / stock movements) drop out of every query without FK trouble. entity.DeletedAt = DateTime.UtcNow; await _db.SaveChangesAsync(ct); return true; } public async Task 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 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 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 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 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(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 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 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 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}"; }