* Extended functionalities
* Added different UIs
This commit is contained in:
190
MealPlan.Api/Services/RecipeManagementService.cs
Normal file
190
MealPlan.Api/Services/RecipeManagementService.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user