From 2c8d6b69aee11bce8afe1a2009ddcf405e3155c0 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Mon, 4 May 2026 22:06:57 +0200 Subject: [PATCH] feat: add support for alternative ingredients; implement JSON storage and parsing logic --- NEXT_STEPS.md | 20 ++++++ .../migration.sql | 1 + backend/prisma/schema.prisma | 1 + .../src/common/utils/recipe-parser.spec.ts | 64 +++++++++++++++++++ backend/src/common/utils/recipe-parser.ts | 36 +++++++++-- backend/src/recipes/dto/create-recipe.dto.ts | 5 ++ backend/src/recipes/recipes.service.ts | 33 +++++++++- .../recipes/domain/parsed_recipe.dart | 5 ++ .../presentation/create_recipe_screen.dart | 24 ++++++- 9 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 backend/prisma/migrations/20260504220420_add_alternative_product_ids/migration.sql create mode 100644 backend/src/common/utils/recipe-parser.spec.ts diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 3c27c23f..58103f68 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -36,6 +36,26 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen - Backend-kontrakt ar sanningskalla; klienter foljer kontrakten. - Importfunktionalitet ar delegerad till microservice-importer dar det ar beslutat. +## Framtida förbättringsområden + +### Alternativa ingredienser — migrering till relationsmodell (Option B) + +Nuläge: `RecipeIngredient.alternativeProductIds` lagras som JSON-kolumn (Option A). +Detta fungerar men saknar referensintegritet — om en alternativ produkt tas bort uppdateras inte kolumnen automatiskt. + +Framtida lösning: Ersätt JSON-kolumnen med en separat tabell: +```prisma +model RecipeIngredientAlternative { + id Int @id @default(autoincrement()) + recipeIngredientId Int + recipeIngredient RecipeIngredient @relation(fields: [recipeIngredientId], references: [id], onDelete: Cascade) + productId Int + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) +} +``` +Fördelar: FK-integritet, möjlig sortering/prioritering av alternativ, lättare att querrya. +Förutsättning: migration som konverterar befintlig JSON-data till rader i tabellen. + ## Relaterade dokument - `README.md` - anvandarperspektiv. diff --git a/backend/prisma/migrations/20260504220420_add_alternative_product_ids/migration.sql b/backend/prisma/migrations/20260504220420_add_alternative_product_ids/migration.sql new file mode 100644 index 00000000..36ff6655 --- /dev/null +++ b/backend/prisma/migrations/20260504220420_add_alternative_product_ids/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `RecipeIngredient` ADD COLUMN `alternativeProductIds` JSON NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ce361f3d..b8fe74d2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -156,6 +156,7 @@ model RecipeIngredient { quantity Decimal @db.Decimal(10, 2) unit String note String? + alternativeProductIds Json? // [id, id, ...] — alternativa produkter (t.ex. "ris eller couscous") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/common/utils/recipe-parser.spec.ts b/backend/src/common/utils/recipe-parser.spec.ts new file mode 100644 index 00000000..5c9bc370 --- /dev/null +++ b/backend/src/common/utils/recipe-parser.spec.ts @@ -0,0 +1,64 @@ +import { parseRecipeMarkdown } from './recipe-parser'; + +describe('parseRecipeMarkdown — ingrediensformat', () => { + const parse = (line: string) => { + const md = `# Test\n## Ingredienser\n- ${line}\n## Instruktioner\n1. Gör det.`; + return parseRecipeMarkdown(md).ingredients[0]; + }; + + it('vanlig rad: kvantitet enhet namn', () => { + const ing = parse('400 g kycklingfilé'); + expect(ing).toMatchObject({ quantity: 400, unit: 'g', rawName: 'kycklingfilé' }); + }); + + it('bråk: 1 1/2 dl grädde', () => { + const ing = parse('1 1/2 dl grädde'); + expect(ing.quantity).toBeCloseTo(1.5); + expect(ing).toMatchObject({ unit: 'dl', rawName: 'grädde' }); + }); + + it('bråk utan heltal: 1/2 dl mjölk', () => { + const ing = parse('1/2 dl mjölk'); + expect(ing.quantity).toBeCloseTo(0.5); + expect(ing).toMatchObject({ unit: 'dl', rawName: 'mjölk' }); + }); + + it('intervall: 600 - ca 700 g kycklingfilé', () => { + const ing = parse('600 - ca 700 g kycklingfilé'); + expect(ing.quantity).toBeCloseTo(650); + expect(ing).toMatchObject({ unit: 'g', rawName: 'kycklingfilé' }); + }); + + it('intervall: ca 600-700 g kycklingfilé', () => { + const ing = parse('ca 600-700 g kycklingfilé'); + expect(ing.quantity).toBeCloseTo(650); + expect(ing).toMatchObject({ unit: 'g' }); + }); + + it('intervall med alternativt namn: 600 - ca 700 g kycklingfilé eller kycklinginnerfilé', () => { + const ing = parse('600 - ca 700 g kycklingfilé eller kycklinginnerfilé'); + expect(ing.quantity).toBeCloseTo(650); + expect(ing).toMatchObject({ unit: 'g', rawName: 'kycklingfilé eller kycklinginnerfilé' }); + }); + + it('parentes-not: 2 dl grädde (eller crème fraiche)', () => { + const ing = parse('2 dl grädde (eller crème fraiche)'); + expect(ing).toMatchObject({ quantity: 2, unit: 'dl', rawName: 'grädde', note: 'eller crème fraiche' }); + }); + + it('utan enhet: 3 ägg', () => { + const ing = parse('3 ägg'); + expect(ing).toMatchObject({ quantity: 3, unit: 'st', rawName: 'ägg' }); + }); + + it('bara namn: salt', () => { + const ing = parse('salt'); + expect(ing).toMatchObject({ quantity: 0, unit: '', rawName: 'salt' }); + }); + + it('decimalkomma: 2,5 dl mjölk', () => { + const ing = parse('2,5 dl mjölk'); + expect(ing.quantity).toBeCloseTo(2.5); + expect(ing).toMatchObject({ unit: 'dl', rawName: 'mjölk' }); + }); +}); diff --git a/backend/src/common/utils/recipe-parser.ts b/backend/src/common/utils/recipe-parser.ts index fbe9c30c..c9179de7 100644 --- a/backend/src/common/utils/recipe-parser.ts +++ b/backend/src/common/utils/recipe-parser.ts @@ -12,6 +12,7 @@ export interface ParsedIngredient { quantity: number; unit: string; note: string | null; + alternatives: string[]; // uppdelning av "ris eller couscous" → ["ris", "couscous"] } export interface ParsedRecipe { @@ -38,8 +39,10 @@ const INSTRUCTION_HEADING_RE = /instruktion|tillagning|gör så här|steg|tillv const BULLET_RE = /^[-*]\s+/; const PAREN_NOTE_RE = /\(([^)]+)\)\s*$/; const FRACTION_RE = /^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.+)$/; +const RANGE_RE = /^(?:ca\.?\s+)?(\d+(?:[.,]\d+)?)\s*[-–]\s*(?:ca\.?\s+)?(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/i; const QTY_UNIT_NAME_RE = /^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/; const QTY_NAME_RE = /^(\d+(?:[.,]\d+)?)\s+(.+)$/; +const ALTERNATIVES_RE = /\s+eller\s+/i; // ============================================================================ // Parser Functions @@ -136,6 +139,12 @@ export function parseRecipeMarkdown(markdown: string): ParsedRecipe { function parseIngredientLine(text: string): ParsedIngredient { const trimmed = text.trim(); + // Hjälpfunktion: splittar rawName på " eller " och returnerar alternatives + const toAlternatives = (rawName: string): string[] => + ALTERNATIVES_RE.test(rawName) + ? rawName.split(ALTERNATIVES_RE).map((s) => s.trim()).filter(Boolean) + : [rawName]; + // Extrahera eventuell parentes-not i slutet let note: string | null = null; let main = trimmed; @@ -152,7 +161,21 @@ function parseIngredientLine(text: string): ParsedIngredient { const quantity = whole + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]); const candidateUnit = fractionMatch[4].toLowerCase(); if (KNOWN_UNITS.has(candidateUnit)) { - return { quantity, unit: candidateUnit, rawName: fractionMatch[5].trim(), note }; + const rawName = fractionMatch[5].trim(); + return { quantity, unit: candidateUnit, rawName, note, alternatives: toAlternatives(rawName) }; + } + } + + // Försök matcha intervall: "600 - ca 700 g kycklingfilé" eller "ca 600–700 g" + // Använder medelvärdet av intervallet som kvantitet. + const rangeMatch = main.match(RANGE_RE); + if (rangeMatch) { + const candidateUnit = rangeMatch[3].toLowerCase(); + if (KNOWN_UNITS.has(candidateUnit)) { + const lo = parseNumber(rangeMatch[1]); + const hi = parseNumber(rangeMatch[2]); + const rawName = rangeMatch[4].trim(); + return { quantity: (lo + hi) / 2, unit: candidateUnit, rawName, note, alternatives: toAlternatives(rawName) }; } } @@ -161,20 +184,23 @@ function parseIngredientLine(text: string): ParsedIngredient { if (fullMatch) { const candidateUnit = fullMatch[2].toLowerCase(); if (KNOWN_UNITS.has(candidateUnit)) { - return { quantity: parseNumber(fullMatch[1]), unit: candidateUnit, rawName: fullMatch[3].trim(), note }; + const rawName = fullMatch[3].trim(); + return { quantity: parseNumber(fullMatch[1]), unit: candidateUnit, rawName, note, alternatives: toAlternatives(rawName) }; } // Inte känd enhet — behandla "kvantitet ord1 ord2..." utan enhet - return { quantity: parseNumber(fullMatch[1]), unit: 'st', rawName: fullMatch[2] + ' ' + fullMatch[3], note }; + const rawName = fullMatch[2] + ' ' + fullMatch[3]; + return { quantity: parseNumber(fullMatch[1]), unit: 'st', rawName, note, alternatives: toAlternatives(rawName) }; } // Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg" const noUnitMatch = main.match(QTY_NAME_RE); if (noUnitMatch) { - return { quantity: parseNumber(noUnitMatch[1]), unit: 'st', rawName: noUnitMatch[2].trim(), note }; + const rawName = noUnitMatch[2].trim(); + return { quantity: parseNumber(noUnitMatch[1]), unit: 'st', rawName, note, alternatives: toAlternatives(rawName) }; } // Bara ett namn, ingen kvantitet — t.ex. "salt" - return { quantity: 0, unit: '', rawName: main, note }; + return { quantity: 0, unit: '', rawName: main, note, alternatives: toAlternatives(main) }; } function parseNumber(s: string): number { diff --git a/backend/src/recipes/dto/create-recipe.dto.ts b/backend/src/recipes/dto/create-recipe.dto.ts index dc19370c..8f1b1906 100644 --- a/backend/src/recipes/dto/create-recipe.dto.ts +++ b/backend/src/recipes/dto/create-recipe.dto.ts @@ -25,6 +25,11 @@ class CreateRecipeIngredientDto { @IsOptional() @IsString() note?: string; + + @IsOptional() + @IsArray() + @IsInt({ each: true }) + alternativeProductIds?: number[]; } export class CreateRecipeDto { diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 534ffaa4..a3b43e2d 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -94,7 +94,16 @@ export class RecipesService { const ingredientPreviews = await Promise.all( recipe.ingredients.map(async (ingredient: any) => { const inventoryItems = await this.prisma.inventoryItem.findMany({ - where: { productId: ingredient.productId }, + where: { + productId: { + in: [ + ingredient.productId, + ...(Array.isArray(ingredient.alternativeProductIds) + ? ingredient.alternativeProductIds + : []), + ], + }, + }, orderBy: { createdAt: 'desc' }, }); @@ -276,6 +285,7 @@ export class RecipesService { quantity: ingredient.quantity, unit: ingredient.unit, note: ingredient.note || null, + alternativeProductIds: ingredient.alternativeProductIds ?? [], })), }, }, @@ -443,6 +453,7 @@ export class RecipesService { quantity: ingredient.quantity, unit: ingredient.unit, note: ingredient.note || null, + alternativeProductIds: ingredient.alternativeProductIds ?? [], })), }, }, @@ -512,9 +523,12 @@ export class RecipesService { }; const ingredientsWithSuggestions = parsed.ingredients.map((ingredient: ParsedIngredient) => { - const query = normalize(ingredient.rawName); + // Kör matchning mot alla alternativ och slå ihop suggestions + const alternatives = ingredient.alternatives?.length > 1 + ? ingredient.alternatives + : [ingredient.rawName]; - const scored = allProducts + const scoreProduct = (query: string) => allProducts .map((product) => { const targetName = normalize(product.canonicalName || product.name); const targetNormalized = normalize(product.normalizedName); @@ -538,6 +552,18 @@ export class RecipesService { }) .filter((s) => s.score >= 40) .sort((a, b) => b.score - a.score) + .slice(0, 5); + + // Slå ihop suggestions från alla alternativ, deduplicera på productId, ta topp 5 + const seenIds = new Set(); + const scored = alternatives + .flatMap((alt) => scoreProduct(normalize(alt))) + .filter((s) => { + if (seenIds.has(s.product.id)) return false; + seenIds.add(s.product.id); + return true; + }) + .sort((a, b) => b.score - a.score) .slice(0, 5) .map((s) => ({ productId: s.product.id, @@ -547,6 +573,7 @@ export class RecipesService { return { rawName: ingredient.rawName, + alternatives: ingredient.alternatives ?? [], quantity: ingredient.quantity, unit: ingredient.unit, note: ingredient.note, diff --git a/flutter/lib/features/recipes/domain/parsed_recipe.dart b/flutter/lib/features/recipes/domain/parsed_recipe.dart index 8752fae9..898b56a0 100644 --- a/flutter/lib/features/recipes/domain/parsed_recipe.dart +++ b/flutter/lib/features/recipes/domain/parsed_recipe.dart @@ -23,6 +23,7 @@ class ParsedIngredient { final String unit; final String? note; final List suggestions; + final List alternatives; const ParsedIngredient({ required this.rawName, @@ -30,6 +31,7 @@ class ParsedIngredient { required this.unit, this.note, required this.suggestions, + this.alternatives = const [], }); factory ParsedIngredient.fromJson(Map json) { @@ -42,6 +44,9 @@ class ParsedIngredient { suggestions: rawSuggestions .map((s) => IngredientSuggestion.fromJson(s as Map)) .toList(), + alternatives: (json['alternatives'] as List? ?? []) + .map((a) => a as String) + .toList(), ); } } diff --git a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart index ca6f36c7..89556c09 100644 --- a/flutter/lib/features/recipes/presentation/create_recipe_screen.dart +++ b/flutter/lib/features/recipes/presentation/create_recipe_screen.dart @@ -149,11 +149,21 @@ class _CreateRecipeScreenState extends ConsumerState { _parsed!.ingredients[i].quantity; final unit = _unitControllers[i]!.text.trim(); final note = _noteControllers[i]!.text.trim(); + final ing = _parsed!.ingredients[i]; + // Alternativa produkter: alla suggestions vars productId matchar ett alternativ + final alternativeProductIds = ing.alternatives.length > 1 + ? ing.suggestions + .where((s) => s.productId != productId) + .map((s) => s.productId) + .toList() + : []; ingredients.add({ 'productId': productId, 'quantity': qty, 'unit': unit, if (note.isNotEmpty) 'note': note, + if (alternativeProductIds.isNotEmpty) + 'alternativeProductIds': alternativeProductIds, }); } @@ -339,7 +349,19 @@ class _CreateRecipeScreenState extends ConsumerState { CheckboxListTile( value: isIncluded, onChanged: (v) => setState(() => _included[index] = v ?? false), - title: Text(ing.rawName), + title: ing.alternatives.length > 1 + ? Wrap( + spacing: 4, + children: ing.alternatives + .map((alt) => Chip( + label: Text(alt), + padding: EdgeInsets.zero, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + )) + .toList(), + ) + : Text(ing.rawName), subtitle: noProductFound ? Text( context.l10n.recipeCreateNoProductFound,