307 lines
11 KiB
C#
307 lines
11 KiB
C#
using ClosedXML.Excel;
|
||
using MealPlan.Api.Data;
|
||
using MealPlan.Api.DTOs.Recipe;
|
||
using MealPlan.Api.Models;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace MealPlan.Api.Services;
|
||
|
||
/// <summary>
|
||
/// Imports recipes from a three-sheet Excel workbook (Recipes, Ingredients, Steps).
|
||
/// Also generates a downloadable template in the same format.
|
||
/// </summary>
|
||
public class RecipeExcelService : IRecipeExcelService
|
||
{
|
||
private readonly AppDbContext _db;
|
||
private readonly ILogger<RecipeExcelService> _logger;
|
||
|
||
public RecipeExcelService(AppDbContext db, ILogger<RecipeExcelService> logger)
|
||
{
|
||
_db = db;
|
||
_logger = logger;
|
||
}
|
||
|
||
public byte[] GenerateTemplate()
|
||
{
|
||
using var workbook = new XLWorkbook();
|
||
|
||
var recipes = workbook.Worksheets.Add("Recipes");
|
||
recipes.Cell(1, 1).Value = "Name";
|
||
recipes.Cell(1, 2).Value = "MealCategory";
|
||
recipes.Cell(1, 3).Value = "Calories";
|
||
recipes.Cell(1, 4).Value = "Protein";
|
||
recipes.Cell(1, 5).Value = "Fat";
|
||
recipes.Cell(1, 6).Value = "Carbs";
|
||
recipes.Cell(1, 7).Value = "PrepTimeMinutes";
|
||
recipes.Row(1).Style.Font.Bold = true;
|
||
recipes.Cell(2, 1).Value = "Oatmeal with berries";
|
||
recipes.Cell(2, 2).Value = "Breakfast";
|
||
recipes.Cell(2, 3).Value = 320;
|
||
recipes.Cell(2, 4).Value = 12;
|
||
recipes.Cell(2, 5).Value = 8;
|
||
recipes.Cell(2, 6).Value = 45;
|
||
recipes.Cell(2, 7).Value = 10;
|
||
recipes.Columns().AdjustToContents();
|
||
|
||
var ingredients = workbook.Worksheets.Add("Ingredients");
|
||
ingredients.Cell(1, 1).Value = "RecipeName";
|
||
ingredients.Cell(1, 2).Value = "Name";
|
||
ingredients.Cell(1, 3).Value = "AmountGrams";
|
||
ingredients.Cell(1, 4).Value = "Unit";
|
||
ingredients.Cell(1, 5).Value = "SortOrder";
|
||
ingredients.Row(1).Style.Font.Bold = true;
|
||
ingredients.Cell(2, 1).Value = "Oatmeal with berries";
|
||
ingredients.Cell(2, 2).Value = "Rolled oats";
|
||
ingredients.Cell(2, 3).Value = 80;
|
||
ingredients.Cell(2, 4).Value = "g";
|
||
ingredients.Cell(2, 5).Value = 0;
|
||
ingredients.Cell(3, 1).Value = "Oatmeal with berries";
|
||
ingredients.Cell(3, 2).Value = "Blueberries";
|
||
ingredients.Cell(3, 3).Value = 100;
|
||
ingredients.Cell(3, 4).Value = "g";
|
||
ingredients.Cell(3, 5).Value = 1;
|
||
ingredients.Columns().AdjustToContents();
|
||
|
||
var steps = workbook.Worksheets.Add("Steps");
|
||
steps.Cell(1, 1).Value = "RecipeName";
|
||
steps.Cell(1, 2).Value = "StepNumber";
|
||
steps.Cell(1, 3).Value = "Description";
|
||
steps.Row(1).Style.Font.Bold = true;
|
||
steps.Cell(2, 1).Value = "Oatmeal with berries";
|
||
steps.Cell(2, 2).Value = 1;
|
||
steps.Cell(2, 3).Value = "Cook oats in milk for 5 minutes.";
|
||
steps.Cell(3, 1).Value = "Oatmeal with berries";
|
||
steps.Cell(3, 2).Value = 2;
|
||
steps.Cell(3, 3).Value = "Top with berries and serve.";
|
||
steps.Columns().AdjustToContents();
|
||
|
||
var help = workbook.Worksheets.Add("Help");
|
||
help.Cell(1, 1).Value = "MealCategory values";
|
||
help.Cell(2, 1).Value = "Breakfast, SecondBreakfast (or Second Breakfast), Lunch, Dinner";
|
||
help.Cell(2, 2).Value = "Or numeric: 0, 1, 2, 3";
|
||
help.Cell(4, 1).Value = "RecipeName in Ingredients/Steps must match Name in Recipes sheet exactly.";
|
||
help.Columns().AdjustToContents();
|
||
|
||
using var stream = new MemoryStream();
|
||
workbook.SaveAs(stream);
|
||
return stream.ToArray();
|
||
}
|
||
|
||
public async Task<RecipeImportResultDto> ImportAsync(Stream fileStream, CancellationToken ct = default)
|
||
{
|
||
var result = new RecipeImportResultDto();
|
||
|
||
using var workbook = new XLWorkbook(fileStream);
|
||
var recipeSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||
w.Name.Equals("Recipes", StringComparison.OrdinalIgnoreCase));
|
||
if (recipeSheet is null)
|
||
{
|
||
result.Errors.Add("Worksheet 'Recipes' not found.");
|
||
return result;
|
||
}
|
||
|
||
var ingredientSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||
w.Name.Equals("Ingredients", StringComparison.OrdinalIgnoreCase));
|
||
var stepSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||
w.Name.Equals("Steps", StringComparison.OrdinalIgnoreCase));
|
||
|
||
var ingredientRows = ParseIngredients(ingredientSheet, result.Errors);
|
||
var stepRows = ParseSteps(stepSheet, result.Errors);
|
||
|
||
var recipeRows = recipeSheet.RowsUsed().Skip(1);
|
||
foreach (var row in recipeRows)
|
||
{
|
||
var name = row.Cell(1).GetString().Trim();
|
||
if (string.IsNullOrEmpty(name))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!TryParseCategory(row.Cell(2).GetString(), out var category, out var categoryError))
|
||
{
|
||
result.Errors.Add($"Recipe '{name}': {categoryError}");
|
||
result.SkippedCount++;
|
||
continue;
|
||
}
|
||
|
||
var dto = new CreateRecipeDto
|
||
{
|
||
Name = name,
|
||
MealCategory = category,
|
||
Calories = ParseNullableInt(row.Cell(3)),
|
||
Protein = ParseNullableDecimal(row.Cell(4)),
|
||
Fat = ParseNullableDecimal(row.Cell(5)),
|
||
Carbs = ParseNullableDecimal(row.Cell(6)),
|
||
PrepTimeMinutes = ParseNullableInt(row.Cell(7)),
|
||
Ingredients = ingredientRows
|
||
.Where(i => i.RecipeName.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||
.Select(i => new CreateIngredientDto
|
||
{
|
||
Name = i.Name,
|
||
AmountGrams = i.AmountGrams,
|
||
Unit = i.Unit,
|
||
SortOrder = i.SortOrder,
|
||
})
|
||
.ToList(),
|
||
Steps = stepRows
|
||
.Where(s => s.RecipeName.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||
.OrderBy(s => s.StepNumber)
|
||
.Select(s => new CreateRecipeStepDto
|
||
{
|
||
StepNumber = s.StepNumber,
|
||
Description = s.Description,
|
||
})
|
||
.ToList(),
|
||
};
|
||
|
||
try
|
||
{
|
||
var exists = await _db.Recipes.AnyAsync(
|
||
r => r.IsActive && r.Name.ToLower() == name.ToLower(), ct);
|
||
if (exists)
|
||
{
|
||
result.Errors.Add($"Recipe '{name}' already exists — skipped.");
|
||
result.SkippedCount++;
|
||
continue;
|
||
}
|
||
|
||
var entity = RecipeManagementService.MapToEntity(dto);
|
||
entity.CreatedAt = DateTime.UtcNow;
|
||
entity.IsActive = true;
|
||
_db.Recipes.Add(entity);
|
||
await _db.SaveChangesAsync(ct);
|
||
result.CreatedCount++;
|
||
result.CreatedRecipeIds.Add(entity.Id);
|
||
_logger.LogInformation("Imported recipe {RecipeId} '{Name}' from Excel", entity.Id, name);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "Failed to import recipe '{Name}'", name);
|
||
result.Errors.Add($"Recipe '{name}': {ex.Message}");
|
||
result.SkippedCount++;
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
private static List<IngredientRow> ParseIngredients(IXLWorksheet? sheet, List<string> errors)
|
||
{
|
||
var rows = new List<IngredientRow>();
|
||
if (sheet is null)
|
||
{
|
||
return rows;
|
||
}
|
||
|
||
foreach (var row in sheet.RowsUsed().Skip(1))
|
||
{
|
||
var recipeName = row.Cell(1).GetString().Trim();
|
||
var name = row.Cell(2).GetString().Trim();
|
||
if (string.IsNullOrEmpty(recipeName) || string.IsNullOrEmpty(name))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
rows.Add(new IngredientRow(
|
||
recipeName,
|
||
name,
|
||
ParseNullableDecimal(row.Cell(3)),
|
||
NullIfEmpty(row.Cell(4).GetString()),
|
||
ParseNullableInt(row.Cell(5)) ?? rows.Count(r =>
|
||
r.RecipeName.Equals(recipeName, StringComparison.OrdinalIgnoreCase))));
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
private static List<StepRow> ParseSteps(IXLWorksheet? sheet, List<string> errors)
|
||
{
|
||
var rows = new List<StepRow>();
|
||
if (sheet is null)
|
||
{
|
||
return rows;
|
||
}
|
||
|
||
foreach (var row in sheet.RowsUsed().Skip(1))
|
||
{
|
||
var recipeName = row.Cell(1).GetString().Trim();
|
||
var stepNumber = ParseNullableInt(row.Cell(2));
|
||
var description = row.Cell(3).GetString().Trim();
|
||
if (string.IsNullOrEmpty(recipeName) || stepNumber is null or < 1 || string.IsNullOrEmpty(description))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
rows.Add(new StepRow(recipeName, stepNumber.Value, description));
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
internal static bool TryParseCategory(string raw, out int category, out string error)
|
||
{
|
||
error = string.Empty;
|
||
category = 0;
|
||
var value = raw.Trim();
|
||
|
||
if (int.TryParse(value, out category) && category is >= 0 and <= 3)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
category = value.ToLowerInvariant().Replace(" ", "") switch
|
||
{
|
||
"breakfast" => 0,
|
||
"secondbreakfast" => 1,
|
||
"lunch" => 2,
|
||
"dinner" => 3,
|
||
_ => -1,
|
||
};
|
||
|
||
if (category < 0)
|
||
{
|
||
error = $"Invalid MealCategory '{raw}'. Use Breakfast, SecondBreakfast, Lunch, Dinner or 0–3.";
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
private static int? ParseNullableInt(IXLCell cell)
|
||
{
|
||
if (cell.IsEmpty())
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (cell.TryGetValue(out int intValue))
|
||
{
|
||
return intValue;
|
||
}
|
||
|
||
return int.TryParse(cell.GetString(), out intValue) ? intValue : null;
|
||
}
|
||
|
||
private static decimal? ParseNullableDecimal(IXLCell cell)
|
||
{
|
||
if (cell.IsEmpty())
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (cell.TryGetValue(out double doubleValue))
|
||
{
|
||
return (decimal)doubleValue;
|
||
}
|
||
|
||
return decimal.TryParse(cell.GetString(), out var decimalValue) ? decimalValue : null;
|
||
}
|
||
|
||
private static string? NullIfEmpty(string value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
private sealed record IngredientRow(string RecipeName, string Name, decimal? AmountGrams, string? Unit, int SortOrder);
|
||
|
||
private sealed record StepRow(string RecipeName, int StepNumber, string Description);
|
||
}
|