feat: enhance recipe ingredient model; add raw fields and optional properties for better ingredient handling
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:
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE `RecipeIngredient`
|
||||
MODIFY `productId` INTEGER NULL,
|
||||
MODIFY `quantity` DECIMAL(10, 2) NULL,
|
||||
MODIFY `unit` VARCHAR(191) NULL,
|
||||
ADD COLUMN `rawName` VARCHAR(191) NOT NULL DEFAULT '',
|
||||
ADD COLUMN `rawLine` TEXT NULL,
|
||||
ADD COLUMN `matchConfidence` DOUBLE NULL,
|
||||
ADD COLUMN `matchSource` VARCHAR(191) NULL,
|
||||
ADD COLUMN `analysisStatus` VARCHAR(191) NULL;
|
||||
@@ -151,12 +151,17 @@ model RecipeIngredient {
|
||||
id Int @id @default(autoincrement())
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id])
|
||||
recipeId Int
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
productId Int
|
||||
quantity Decimal @db.Decimal(10, 2)
|
||||
unit String
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
productId Int?
|
||||
rawName String @default("")
|
||||
rawLine String? @db.Text
|
||||
quantity Decimal? @db.Decimal(10, 2)
|
||||
unit String?
|
||||
note String?
|
||||
alternativeProductIds Json? // [id, id, ...] — alternativa produkter (t.ex. "ris eller couscous")
|
||||
matchConfidence Float?
|
||||
matchSource String?
|
||||
analysisStatus String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -12,20 +12,39 @@ import {
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class CreateRecipeIngredientDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
productId!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity!: number;
|
||||
productId?: number;
|
||||
|
||||
@IsString()
|
||||
unit!: string;
|
||||
rawName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
rawLine?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
unit?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
matchConfidence?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
matchSource?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
|
||||
@@ -113,6 +113,24 @@ export class RecipesService {
|
||||
|
||||
const ingredientPreviews = await Promise.all(
|
||||
recipe.ingredients.map(async (ingredient: any) => {
|
||||
if (!ingredient.productId || !ingredient.product) {
|
||||
return {
|
||||
ingredientId: ingredient.id,
|
||||
productId: null,
|
||||
productName: ingredient.rawName || 'Okänd ingrediens',
|
||||
requiredQuantity: Number(ingredient.quantity ?? 0),
|
||||
requiredUnit: ingredient.unit || '',
|
||||
note: ingredient.note,
|
||||
availableQuantity: 0,
|
||||
availableUnit: ingredient.unit || '',
|
||||
matchingInventoryItems: [],
|
||||
otherInventoryItems: [],
|
||||
status: 'missing' as const,
|
||||
fromPantry: false,
|
||||
missingQuantity: Number(ingredient.quantity ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
// Täcks ingrediensen av pantry (inkl. alternativ)?
|
||||
const coveredByPantry =
|
||||
pantryProductIds.has(ingredient.productId) ||
|
||||
@@ -313,7 +331,11 @@ export class RecipesService {
|
||||
await this.assertAndClaimRecipeOwner(existingRecipe, userId);
|
||||
|
||||
// Validera att alla produkter är aktiva
|
||||
await this.assertProductsActive(updateRecipeDto.ingredients.map((i) => i.productId));
|
||||
await this.assertProductsActive(
|
||||
updateRecipeDto.ingredients
|
||||
.map((i) => i.productId)
|
||||
.filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
|
||||
// Transaktionsblock: ta bort gamla + skapa nya ingredienser atomärt
|
||||
const recipe = await this.prisma.$transaction(async (tx) => {
|
||||
@@ -329,11 +351,15 @@ export class RecipesService {
|
||||
...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }),
|
||||
ingredients: {
|
||||
create: updateRecipeDto.ingredients.map((ingredient) => ({
|
||||
productId: ingredient.productId,
|
||||
quantity: ingredient.quantity,
|
||||
unit: ingredient.unit,
|
||||
productId: ingredient.productId ?? null,
|
||||
rawName: ingredient.rawName,
|
||||
rawLine: ingredient.rawLine ?? null,
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unit: ingredient.unit?.trim() ? ingredient.unit : null,
|
||||
note: ingredient.note || null,
|
||||
alternativeProductIds: ingredient.alternativeProductIds ?? [],
|
||||
matchConfidence: ingredient.matchConfidence ?? null,
|
||||
matchSource: ingredient.matchSource ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -462,7 +488,11 @@ export class RecipesService {
|
||||
|
||||
async create(createRecipeDto: CreateRecipeDto, userId: number) {
|
||||
// Validera att alla produkter är aktiva
|
||||
await this.assertProductsActive(createRecipeDto.ingredients.map((i) => i.productId));
|
||||
await this.assertProductsActive(
|
||||
createRecipeDto.ingredients
|
||||
.map((i) => i.productId)
|
||||
.filter((id): id is number => typeof id === 'number'),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`,
|
||||
@@ -497,11 +527,15 @@ export class RecipesService {
|
||||
isPublic: false,
|
||||
ingredients: {
|
||||
create: createRecipeDto.ingredients.map((ingredient) => ({
|
||||
productId: ingredient.productId,
|
||||
quantity: ingredient.quantity,
|
||||
unit: ingredient.unit,
|
||||
productId: ingredient.productId ?? null,
|
||||
rawName: ingredient.rawName,
|
||||
rawLine: ingredient.rawLine ?? null,
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unit: ingredient.unit?.trim() ? ingredient.unit : null,
|
||||
note: ingredient.note || null,
|
||||
alternativeProductIds: ingredient.alternativeProductIds ?? [],
|
||||
matchConfidence: ingredient.matchConfidence ?? null,
|
||||
matchSource: ingredient.matchSource ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
@@ -745,6 +779,7 @@ Regler:
|
||||
|
||||
return {
|
||||
rawName: ingredient.rawName,
|
||||
rawLine: ingredient.rawName,
|
||||
alternatives: ingredient.alternatives ?? [],
|
||||
quantity: ingredient.quantity,
|
||||
unit: ingredient.unit,
|
||||
|
||||
@@ -2,7 +2,7 @@ enum IngredientStatus { enough, missing, unitMismatch }
|
||||
|
||||
class IngredientPreview {
|
||||
final int ingredientId;
|
||||
final int productId;
|
||||
final int? productId;
|
||||
final String productName;
|
||||
final double requiredQuantity;
|
||||
final String requiredUnit;
|
||||
@@ -14,7 +14,7 @@ class IngredientPreview {
|
||||
|
||||
const IngredientPreview({
|
||||
required this.ingredientId,
|
||||
required this.productId,
|
||||
this.productId,
|
||||
required this.productName,
|
||||
required this.requiredQuantity,
|
||||
required this.requiredUnit,
|
||||
@@ -34,8 +34,8 @@ class IngredientPreview {
|
||||
};
|
||||
return IngredientPreview(
|
||||
ingredientId: json['ingredientId'] as int,
|
||||
productId: json['productId'] as int,
|
||||
productName: json['productName'] as String,
|
||||
productId: (json['productId'] as num?)?.toInt(),
|
||||
productName: (json['productName'] as String?) ?? (json['rawName'] as String? ?? ''),
|
||||
requiredQuantity: (json['requiredQuantity'] as num).toDouble(),
|
||||
requiredUnit: json['requiredUnit'] as String? ?? '',
|
||||
note: json['note'] as String?,
|
||||
|
||||
@@ -19,6 +19,7 @@ class IngredientSuggestion {
|
||||
|
||||
class ParsedIngredient {
|
||||
final String rawName;
|
||||
final String? rawLine;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
final String? note;
|
||||
@@ -27,6 +28,7 @@ class ParsedIngredient {
|
||||
|
||||
const ParsedIngredient({
|
||||
required this.rawName,
|
||||
this.rawLine,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
this.note,
|
||||
@@ -38,6 +40,7 @@ class ParsedIngredient {
|
||||
final rawSuggestions = json['suggestions'] as List<dynamic>? ?? [];
|
||||
return ParsedIngredient(
|
||||
rawName: json['rawName'] as String? ?? '',
|
||||
rawLine: json['rawLine'] as String?,
|
||||
quantity: (json['quantity'] as num? ?? 0).toDouble(),
|
||||
unit: json['unit'] as String? ?? '',
|
||||
note: json['note'] as String?,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
class RecipeIngredient {
|
||||
final int id;
|
||||
final int productId;
|
||||
final String productName;
|
||||
final int? productId;
|
||||
final String? productName;
|
||||
final String rawName;
|
||||
final String? rawLine;
|
||||
final double quantity;
|
||||
final String unit;
|
||||
final String? note;
|
||||
|
||||
const RecipeIngredient({
|
||||
required this.id,
|
||||
required this.productId,
|
||||
required this.productName,
|
||||
this.productId,
|
||||
this.productName,
|
||||
required this.rawName,
|
||||
this.rawLine,
|
||||
required this.quantity,
|
||||
required this.unit,
|
||||
this.note,
|
||||
@@ -20,8 +24,10 @@ class RecipeIngredient {
|
||||
final rawQty = json['quantity'];
|
||||
return RecipeIngredient(
|
||||
id: (json['id'] as num).toInt(),
|
||||
productId: (json['productId'] as num).toInt(),
|
||||
productName: product?['name'] as String? ?? '',
|
||||
productId: (json['productId'] as num?)?.toInt(),
|
||||
productName: product?['canonicalName'] as String? ?? product?['name'] as String?,
|
||||
rawName: json['rawName'] as String? ?? '',
|
||||
rawLine: json['rawLine'] as String?,
|
||||
quantity: rawQty is num
|
||||
? rawQty.toDouble()
|
||||
: double.tryParse(rawQty?.toString() ?? '') ?? 0,
|
||||
|
||||
@@ -191,7 +191,6 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
for (var i = 0; i < _parsed!.ingredients.length; i++) {
|
||||
if (!_included[i]) continue;
|
||||
final productId = _selectedProductIds[i];
|
||||
if (productId == null) continue;
|
||||
final qty = double.tryParse(
|
||||
_qtyControllers[i]!.text.trim().replaceAll(',', '.'),
|
||||
) ??
|
||||
@@ -207,9 +206,11 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
|
||||
.toList()
|
||||
: <int>[];
|
||||
ingredients.add({
|
||||
'productId': productId,
|
||||
'quantity': qty,
|
||||
'unit': unit,
|
||||
'rawName': ing.rawName,
|
||||
if ((ing.rawLine ?? '').trim().isNotEmpty) 'rawLine': ing.rawLine,
|
||||
if (productId != null) 'productId': productId,
|
||||
if (qty > 0) 'quantity': qty,
|
||||
if (unit.isNotEmpty) 'unit': unit,
|
||||
if (note.isNotEmpty) 'note': note,
|
||||
if (alternativeProductIds.isNotEmpty)
|
||||
'alternativeProductIds': alternativeProductIds,
|
||||
|
||||
@@ -396,6 +396,10 @@ class _RecipeBody extends StatelessWidget {
|
||||
const SizedBox(height: 12),
|
||||
...recipe.ingredients.map((ing) {
|
||||
final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity);
|
||||
final ingredientLabel = (ing.rawName.trim().isNotEmpty
|
||||
? ing.rawName
|
||||
: (ing.productName ?? '').trim())
|
||||
.trim();
|
||||
final measureParts = [
|
||||
if (qtyStr.isNotEmpty) qtyStr,
|
||||
if (ing.unit.isNotEmpty) ing.unit,
|
||||
@@ -430,8 +434,8 @@ class _RecipeBody extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
ing.note != null
|
||||
? '${ing.productName} (${ing.note})'
|
||||
: ing.productName,
|
||||
? '$ingredientLabel (${ing.note})'
|
||||
: ingredientLabel,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user