diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a333f53b..a8686bf5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,7 @@ import { RecipesModule } from './recipes/recipes.module'; import { QuickImportModule } from './quick-import/quick-import.module'; import { PantryModule } from './pantry/pantry.module'; import { MealPlanModule } from './meal-plan/meal-plan.module'; +import { ReceiptImportModule } from './receipt-import/receipt-import.module'; @Module({ @@ -19,6 +20,7 @@ import { MealPlanModule } from './meal-plan/meal-plan.module'; QuickImportModule, PantryModule, MealPlanModule, + ReceiptImportModule, ], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts new file mode 100644 index 00000000..02e51d71 --- /dev/null +++ b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts @@ -0,0 +1,8 @@ +export interface ParsedReceiptItem { + rawName: string; + quantity: number; + unit: string; + price?: number | null; + matchedProductId?: number; + matchedProductName?: string; +} diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts new file mode 100644 index 00000000..ec369d3d --- /dev/null +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -0,0 +1,45 @@ +import { + Controller, + Post, + UploadedFile, + UseInterceptors, + BadRequestException, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { ReceiptImportService } from './receipt-import.service'; +import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; + +const ALLOWED_MIMES = [ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/heic', + 'image/heif', +]; + +@Controller('receipt-import') +export class ReceiptImportController { + constructor(private readonly receiptImportService: ReceiptImportService) {} + + @Post() + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB + }), + ) + async parseReceipt( + @UploadedFile() file?: Express.Multer.File, + ): Promise { + if (!file?.buffer) { + throw new BadRequestException('Ingen fil skickades med.'); + } + if (!ALLOWED_MIMES.includes(file.mimetype)) { + throw new BadRequestException( + 'Otillåten filtyp. Använd JPEG, PNG eller WebP.', + ); + } + return this.receiptImportService.parseReceipt(file); + } +} diff --git a/backend/src/receipt-import/receipt-import.module.ts b/backend/src/receipt-import/receipt-import.module.ts new file mode 100644 index 00000000..2433e510 --- /dev/null +++ b/backend/src/receipt-import/receipt-import.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ReceiptImportController } from './receipt-import.controller'; +import { ReceiptImportService } from './receipt-import.service'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [ReceiptImportController], + providers: [ReceiptImportService], +}) +export class ReceiptImportModule {} diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts new file mode 100644 index 00000000..587173a1 --- /dev/null +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -0,0 +1,122 @@ +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; + +const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; +const PROMPT = `Du är en kvittoläsare. Analysera detta kvitto och returnera ENDAST en JSON-array med alla köpta varor. +Varje vara ska ha följande fält: +- "rawName": varans namn som det står på kvittot (sträng) +- "quantity": antal eller mängd som ett tal (t.ex. 1, 2, 0.5) +- "unit": enhet — välj ett av: "st", "kg", "g", "l", "dl", "cl", "ml", "förp", "pak", "burk", "flaska" +- "price": pris i SEK som ett tal, eller null + +Returnera BARA JSON-arrayen utan markdown-formatering.`; + +@Injectable() +export class ReceiptImportService { + private readonly logger = new Logger(ReceiptImportService.name); + + constructor(private readonly prisma: PrismaService) {} + + async parseReceipt(file: Express.Multer.File): Promise { + const apiKey = process.env.MISTRAL_API_KEY; + if (!apiKey) { + throw new ServiceUnavailableException( + 'MISTRAL_API_KEY är inte konfigurerad i miljövariabler', + ); + } + + const base64 = file.buffer.toString('base64'); + const mimeType = file.mimetype || 'image/jpeg'; + + const response = await fetch(MISTRAL_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: 'pixtral-12b-2409', + messages: [ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: `data:${mimeType};base64,${base64}` }, + }, + { type: 'text', text: PROMPT }, + ], + }, + ], + max_tokens: 2000, + temperature: 0.1, + }), + }); + + if (!response.ok) { + const err = await response.text(); + this.logger.error(`Mistral API svarade ${response.status}: ${err}`); + throw new ServiceUnavailableException('Mistral API returnerade ett fel — kontrollera API-nyckeln'); + } + + const data = (await response.json()) as { + choices: { message: { content: string } }[]; + }; + const content = data.choices?.[0]?.message?.content ?? '[]'; + + let rawItems: ParsedReceiptItem[]; + try { + const clean = content.replace(/```(?:json)?/gi, '').trim(); + rawItems = JSON.parse(clean); + if (!Array.isArray(rawItems)) throw new Error('Inte en array'); + } catch { + this.logger.error('Kunde inte parsa Mistral-svar:', content); + throw new BadRequestException( + 'Kvittot kunde inte tolkas. Försök med en tydligare bild.', + ); + } + + return this.matchProducts(rawItems); + } + + private async matchProducts( + items: ParsedReceiptItem[], + ): Promise { + const products = await this.prisma.product.findMany({ + select: { id: true, name: true, canonicalName: true }, + }); + + return items.map((item) => { + const raw = (item.rawName ?? '').toLowerCase().trim(); + if (!raw) return item; + + // Exakt matchning först + let match = products.find((p) => { + const n = (p.canonicalName ?? p.name).toLowerCase(); + return n === raw || p.name.toLowerCase() === raw; + }); + + // Delvis matchning + if (!match) { + match = products.find((p) => { + const n = (p.canonicalName ?? p.name).toLowerCase(); + return n.includes(raw) || raw.includes(n); + }); + } + + return { + ...item, + matchedProductId: match?.id, + matchedProductName: match + ? (match.canonicalName ?? match.name) + : undefined, + }; + }); + } +} diff --git a/compose.yml b/compose.yml index 0656faeb..aa89f4eb 100644 --- a/compose.yml +++ b/compose.yml @@ -38,6 +38,7 @@ services: environment: NODE_ENV: "production" DATABASE_URL: "mysql://root:${MARIADB_ROOT_PASSWORD}@recipe-db:3306/${MARIADB_DATABASE}" + MISTRAL_API_KEY: "${MISTRAL_API_KEY}" volumes: - recipe_images:/app/recipe-images depends_on: diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx index 95b3632f..04a999d7 100644 --- a/frontend/app/Navigation.tsx +++ b/frontend/app/Navigation.tsx @@ -119,6 +119,21 @@ export default function Navigation() { > 📅 Matplan + + 🧾 Importera kvitto + ); } diff --git a/frontend/app/api/receipt-import-proxy/route.ts b/frontend/app/api/receipt-import-proxy/route.ts new file mode 100644 index 00000000..3eaa9191 --- /dev/null +++ b/frontend/app/api/receipt-import-proxy/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = + process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function POST(request: NextRequest) { + const formData = await request.formData(); + + const res = await fetch(`${API_BASE}/api/receipt-import`, { + method: 'POST', + body: formData, + }); + + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/frontend/app/kvitto/ReceiptImportClient.tsx b/frontend/app/kvitto/ReceiptImportClient.tsx new file mode 100644 index 00000000..6f444922 --- /dev/null +++ b/frontend/app/kvitto/ReceiptImportClient.tsx @@ -0,0 +1,274 @@ +'use client'; + +import { useRef, useState } from 'react'; + +type ParsedItem = { + rawName: string; + quantity: number; + unit: string; + price?: number | null; + matchedProductId?: number; + matchedProductName?: string; +}; + +type RowState = ParsedItem & { checked: boolean; editQty: string; editUnit: string }; + +const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska']; + +export default function ReceiptImportClient() { + const fileRef = useRef(null); + const [preview, setPreview] = useState(null); + const [parsing, setParsing] = useState(false); + const [saving, setSaving] = useState(false); + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + const [savedCount, setSavedCount] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setSelectedFile(file); + setPreview(URL.createObjectURL(file)); + setRows([]); + setError(null); + setSavedCount(null); + }; + + const handleParse = async () => { + if (!selectedFile) return; + setParsing(true); + setError(null); + try { + const fd = new FormData(); + fd.append('file', selectedFile); + const res = await fetch('/api/receipt-import-proxy', { method: 'POST', body: fd }); + if (!res.ok) { + const e = await res.json().catch(() => ({ message: 'Okänt fel' })); + throw new Error(e.message ?? 'Servern svarade med fel'); + } + const items: ParsedItem[] = await res.json(); + setRows( + items.map((item) => ({ + ...item, + checked: !!item.matchedProductId, + editQty: String(item.quantity), + editUnit: item.unit, + })), + ); + } catch (err) { + setError(err instanceof Error ? err.message : 'Kunde inte tolka kvittot'); + } finally { + setParsing(false); + } + }; + + const updateRow = (i: number, patch: Partial) => { + setRows((prev) => prev.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + }; + + const handleSave = async () => { + const toSave = rows.filter((r) => r.checked && r.matchedProductId); + if (toSave.length === 0) return; + setSaving(true); + setError(null); + try { + await Promise.all( + toSave.map((r) => + fetch('/api/inventory', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId: r.matchedProductId, + quantity: parseFloat(r.editQty) || r.quantity, + unit: r.editUnit, + }), + }), + ), + ); + setSavedCount(toSave.length); + setRows([]); + setPreview(null); + setSelectedFile(null); + if (fileRef.current) fileRef.current.value = ''; + } catch { + setError('Något gick fel vid sparning. Försök igen.'); + } finally { + setSaving(false); + } + }; + + const checkedCount = rows.filter((r) => r.checked && r.matchedProductId).length; + const unmatchedCount = rows.filter((r) => !r.matchedProductId).length; + + return ( +
+ {/* Fil-input */} +
fileRef.current?.click()} + > + + {preview ? ( + // eslint-disable-next-line @next/next/no-img-element + Kvittoförhandsgranskning + ) : ( +
+
📷
+
Fotografera eller välj kvitto
+
+ Klicka för att välja bild (JPEG, PNG, WebP) +
+
+ )} +
+ + {preview && rows.length === 0 && ( + + )} + + {error && ( +

+ {error} +

+ )} + + {savedCount !== null && ( +

+ ✓ {savedCount} {savedCount === 1 ? 'vara lades till' : 'varor lades till'} i inventariet. +

+ )} + + {/* Parsade rader */} + {rows.length > 0 && ( +
+
+

+ Identifierade varor ({rows.length}) +

+ {unmatchedCount > 0 && ( + + {unmatchedCount} {unmatchedCount === 1 ? 'vara' : 'varor'} saknas i produktdatabasen + + )} +
+ +
+ {rows.map((row, i) => { + const matched = !!row.matchedProductId; + return ( +
+ updateRow(i, { checked: e.target.checked })} + style={{ width: '18px', height: '18px', cursor: matched ? 'pointer' : 'not-allowed' }} + title={!matched ? 'Produkten finns inte i databasen — lägg till den i admin först' : ''} + /> +
+
+ {row.matchedProductName ?? row.rawName} +
+ {row.matchedProductName && row.matchedProductName.toLowerCase() !== row.rawName.toLowerCase() && ( +
+ Kvitto: {row.rawName} +
+ )} + {!matched && ( +
+ Ingen matchning — {row.rawName} +
+ )} +
+ updateRow(i, { editQty: e.target.value })} + style={{ padding: '0.3rem 0.5rem', border: '1px solid #ced4da', borderRadius: '4px', width: '100%', fontSize: '0.9rem' }} + /> + +
+ ); + })} +
+ +
+ + +
+
+ )} +
+ ); +} + +function primaryBtn(disabled: boolean): React.CSSProperties { + return { + padding: '0.6rem 1.25rem', + background: disabled ? '#aaa' : '#0070f3', + color: '#fff', + border: 'none', + borderRadius: '6px', + cursor: disabled ? 'not-allowed' : 'pointer', + fontWeight: 600, + fontSize: '0.95rem', + }; +} diff --git a/frontend/app/kvitto/page.tsx b/frontend/app/kvitto/page.tsx new file mode 100644 index 00000000..804cdda8 --- /dev/null +++ b/frontend/app/kvitto/page.tsx @@ -0,0 +1,16 @@ +import Navigation from '../Navigation'; +import ReceiptImportClient from './ReceiptImportClient'; + +export default function KvittoPage() { + return ( +
+ +

Importera kvitto

+

+ Fotografera eller ladda upp ett kvitto — varorna läggs till i ditt + inventarie. +

+ +
+ ); +}