Files
DailyMeals/MealPlan.Api/Services/RecipeManagementService.cs
Piotr Kus f15bb7a916 * Extended functionalities
* Added different UIs
2026-06-24 21:43:40 +02:00

191 lines
6.5 KiB
C#

using MealPlan.Api.Data;
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace MealPlan.Api.Services;
/// <summary>Creates and updates recipes in the existing PostgreSQL schema.</summary>
public class RecipeManagementService : IRecipeManagementService
{
private readonly AppDbContext _db;
private readonly IRecipeService _recipeService;
public RecipeManagementService(AppDbContext db, IRecipeService recipeService)
{
_db = db;
_recipeService = recipeService;
}
public async Task<RecipeDetailDto> CreateRecipeAsync(CreateRecipeDto dto, CancellationToken ct = default)
{
var recipe = MapToEntity(dto);
recipe.CreatedAt = DateTime.UtcNow;
recipe.IsActive = true;
await ApplyMacrosFromCatalogAsync(recipe, ct);
_db.Recipes.Add(recipe);
await _db.SaveChangesAsync(ct);
return (await _recipeService.GetRecipeByIdAsync(recipe.Id, ct))!;
}
public async Task<RecipeDetailDto?> UpdateRecipeAsync(int id, CreateRecipeDto dto, CancellationToken ct = default)
{
var recipe = await _db.Recipes
.Include(r => r.Ingredients)
.Include(r => r.Steps)
.FirstOrDefaultAsync(r => r.Id == id && r.IsActive, ct);
if (recipe is null)
{
return null;
}
recipe.Name = dto.Name.Trim();
recipe.MealCategory = dto.MealCategory;
recipe.Calories = dto.Calories;
recipe.Protein = dto.Protein;
recipe.Fat = dto.Fat;
recipe.Carbs = dto.Carbs;
recipe.PrepTimeMinutes = dto.PrepTimeMinutes;
recipe.UpdatedAt = DateTime.UtcNow;
_db.Ingredients.RemoveRange(recipe.Ingredients);
_db.RecipeSteps.RemoveRange(recipe.Steps);
recipe.Ingredients = MapIngredients(dto.Ingredients);
recipe.Steps = MapSteps(dto.Steps);
await ApplyMacrosFromCatalogAsync(recipe, ct);
await _db.SaveChangesAsync(ct);
return await _recipeService.GetRecipeByIdAsync(id, ct);
}
public async Task<RecipeMacroRecalcResultDto> RecalculateAllMacrosFromCatalogAsync(CancellationToken ct = default)
{
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
.Where(i => i.IsActive)
.ToDictionaryAsync(i => i.Id, ct);
var recipes = await _db.Recipes
.Include(r => r.Ingredients)
.Where(r => r.IsActive)
.ToListAsync(ct);
var updated = 0;
foreach (var recipe in recipes)
{
if (TrySetMacrosFromCatalog(recipe, catalog, overwrite: true))
{
recipe.UpdatedAt = DateTime.UtcNow;
updated++;
}
}
await _db.SaveChangesAsync(ct);
var unlinked = await _db.Ingredients.CountAsync(i =>
i.AmountGrams > 0 &&
i.CatalogItemId == null &&
(i.Unit == null || !HasNonMacroUnit(i.Unit)), ct);
return new RecipeMacroRecalcResultDto
{
RecipesProcessed = recipes.Count,
RecipesUpdated = updated,
UnlinkedIngredientRows = unlinked,
};
}
internal static Recipe MapToEntity(CreateRecipeDto dto) =>
new()
{
Name = dto.Name.Trim(),
MealCategory = dto.MealCategory,
Calories = dto.Calories,
Protein = dto.Protein,
Fat = dto.Fat,
Carbs = dto.Carbs,
PrepTimeMinutes = dto.PrepTimeMinutes,
Ingredients = MapIngredients(dto.Ingredients),
Steps = MapSteps(dto.Steps),
};
private static List<Ingredient> MapIngredients(IEnumerable<CreateIngredientDto> items) =>
items.Select((item, index) => new Ingredient
{
Name = item.Name.Trim(),
AmountGrams = item.AmountGrams,
Unit = string.IsNullOrWhiteSpace(item.Unit) ? null : item.Unit.Trim(),
SortOrder = item.SortOrder > 0 ? item.SortOrder : index,
CatalogItemId = item.CatalogItemId,
}).ToList();
private async Task ApplyMacrosFromCatalogAsync(Recipe recipe, CancellationToken ct)
{
var catalogIds = recipe.Ingredients
.Where(i => i.CatalogItemId.HasValue && i.AmountGrams is > 0 && !HasNonMacroUnit(i.Unit))
.Select(i => i.CatalogItemId!.Value)
.Distinct()
.ToList();
if (catalogIds.Count == 0) return;
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
.Where(i => catalogIds.Contains(i.Id) && i.IsActive)
.ToDictionaryAsync(i => i.Id, ct);
TrySetMacrosFromCatalog(recipe, catalog, overwrite: false);
}
private static bool TrySetMacrosFromCatalog(
Recipe recipe,
IReadOnlyDictionary<int, IngredientNutritionCatalogItem> catalog,
bool overwrite)
{
int totalCalories = 0;
decimal totalProtein = 0;
decimal totalFat = 0;
decimal totalCarbs = 0;
var hasAny = false;
foreach (var ing in recipe.Ingredients)
{
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || HasNonMacroUnit(ing.Unit)) continue;
if (!catalog.TryGetValue(ing.CatalogItemId.Value, out var cat)) continue;
var scale = ing.AmountGrams.Value / 100m;
totalCalories += (int)Math.Round(cat.CaloriesPer100G * scale);
totalProtein += cat.ProteinGPer100G * scale;
totalFat += cat.FatGPer100G * scale;
totalCarbs += cat.CarbsGPer100G * scale;
hasAny = true;
}
if (!hasAny) return false;
if (overwrite || !recipe.Calories.HasValue) recipe.Calories = totalCalories;
if (overwrite || !recipe.Protein.HasValue) recipe.Protein = Math.Round(totalProtein, 2);
if (overwrite || !recipe.Fat.HasValue) recipe.Fat = Math.Round(totalFat, 2);
if (overwrite || !recipe.Carbs.HasValue) recipe.Carbs = Math.Round(totalCarbs, 2);
return true;
}
private static bool HasNonMacroUnit(string? unit)
{
if (string.IsNullOrWhiteSpace(unit)) return false;
var u = unit.Trim().ToLowerInvariant();
return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta";
}
private static List<RecipeStep> MapSteps(IEnumerable<CreateRecipeStepDto> items) =>
items.Select(item => new RecipeStep
{
StepNumber = item.StepNumber,
Description = item.Description.Trim(),
}).ToList();
}