2026-05-27 21:33:48 +03:30
|
|
|
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);
|
2026-06-02 16:14:40 +03:30
|
|
|
Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
|
2026-05-27 21:33:48 +03:30
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-02 16:14:40 +03:30
|
|
|
public async Task<bool> 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 21:33:48 +03:30
|
|
|
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}";
|
|
|
|
|
}
|