feat: add support for alternative ingredients; implement JSON storage and parsing logic
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -36,6 +36,26 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen
|
|||||||
- Backend-kontrakt ar sanningskalla; klienter foljer kontrakten.
|
- Backend-kontrakt ar sanningskalla; klienter foljer kontrakten.
|
||||||
- Importfunktionalitet ar delegerad till microservice-importer dar det ar beslutat.
|
- 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
|
## Relaterade dokument
|
||||||
|
|
||||||
- `README.md` - anvandarperspektiv.
|
- `README.md` - anvandarperspektiv.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `RecipeIngredient` ADD COLUMN `alternativeProductIds` JSON NULL;
|
||||||
@@ -156,6 +156,7 @@ model RecipeIngredient {
|
|||||||
quantity Decimal @db.Decimal(10, 2)
|
quantity Decimal @db.Decimal(10, 2)
|
||||||
unit String
|
unit String
|
||||||
note String?
|
note String?
|
||||||
|
alternativeProductIds Json? // [id, id, ...] — alternativa produkter (t.ex. "ris eller couscous")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ export interface ParsedIngredient {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
unit: string;
|
unit: string;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
|
alternatives: string[]; // uppdelning av "ris eller couscous" → ["ris", "couscous"]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedRecipe {
|
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 BULLET_RE = /^[-*]\s+/;
|
||||||
const PAREN_NOTE_RE = /\(([^)]+)\)\s*$/;
|
const PAREN_NOTE_RE = /\(([^)]+)\)\s*$/;
|
||||||
const FRACTION_RE = /^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\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_UNIT_NAME_RE = /^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/;
|
||||||
const QTY_NAME_RE = /^(\d+(?:[.,]\d+)?)\s+(.+)$/;
|
const QTY_NAME_RE = /^(\d+(?:[.,]\d+)?)\s+(.+)$/;
|
||||||
|
const ALTERNATIVES_RE = /\s+eller\s+/i;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Parser Functions
|
// Parser Functions
|
||||||
@@ -136,6 +139,12 @@ export function parseRecipeMarkdown(markdown: string): ParsedRecipe {
|
|||||||
function parseIngredientLine(text: string): ParsedIngredient {
|
function parseIngredientLine(text: string): ParsedIngredient {
|
||||||
const trimmed = text.trim();
|
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
|
// Extrahera eventuell parentes-not i slutet
|
||||||
let note: string | null = null;
|
let note: string | null = null;
|
||||||
let main = trimmed;
|
let main = trimmed;
|
||||||
@@ -152,7 +161,21 @@ function parseIngredientLine(text: string): ParsedIngredient {
|
|||||||
const quantity = whole + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]);
|
const quantity = whole + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]);
|
||||||
const candidateUnit = fractionMatch[4].toLowerCase();
|
const candidateUnit = fractionMatch[4].toLowerCase();
|
||||||
if (KNOWN_UNITS.has(candidateUnit)) {
|
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) {
|
if (fullMatch) {
|
||||||
const candidateUnit = fullMatch[2].toLowerCase();
|
const candidateUnit = fullMatch[2].toLowerCase();
|
||||||
if (KNOWN_UNITS.has(candidateUnit)) {
|
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
|
// 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"
|
// Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg"
|
||||||
const noUnitMatch = main.match(QTY_NAME_RE);
|
const noUnitMatch = main.match(QTY_NAME_RE);
|
||||||
if (noUnitMatch) {
|
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"
|
// 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 {
|
function parseNumber(s: string): number {
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class CreateRecipeIngredientDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
note?: string;
|
note?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
alternativeProductIds?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateRecipeDto {
|
export class CreateRecipeDto {
|
||||||
|
|||||||
@@ -94,7 +94,16 @@ export class RecipesService {
|
|||||||
const ingredientPreviews = await Promise.all(
|
const ingredientPreviews = await Promise.all(
|
||||||
recipe.ingredients.map(async (ingredient: any) => {
|
recipe.ingredients.map(async (ingredient: any) => {
|
||||||
const inventoryItems = await this.prisma.inventoryItem.findMany({
|
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' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,6 +285,7 @@ export class RecipesService {
|
|||||||
quantity: ingredient.quantity,
|
quantity: ingredient.quantity,
|
||||||
unit: ingredient.unit,
|
unit: ingredient.unit,
|
||||||
note: ingredient.note || null,
|
note: ingredient.note || null,
|
||||||
|
alternativeProductIds: ingredient.alternativeProductIds ?? [],
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -443,6 +453,7 @@ export class RecipesService {
|
|||||||
quantity: ingredient.quantity,
|
quantity: ingredient.quantity,
|
||||||
unit: ingredient.unit,
|
unit: ingredient.unit,
|
||||||
note: ingredient.note || null,
|
note: ingredient.note || null,
|
||||||
|
alternativeProductIds: ingredient.alternativeProductIds ?? [],
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -512,9 +523,12 @@ export class RecipesService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ingredientsWithSuggestions = parsed.ingredients.map((ingredient: ParsedIngredient) => {
|
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) => {
|
.map((product) => {
|
||||||
const targetName = normalize(product.canonicalName || product.name);
|
const targetName = normalize(product.canonicalName || product.name);
|
||||||
const targetNormalized = normalize(product.normalizedName);
|
const targetNormalized = normalize(product.normalizedName);
|
||||||
@@ -538,6 +552,18 @@ export class RecipesService {
|
|||||||
})
|
})
|
||||||
.filter((s) => s.score >= 40)
|
.filter((s) => s.score >= 40)
|
||||||
.sort((a, b) => b.score - a.score)
|
.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<number>();
|
||||||
|
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)
|
.slice(0, 5)
|
||||||
.map((s) => ({
|
.map((s) => ({
|
||||||
productId: s.product.id,
|
productId: s.product.id,
|
||||||
@@ -547,6 +573,7 @@ export class RecipesService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
rawName: ingredient.rawName,
|
rawName: ingredient.rawName,
|
||||||
|
alternatives: ingredient.alternatives ?? [],
|
||||||
quantity: ingredient.quantity,
|
quantity: ingredient.quantity,
|
||||||
unit: ingredient.unit,
|
unit: ingredient.unit,
|
||||||
note: ingredient.note,
|
note: ingredient.note,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class ParsedIngredient {
|
|||||||
final String unit;
|
final String unit;
|
||||||
final String? note;
|
final String? note;
|
||||||
final List<IngredientSuggestion> suggestions;
|
final List<IngredientSuggestion> suggestions;
|
||||||
|
final List<String> alternatives;
|
||||||
|
|
||||||
const ParsedIngredient({
|
const ParsedIngredient({
|
||||||
required this.rawName,
|
required this.rawName,
|
||||||
@@ -30,6 +31,7 @@ class ParsedIngredient {
|
|||||||
required this.unit,
|
required this.unit,
|
||||||
this.note,
|
this.note,
|
||||||
required this.suggestions,
|
required this.suggestions,
|
||||||
|
this.alternatives = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ParsedIngredient.fromJson(Map<String, dynamic> json) {
|
factory ParsedIngredient.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -42,6 +44,9 @@ class ParsedIngredient {
|
|||||||
suggestions: rawSuggestions
|
suggestions: rawSuggestions
|
||||||
.map((s) => IngredientSuggestion.fromJson(s as Map<String, dynamic>))
|
.map((s) => IngredientSuggestion.fromJson(s as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
alternatives: (json['alternatives'] as List<dynamic>? ?? [])
|
||||||
|
.map((a) => a as String)
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,11 +149,21 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
|||||||
_parsed!.ingredients[i].quantity;
|
_parsed!.ingredients[i].quantity;
|
||||||
final unit = _unitControllers[i]!.text.trim();
|
final unit = _unitControllers[i]!.text.trim();
|
||||||
final note = _noteControllers[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()
|
||||||
|
: <int>[];
|
||||||
ingredients.add({
|
ingredients.add({
|
||||||
'productId': productId,
|
'productId': productId,
|
||||||
'quantity': qty,
|
'quantity': qty,
|
||||||
'unit': unit,
|
'unit': unit,
|
||||||
if (note.isNotEmpty) 'note': note,
|
if (note.isNotEmpty) 'note': note,
|
||||||
|
if (alternativeProductIds.isNotEmpty)
|
||||||
|
'alternativeProductIds': alternativeProductIds,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,7 +349,19 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
|||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
value: isIncluded,
|
value: isIncluded,
|
||||||
onChanged: (v) => setState(() => _included[index] = v ?? false),
|
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
|
subtitle: noProductFound
|
||||||
? Text(
|
? Text(
|
||||||
context.l10n.recipeCreateNoProductFound,
|
context.l10n.recipeCreateNoProductFound,
|
||||||
|
|||||||
Reference in New Issue
Block a user