Auto-learn product allowed-unit categories from recipe / list writes
When a recipe ingredient or list item is saved with both a product link and a unit link, the unit's category is OR'd into the product's AllowedUnitCategories so the dropdown filter populates itself from real usage instead of needing explicit configuration. Family products are mutated directly; global products gain (or extend) a per-family FamilyProductOverride so the global catalog stays read-only. The learner only widens flag sets — it never narrows or clears them — so repeated writes are idempotent. No-ops when the global product already covers the category (no spurious override rows). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -298,4 +298,87 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue();
|
await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue();
|
||||||
await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste");
|
await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_learns_allowed_unit_categories_for_family_product()
|
||||||
|
{
|
||||||
|
var (familyProduct, unitId) = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var familyId = await db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync();
|
||||||
|
var fp = new FamilyProduct { FamilyId = familyId, Name = "House Flour" };
|
||||||
|
db.FamilyProducts.Add(fp);
|
||||||
|
var u = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "cup-recipe-learn", SingularName = "cup", PluralName = "cups",
|
||||||
|
Abbreviation = "cup-rl", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return (fp, u.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||||
|
Title: "Pancakes",
|
||||||
|
Description: null,
|
||||||
|
Instructions: null,
|
||||||
|
Servings: null,
|
||||||
|
SourceUrl: null,
|
||||||
|
Ingredients:
|
||||||
|
[
|
||||||
|
new RecipeEndpoints.IngredientRequest("flour", 1,
|
||||||
|
Quantity: 2m, UnitOfMeasureId: unitId, FamilyProductId: familyProduct.Id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == familyProduct.Id));
|
||||||
|
await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_accumulates_distinct_categories_across_ingredients()
|
||||||
|
{
|
||||||
|
var familyId = await UseDbAsync(db =>
|
||||||
|
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
|
||||||
|
var (familyProduct, cupId, ozId) = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var fp = new FamilyProduct { FamilyId = familyId, Name = "House Flour-multi" };
|
||||||
|
db.FamilyProducts.Add(fp);
|
||||||
|
var cup = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "cup-acc", SingularName = "cup", PluralName = "cups",
|
||||||
|
Abbreviation = "cup-acc", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
var oz = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "oz-acc", SingularName = "oz", PluralName = "oz",
|
||||||
|
Abbreviation = "oz-acc", Category = UnitCategory.Weight,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.AddRange(cup, oz);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return (fp, cup.Id, oz.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
var request = new RecipeEndpoints.CreateRecipeRequest(
|
||||||
|
Title: "Bread",
|
||||||
|
Description: null,
|
||||||
|
Instructions: null,
|
||||||
|
Servings: null,
|
||||||
|
SourceUrl: null,
|
||||||
|
Ingredients:
|
||||||
|
[
|
||||||
|
new RecipeEndpoints.IngredientRequest("flour-cup", 1,
|
||||||
|
Quantity: 2m, UnitOfMeasureId: cupId, FamilyProductId: familyProduct.Id),
|
||||||
|
new RecipeEndpoints.IngredientRequest("flour-oz", 2,
|
||||||
|
Quantity: 8m, UnitOfMeasureId: ozId, FamilyProductId: familyProduct.Id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/recipes", request);
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == familyProduct.Id));
|
||||||
|
await Assert.That(stored.AllowedUnitCategories)
|
||||||
|
.IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Weight);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -601,6 +601,67 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(salt.GetProperty("quantity").ValueKind).IsEqualTo(JsonValueKind.Null);
|
await Assert.That(salt.GetProperty("quantity").ValueKind).IsEqualTo(JsonValueKind.Null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Add_item_learns_family_product_unit_category()
|
||||||
|
{
|
||||||
|
var (familyProduct, cupId) = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var familyId = await db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync();
|
||||||
|
var fp = new FamilyProduct { FamilyId = familyId, Name = "House Milk" };
|
||||||
|
db.FamilyProducts.Add(fp);
|
||||||
|
var u = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "cup-learn", SingularName = "cup", PluralName = "cups",
|
||||||
|
Abbreviation = "cup-l", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return (fp, u.Id);
|
||||||
|
});
|
||||||
|
var list = await CreateListAsync();
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
|
new ShoppingListEndpoints.AddItemRequest("milk",
|
||||||
|
FamilyProductId: familyProduct.Id, Quantity: 1m, UnitOfMeasureId: cupId));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == familyProduct.Id));
|
||||||
|
await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Add_item_learns_global_product_via_family_override()
|
||||||
|
{
|
||||||
|
var (product, cupId) = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
var p = new Product { Name = "Milk-global-learn" };
|
||||||
|
db.Products.Add(p);
|
||||||
|
var u = new UnitOfMeasure
|
||||||
|
{
|
||||||
|
Code = "cup-global-learn", SingularName = "cup", PluralName = "cups",
|
||||||
|
Abbreviation = "cup-gl", Category = UnitCategory.Volume,
|
||||||
|
};
|
||||||
|
db.UnitsOfMeasure.Add(u);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return (p, u.Id);
|
||||||
|
});
|
||||||
|
var list = await CreateListAsync();
|
||||||
|
|
||||||
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
|
new ShoppingListEndpoints.AddItemRequest("milk",
|
||||||
|
ProductId: product.Id, Quantity: 1m, UnitOfMeasureId: cupId));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
|
||||||
|
var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleOrDefaultAsync(o => o.ProductId == product.Id));
|
||||||
|
await Assert.That(ovr).IsNotNull();
|
||||||
|
await Assert.That(ovr!.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
|
||||||
|
|
||||||
|
// Global row stays untouched — only the family-scoped override carries the learned flag.
|
||||||
|
var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == product.Id));
|
||||||
|
await Assert.That(global.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.None);
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Add_recipe_returns_404_when_recipe_missing()
|
public async Task Add_recipe_returns_404_when_recipe_missing()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using YesChef.Api.Data;
|
||||||
|
using YesChef.Api.Entities;
|
||||||
|
|
||||||
|
namespace YesChef.Api.Features.Products;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Folds the categories of (product, unit) pairings observed at write time
|
||||||
|
/// into the product's effective AllowedUnitCategories. Family products are
|
||||||
|
/// mutated directly; global products gain (or extend) a per-family override
|
||||||
|
/// so the global catalog stays read-only. Only widens — never narrows — the
|
||||||
|
/// flag set, so repeated calls are idempotent.
|
||||||
|
/// </summary>
|
||||||
|
public static class AllowedUnitCategoryLearner
|
||||||
|
{
|
||||||
|
public readonly record struct Pair(int? ProductId, int? FamilyProductId, int? UnitOfMeasureId, int? FamilyUnitOfMeasureId);
|
||||||
|
|
||||||
|
public static async Task LearnAsync(YesChefDb db, int familyId, IEnumerable<Pair> pairs)
|
||||||
|
{
|
||||||
|
var valid = pairs.Where(p =>
|
||||||
|
(p.ProductId.HasValue || p.FamilyProductId.HasValue) &&
|
||||||
|
(p.UnitOfMeasureId.HasValue || p.FamilyUnitOfMeasureId.HasValue)).ToList();
|
||||||
|
if (valid.Count == 0) return;
|
||||||
|
|
||||||
|
var globalUnitIds = valid.Where(p => p.UnitOfMeasureId.HasValue)
|
||||||
|
.Select(p => p.UnitOfMeasureId!.Value).Distinct().ToList();
|
||||||
|
var familyUnitIds = valid.Where(p => p.FamilyUnitOfMeasureId.HasValue)
|
||||||
|
.Select(p => p.FamilyUnitOfMeasureId!.Value).Distinct().ToList();
|
||||||
|
|
||||||
|
var globalCats = globalUnitIds.Count == 0
|
||||||
|
? new Dictionary<int, UnitCategory>()
|
||||||
|
: await db.UnitsOfMeasure.Where(u => globalUnitIds.Contains(u.Id))
|
||||||
|
.ToDictionaryAsync(u => u.Id, u => u.Category);
|
||||||
|
var familyCats = familyUnitIds.Count == 0
|
||||||
|
? new Dictionary<int, UnitCategory>()
|
||||||
|
: await db.FamilyUnitsOfMeasure.Where(u => familyUnitIds.Contains(u.Id) && u.FamilyId == familyId)
|
||||||
|
.ToDictionaryAsync(u => u.Id, u => u.Category);
|
||||||
|
|
||||||
|
var familyProductDeltas = new Dictionary<int, UnitCategoryFlags>();
|
||||||
|
var globalProductDeltas = new Dictionary<int, UnitCategoryFlags>();
|
||||||
|
|
||||||
|
foreach (var p in valid)
|
||||||
|
{
|
||||||
|
UnitCategoryFlags flag = UnitCategoryFlags.None;
|
||||||
|
if (p.UnitOfMeasureId.HasValue && globalCats.TryGetValue(p.UnitOfMeasureId.Value, out var gc))
|
||||||
|
flag = gc.ToFlag();
|
||||||
|
else if (p.FamilyUnitOfMeasureId.HasValue && familyCats.TryGetValue(p.FamilyUnitOfMeasureId.Value, out var fc))
|
||||||
|
flag = fc.ToFlag();
|
||||||
|
if (flag == UnitCategoryFlags.None) continue;
|
||||||
|
|
||||||
|
if (p.FamilyProductId.HasValue)
|
||||||
|
{
|
||||||
|
familyProductDeltas.TryGetValue(p.FamilyProductId.Value, out var existing);
|
||||||
|
familyProductDeltas[p.FamilyProductId.Value] = existing | flag;
|
||||||
|
}
|
||||||
|
else if (p.ProductId.HasValue)
|
||||||
|
{
|
||||||
|
globalProductDeltas.TryGetValue(p.ProductId.Value, out var existing);
|
||||||
|
globalProductDeltas[p.ProductId.Value] = existing | flag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (familyProductDeltas.Count > 0)
|
||||||
|
{
|
||||||
|
var ids = familyProductDeltas.Keys.ToList();
|
||||||
|
var products = await db.FamilyProducts
|
||||||
|
.Where(p => ids.Contains(p.Id) && p.FamilyId == familyId).ToListAsync();
|
||||||
|
foreach (var fp in products)
|
||||||
|
{
|
||||||
|
var next = fp.AllowedUnitCategories | familyProductDeltas[fp.Id];
|
||||||
|
if (next != fp.AllowedUnitCategories) fp.AllowedUnitCategories = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalProductDeltas.Count > 0)
|
||||||
|
{
|
||||||
|
var ids = globalProductDeltas.Keys.ToList();
|
||||||
|
var globalBase = await db.Products.Where(p => ids.Contains(p.Id))
|
||||||
|
.Select(p => new { p.Id, p.AllowedUnitCategories })
|
||||||
|
.ToDictionaryAsync(p => p.Id, p => p.AllowedUnitCategories);
|
||||||
|
var overrides = await db.FamilyProductOverrides
|
||||||
|
.Where(o => o.FamilyId == familyId && ids.Contains(o.ProductId))
|
||||||
|
.ToDictionaryAsync(o => o.ProductId, o => o);
|
||||||
|
|
||||||
|
foreach (var (pid, delta) in globalProductDeltas)
|
||||||
|
{
|
||||||
|
var baseCats = globalBase.TryGetValue(pid, out var gc) ? gc : UnitCategoryFlags.None;
|
||||||
|
if (!overrides.TryGetValue(pid, out var ovr))
|
||||||
|
{
|
||||||
|
// No override yet. Skip if the global already covers it
|
||||||
|
// — otherwise create an override that carries the union.
|
||||||
|
var next = baseCats | delta;
|
||||||
|
if (next == baseCats) continue;
|
||||||
|
db.FamilyProductOverrides.Add(new FamilyProductOverride
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
ProductId = pid,
|
||||||
|
AllowedUnitCategories = next,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var current = ovr.AllowedUnitCategories ?? baseCats;
|
||||||
|
var next = current | delta;
|
||||||
|
if (next != current)
|
||||||
|
{
|
||||||
|
ovr.AllowedUnitCategories = next;
|
||||||
|
ovr.UpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using YesChef.Api.Auth;
|
using YesChef.Api.Auth;
|
||||||
using YesChef.Api.Data;
|
using YesChef.Api.Data;
|
||||||
using YesChef.Api.Entities;
|
using YesChef.Api.Entities;
|
||||||
|
using YesChef.Api.Features.Products;
|
||||||
using YesChef.Api.Features.ShoppingLists;
|
using YesChef.Api.Features.ShoppingLists;
|
||||||
|
|
||||||
namespace YesChef.Api.Features.Recipes;
|
namespace YesChef.Api.Features.Recipes;
|
||||||
@@ -64,6 +65,7 @@ public static class RecipeEndpoints
|
|||||||
};
|
};
|
||||||
|
|
||||||
db.Recipes.Add(recipe);
|
db.Recipes.Add(recipe);
|
||||||
|
await AllowedUnitCategoryLearner.LearnAsync(db, familyId, request.Ingredients.Select(ToLearnerPair));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Created($"/api/recipes/{recipe.Id}", new { recipe.Id, recipe.Title });
|
return Results.Created($"/api/recipes/{recipe.Id}", new { recipe.Id, recipe.Title });
|
||||||
});
|
});
|
||||||
@@ -127,6 +129,7 @@ public static class RecipeEndpoints
|
|||||||
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
|
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
|
||||||
recipe.Ingredients = request.Ingredients.Select(i => ToEntity(i, familyId)).ToList();
|
recipe.Ingredients = request.Ingredients.Select(i => ToEntity(i, familyId)).ToList();
|
||||||
|
|
||||||
|
await AllowedUnitCategoryLearner.LearnAsync(db, familyId, request.Ingredients.Select(ToLearnerPair));
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(new { recipe.Id, recipe.Title });
|
return Results.Ok(new { recipe.Id, recipe.Title });
|
||||||
});
|
});
|
||||||
@@ -157,6 +160,9 @@ public static class RecipeEndpoints
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static AllowedUnitCategoryLearner.Pair ToLearnerPair(IngredientRequest i) =>
|
||||||
|
new(i.ProductId, i.FamilyProductId, i.UnitOfMeasureId, i.FamilyUnitOfMeasureId);
|
||||||
|
|
||||||
private static RecipeIngredient ToEntity(IngredientRequest i, int familyId) => new()
|
private static RecipeIngredient ToEntity(IngredientRequest i, int familyId) => new()
|
||||||
{
|
{
|
||||||
FamilyId = familyId,
|
FamilyId = familyId,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using YesChef.Api.Auth;
|
using YesChef.Api.Auth;
|
||||||
using YesChef.Api.Data;
|
using YesChef.Api.Data;
|
||||||
using YesChef.Api.Entities;
|
using YesChef.Api.Entities;
|
||||||
|
using YesChef.Api.Features.Products;
|
||||||
|
|
||||||
namespace YesChef.Api.Features.ShoppingLists;
|
namespace YesChef.Api.Features.ShoppingLists;
|
||||||
|
|
||||||
@@ -231,6 +232,11 @@ public static class ShoppingListEndpoints
|
|||||||
if (request.SectionId.HasValue)
|
if (request.SectionId.HasValue)
|
||||||
await RememberSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId, request.SectionId);
|
await RememberSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId, request.SectionId);
|
||||||
|
|
||||||
|
await AllowedUnitCategoryLearner.LearnAsync(db, familyId, new[]
|
||||||
|
{
|
||||||
|
new AllowedUnitCategoryLearner.Pair(request.ProductId, request.FamilyProductId, item.UnitOfMeasureId, item.FamilyUnitOfMeasureId),
|
||||||
|
});
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipeTitle: null));
|
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipeTitle: null));
|
||||||
|
|||||||
Reference in New Issue
Block a user