191 lines
6.5 KiB
C#
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();
|
|
}
|