198 lines
6.5 KiB
C#
198 lines
6.5 KiB
C#
using MealPlan.Api.Data;
|
|
using MealPlan.Api.DTOs.MealPlan;
|
|
using MealPlan.Api.Services.Generators;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MealPlan.Api.Services;
|
|
|
|
public class MealPlanService : IMealPlanService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
private readonly IRecipeService _recipeService;
|
|
|
|
public MealPlanService(AppDbContext db, IRecipeService recipeService)
|
|
{
|
|
_db = db;
|
|
_recipeService = recipeService;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<MealPlanEntryDto>> GetEntriesAsync(
|
|
Guid userId,
|
|
DateOnly from,
|
|
DateOnly to,
|
|
CancellationToken ct = default)
|
|
{
|
|
return await _db.MealPlanEntries.AsNoTracking()
|
|
.Include(e => e.Recipe)
|
|
.Where(e => e.UserId == userId && e.PlanDate >= from && e.PlanDate <= to)
|
|
.OrderBy(e => e.PlanDate)
|
|
.ThenBy(e => e.MealCategory)
|
|
.Select(e => new MealPlanEntryDto
|
|
{
|
|
Id = e.Id,
|
|
PlanDate = e.PlanDate,
|
|
MealCategory = e.MealCategory,
|
|
RecipeId = e.RecipeId,
|
|
RecipeName = e.Recipe!.Name,
|
|
Portions = e.Portions,
|
|
Calories = e.Recipe!.Calories,
|
|
})
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<MealPlanEntryDto?> UpsertEntryAsync(
|
|
Guid userId,
|
|
UpsertMealPlanEntryDto dto,
|
|
CancellationToken ct = default)
|
|
{
|
|
var recipeExists = await _db.Recipes.AnyAsync(r => r.Id == dto.RecipeId && r.IsActive, ct);
|
|
if (!recipeExists) return null;
|
|
|
|
var existing = await _db.MealPlanEntries
|
|
.Include(e => e.Recipe)
|
|
.FirstOrDefaultAsync(
|
|
e => e.UserId == userId && e.PlanDate == dto.PlanDate && e.MealCategory == dto.MealCategory,
|
|
ct);
|
|
|
|
if (existing is null)
|
|
{
|
|
existing = new Models.MealPlanEntry
|
|
{
|
|
UserId = userId,
|
|
PlanDate = dto.PlanDate,
|
|
MealCategory = dto.MealCategory,
|
|
RecipeId = dto.RecipeId,
|
|
Portions = dto.Portions,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
_db.MealPlanEntries.Add(existing);
|
|
}
|
|
else
|
|
{
|
|
existing.RecipeId = dto.RecipeId;
|
|
existing.Portions = dto.Portions;
|
|
existing.UpdatedAt = DateTime.UtcNow;
|
|
if (existing.Recipe is null)
|
|
{
|
|
await _db.Entry(existing).Reference(e => e.Recipe).LoadAsync(ct);
|
|
}
|
|
}
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
await _db.Entry(existing).Reference(e => e.Recipe).LoadAsync(ct);
|
|
|
|
return new MealPlanEntryDto
|
|
{
|
|
Id = existing.Id,
|
|
PlanDate = existing.PlanDate,
|
|
MealCategory = existing.MealCategory,
|
|
RecipeId = existing.RecipeId,
|
|
RecipeName = existing.Recipe?.Name ?? string.Empty,
|
|
Portions = existing.Portions,
|
|
Calories = existing.Recipe?.Calories,
|
|
};
|
|
}
|
|
|
|
public async Task<bool> DeleteEntryAsync(Guid userId, int id, CancellationToken ct = default)
|
|
{
|
|
var entry = await _db.MealPlanEntries.FirstOrDefaultAsync(e => e.Id == id && e.UserId == userId, ct);
|
|
if (entry is null) return false;
|
|
_db.MealPlanEntries.Remove(entry);
|
|
await _db.SaveChangesAsync(ct);
|
|
return true;
|
|
}
|
|
|
|
public async Task<IReadOnlyList<ShoppingListItemDto>> GetShoppingListAsync(
|
|
Guid userId,
|
|
DateOnly from,
|
|
DateOnly to,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entries = await _db.MealPlanEntries.AsNoTracking()
|
|
.Where(e => e.UserId == userId && e.PlanDate >= from && e.PlanDate <= to)
|
|
.ToListAsync(ct);
|
|
|
|
var recipeIds = entries.Select(e => e.RecipeId).Distinct().ToList();
|
|
var recipes = new List<DTOs.Recipe.RecipeDetailDto>();
|
|
foreach (var id in recipeIds)
|
|
{
|
|
var recipe = await _recipeService.GetRecipeByIdAsync(id, ct);
|
|
if (recipe is not null) recipes.Add(recipe);
|
|
}
|
|
|
|
var buckets = new Dictionary<string, ShoppingBucket>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
var recipe = recipes.FirstOrDefault(r => r.Id == entry.RecipeId);
|
|
if (recipe is null) continue;
|
|
|
|
foreach (var ing in recipe.Ingredients)
|
|
{
|
|
if (IngredientScalingHelper.IsNonQuantifiedUnit(ing.Unit))
|
|
continue;
|
|
|
|
var (scaledGrams, _) = IngredientScalingHelper.ScaleForDietPlan(
|
|
ing.AmountGrams, ing.Unit, entry.Portions);
|
|
|
|
if (scaledGrams is not > 0)
|
|
continue;
|
|
|
|
var key = $"g:{NormalizeName(ing.Name)}";
|
|
|
|
if (!buckets.TryGetValue(key, out var bucket))
|
|
{
|
|
bucket = new ShoppingBucket(ing.Name, null);
|
|
buckets[key] = bucket;
|
|
}
|
|
|
|
bucket.Add(recipe.Name, scaledGrams, null);
|
|
}
|
|
}
|
|
|
|
return buckets.Values
|
|
.Select(b => new ShoppingListItemDto
|
|
{
|
|
Name = b.Name,
|
|
Unit = b.Unit,
|
|
TotalGrams = b.TotalGrams,
|
|
TotalAmount = b.TotalAmount,
|
|
Breakdown = b.Breakdown,
|
|
})
|
|
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
|
.ToList();
|
|
}
|
|
|
|
private static string NormalizeName(string name) => name.Trim().ToLowerInvariant();
|
|
|
|
private sealed class ShoppingBucket
|
|
{
|
|
public string Name { get; }
|
|
public string? Unit { get; }
|
|
public decimal? TotalGrams { get; private set; }
|
|
public decimal? TotalAmount { get; private set; }
|
|
public List<string> Breakdown { get; } = new();
|
|
|
|
public ShoppingBucket(string name, string? unit)
|
|
{
|
|
Name = name;
|
|
Unit = unit;
|
|
}
|
|
|
|
public void Add(string recipeName, decimal? grams, decimal? amount)
|
|
{
|
|
if (Unit is null && grams.HasValue)
|
|
{
|
|
TotalGrams = (TotalGrams ?? 0) + grams.Value;
|
|
Breakdown.Add($"{recipeName}: {grams.Value:0.##}g");
|
|
}
|
|
else if (amount.HasValue)
|
|
{
|
|
TotalAmount = (TotalAmount ?? 0) + amount.Value;
|
|
var suffix = Unit is null ? "g" : $" {Unit}";
|
|
Breakdown.Add($"{recipeName}: {amount.Value:0.##}{suffix}");
|
|
}
|
|
}
|
|
}
|
|
}
|