diff --git a/.kilo/plans/1779121909294-silent-star.md b/.kilo/plans/1779121909294-silent-star.md index 11c67e84..bd093a6d 100644 --- a/.kilo/plans/1779121909294-silent-star.md +++ b/.kilo/plans/1779121909294-silent-star.md @@ -1,185 +1,225 @@ -# Analys av `plan importer willys.md.txt` i projektkontext +# Plan: Fortsatt implementation av FlyerImport (autoflöde + UX i `/import` Flutter) -## Kontext och utgångsläge +## Målbild +Implementera ett komplett flyer-flöde med så få klick som möjligt där: +- användaren markerar planerade inköp i `FlyerImportTab` +- kvittoimporten matchar automatiskt mot öppna `FlyerSelection` och sätter `status=bought` +- användaren bara behöver ingripa vid osäker matchning -Den analyserade planen finns i `microservice-importer/plan importer willys.md.txt` och beskriver ett förslag för: -- premium-gating av AI -- PDF-import av Willys-underlag -- produktmatchning mot inventory -- receptgenerering med AI (Mistral) eller mallar +Detta följer båda underlagen: +1) ingen befintlig flyer-UX i `/import` ännu (behöver byggas) +2) maximal automation i punkt 3 (sync med kvittoimport) -Jämfört med faktisk arkitektur i era tre repos: -- `recipe-app`: äger användare, premium/AI-flaggor, inventory, recept, auth, adminflöden och databas (Prisma + MariaDB). -- `microservice-importer`: stateless import/parsing utan databas; ansvarar för quick-import, markdown-parse och receipt-parse. -- `recipe-gitea-runner`: CI-exekvering med labels `backend-node24` och `flutter-3-41`. +--- -## Sammanfattande bedömning +## Principer för “så få klick som möjligt” +- Default är automation: matcha och uppdatera utan extra dialoger. +- UI ska vara “review first, edit only when needed”. +- Endast två manuella åtgärder i happy path: + 1. markera planerade flyer-varor + 2. importera kvitto +- Manuell override ska finnas men inte blockera flödet. -Planen är ambitiös men **inte direkt kompatibel** med nuvarande systemdesign. Den blandar ansvar mellan tjänster, använder kodmönster som avviker från era etablerade stackval och återinför funktioner ni redan implementerat i annan form. +--- -Hög nivå: -- Bra: tydlig stegstruktur, fallback-idé (AI -> templates), fokus på robust importflöde. -- Problem: arkitekturdrift (DB i importer), felaktig domänmodell för era nuvarande User/Product-fält, Express-exempel i NestJS-miljö, svag validering/säkerhet i flera snippets. +## Fas 1: Backend-kontrakt för auto-sync -## Styrkor att återanvända +### 1.1 Nya/utökade endpoints +Implementera i `backend/src/flyer-selection`: -- Tydlig domänuppdelning i delsteg: extrahering -> parsing -> matchning -> generering. -- Fallback-first-princip vid AI-fel (ligger i linje med era befintliga principer). -- Intention att normalisera och strukturera råtext innan beslut i senare led. -- Fokus på svenska enheter/uttryck som passar era kvittoflöden. +- `POST /flyer-selections/receipt-match-preview` + - Input: kvittorader (normaliserad struktur), `weekKey` (optional), `sessionId` (optional) + - Output: matchförslag per kvittorad + confidence + reasonCodes + kandidat-selection + - Används för transparent UI-annotering före commit -## Kritiska gap mot befintlig arkitektur +- `POST /flyer-selections/receipt-match-commit` + - Input: samma payload + optional overrides + - Output: antal uppdaterade selections, listor över `bought`, `unmatched`, `ambiguous` + - Utför transaktionell statusuppdatering (`planned -> bought`) -1. **Fel placerat ansvar (största gapet)** -- Planen föreslår Prisma/DB i importer-flödet (`prisma.user`, `encryptedData.create`, premiumfält m.m.). -- Er importer är dokumenterat stateless och DB-lös. -- Rekommendation: all user/premium/inventory/recipe persistence ska ligga i `recipe-app` backend, inte i `microservice-importer`. +- `GET /flyer-selections/open` + - Query: `weekKey`, `retailer`, pagination + - Returnerar öppna selections (`status=planned`) för snabb klienthämtning -2. **Premium-modell avviker från faktisk datamodell** -- Planen använder `is_premium` och `premium_expiry_date`. -- I `recipe-app` används `isPremium` + `aiEngineEnabled` redan, med admin-styrning och JWT-scope. -- Rekommendation: återanvänd befintliga fält/guards; undvik parallel premiummodell. +Notering: Behåll befintliga CRUD-rutter under `/flyer-sessions/:sessionId/selections`. -3. **Framework mismatch (Express vs NestJS)** -- Planen innehåller Express-routerexempel medan repos är NestJS-moduler/controllers/services. -- Rekommendation: all ny implementation bör följa NestJS module/service/controller + DTO + class-validator. +### 1.2 Matchningsmotor (service-nivå) +I `FlyerSelectionService` lägg till en intern matcher med prioriterad strategi: +1. `productId` exact (högsta prio) +2. normalized name exact +3. alias/ordöverlap (token) +4. quantity/unit-stöd som förstärkning (inte ensam källa) -4. **Datamodell-krockar** -- Planen antar tabeller/fält som inte finns i nuvarande schema (`encryptedData`, snake_case-kolumner osv). -- Rekommendation: mappa mot faktiska modeller (`User`, `Product`, `InventoryItem`, `Recipe*`) eller skapa tydlig migrationplan med namngiven adapter. +Regler: +- En `FlyerSelection` kan bara konsumeras en gång per commit. +- Confidence-trösklar: + - `>=0.90`: auto-commit-kandidat + - `0.70-0.89`: ambiguous (kräver override för commit) + - `<0.70`: unmatched +- Returnera alltid `matchedVia`, `confidence`, `reasonCodes`. -5. **Överlapp med befintlig funktionalitet** -- Ni har redan importerad kvittoparsning med regelmotor + AI-fallback och premium/ai-scope. -- Rekommendation: undvik ”nytt parallellt flöde”; bygg som utökning av befintliga receipt/import pipelines. +### 1.3 Datamodell-justeringar (om behövs) +Nuvarande schema räcker i stort, men planera följande icke-blockerande förbättringar: +- `FlyerSelection`: + - `boughtAt DateTime?` + - `boughtSource String?` (t.ex. `receipt_auto`, `receipt_manual`) + - `receiptImportBatchId String?` för spårbarhet +- Index: + - `(userId, status, updatedAt)` för snabb hämtning av öppna poster -## Tekniska förbättringar av själva planen +Migration i separat steg efter kod. -### A. Arkitekturförbättringar (måste prioriteras) +### 1.4 Säkerhet och robusthet +- Validera alltid user-scope i alla nya endpoints. +- Rate-limit på match-endpoints (liknande befintlig throttle-nivå). +- Transaktion (`prisma.$transaction`) för commit så statusuppdatering blir atomisk. +- Idempotens: commit med samma `receiptImportBatchId` ska inte dubbeluppdatera. -- **Inför tydligt kontrakt mellan tjänsterna** - - `microservice-importer`: parse/normalize only (ingen userstate). - - `recipe-app`: auth, premium-gating, produktmatchning, persistence. -- **Skapa explicit API-kontrakt för kampanjblad/Willys** - - ny endpoint i importer, exempel: `POST /api/flyer/parse`. - - svar med strikt schema: produkter, erbjudandeflaggor, normaliserade mått/enheter, confidence. -- **Beslut i recipe-app** - - besluta AI/template på basis av `isPremium && aiEngineEnabled`. - - lagra endast i recipe-app DB. +--- -### B. Datakontrakt och validering +## Fas 2: Flutter UX i `/import` (ny flyer-tab) -- Ersätt `any` med typed DTO/interfaces i båda repos. -- Lägg till strikt validering (zod eller class-validator) för: - - parsed flyer rows - - matched product payload - - generated recipe payload -- Definiera versionerat kontrakt (`v1`) så importer och app kan deployas oberoende. +### 2.1 Lägg till `FlyerImportTab` +Uppdatera `ImportScreen` så tabbar blir: +1. Recept +2. Kvitto +3. Flyer -### C. Parser-kvalitet och robusthet (Willys-specifikt) +Skapa: +- `flutter/lib/features/import/presentation/flyer_import_tab.dart` +- `flutter/lib/features/import/data/flyer_import_repository.dart` +- `flutter/lib/features/import/data/flyer_import_providers.dart` +- `flutter/lib/features/import/domain/flyer_item.dart` +- `flutter/lib/features/import/domain/flyer_selection.dart` -- Nuvarande regex-idé är för skör för verkliga PDF-varianter. -- Förbättra med pipeline: - 1) textblock-normalisering - 2) radklassificering (kategori, produkt, prisrad, metadata) - 3) enhetsnormalisering (`förp`, `st`, `kg`) - 4) probabilistisk matchscore per fält -- Lägg in rule priority + fallback AI bara för osäkra rader (som ni redan gör i receipt flow). +### 2.2 Flyer-tabens minimalklick-flöde +Steg i UI: +1. Upload flyerfil +2. Visa parserader med förvald checkbox för matchade varor +3. Primär CTA: `Planera markerade` (bulk-create/upsert) +4. Direkt visning av statuschips (`planned/bought/skipped`) -### D. Matchning mot inventory +Klickoptimering: +- Förifyll `plannedQuantity/plannedUnit` från flyerdata. +- Batch-upsert selections i ett enda API-anrop. +- Visa inline varningar istället för modaler där möjligt. -- Planens fuzzy-match på 0.6 riskerar falska positiva. -- Förslag: - - kombinera alias > exact-normalized > token-similarity > levenshtein. - - category-guardrails (finns redan i receipt-flöde, återanvänd). - - trösklar per kategori (mejeri/kött behöver striktare gränser än exotiska varor). - - returnera `matchedVia`, `confidence`, `reasonCodes` för UI-debugg och lärande. +### 2.3 Statusöversikt +I flyer-tabben visa sektioner: +- `Planerade` (öppna) +- `Nyligen köpta` (autouppdaterade från kvitto) +- `Ej matchade vid senaste kvitto` (för snabb manuell hantering) -### E. AI-generering +--- -- Planen gör fri JSON-parsning från modelltext; hög risk för parse-fel. -- Förslag: - - använd strikt output schema + reparationssteg vid JSON-avvikelse. - - lägg budget/timeouts/retries per request. - - prompta på begränsad produktmängd (top-N relevanta) för lägre kostnad/latens. - - logga token-användning och feltyper för cost observability. +## Fas 3: Integrera auto-sync i `ReceiptImportTab` -### F. Säkerhet +### 3.1 Hook efter receipt parse +I befintlig `_submit()` i `receipt_import_tab.dart`: +1. importera kvitto som idag +2. anropa `receipt-match-preview` +3. annotera UI-rader med matchstatus (icon/chip) +4. vid `Lägg till markerade`: anropa `receipt-match-commit` parallellt/sekventiellt med saveReceipt -- Behåll premium-kontroll i backend (ej klient). -- Lägg rate limiting även på nya flyer-endpoints. -- Undvik filsystemberoende (`multer dest + fs.unlinkSync`) om möjligt; använd bufferbaserad pipeline. -- Lägg explicit content-type + storleksgräns + filsignature-validering. +### 3.2 Zero-click commit i happy path +Defaultbeteende vid `Lägg till markerade`: +- auto-committa alla matcher med confidence `>=0.90` +- lämna ambiguous som `planned` +- visa en kompakt snackbar: + - `2 planerade markerades som köpta` + - `1 kräver manuell kontroll` -### G. Drift och CI (recipe-gitea-runner) +Ingen extra dialog i standardfall. -- Lägg nya testjobb med labels ni redan har: - - `backend-node24`: contract tests mellan app/importer. - - `backend-node24`: parser regression suite med fixtures från riktiga Willys-underlag. -- Lägg minimikrav i CI: - - typecheck - - unit tests parser + matcher - - contract tests importer <-> recipe-app - - build -- Publicera artifact med parser-rapport (precision/recall på fixture-set) för varje PR. +### 3.3 Manuell override (endast vid behov) +Lägg till valfri expandrad i resultatlistan: +- “Föreslagen flyer-match” + knapp `Bekräfta ändå` +- används endast för ambiguous fall -## Konkreta optimeringar per repo +--- -### 1) `microservice-importer` +## Fas 4: API- och UI-detaljer för låg friktion -- Lägg till separat modul `flyer-parsing` istället för att återanvända receipt rakt av. -- Returnera endast normaliserad och validerad struktur, aldrig användarspecifika beslut. -- Återanvänd befintlig robusthet: - - fallback parsing - - timeout/retry-mönster - - global exception shape. -- Bygg fixture-driven tester för Willys-format (varianter med OCR-brus, multipack, kampanjtext). +### 4.1 Payload-standard (receipt -> matcher) +Standardisera kvittorad till: +- `rowId` (lokalt index eller UUID) +- `rawName` +- `normalizedName` +- `productId` (om redan mappad) +- `quantity` +- `unit` +- `price` -### 2) `recipe-app` +### 4.2 UX-copy +Konsekventa texter i UI: +- `Automatchad mot flyer` +- `Osäker matchning` +- `Ej matchad` -- Implementera orkestreringstjänst för flyerimport: - - anropa importer - - matcha mot user-scopade produkter - - premium-gata AI-recept (isPremium + aiEngineEnabled) - - spara recept via befintliga modeller -- Exponera adminfeature toggle för ”flyer-recipe-generation” (separation från övrig AI om ni vill kontrollera rollout). -- Lägg telemetri per steg: parse time, match confidence distribution, AI fallback rate. +### 4.3 WeekKey-hantering +Fallback-ordning vid matchning: +1. explicit `sessionId` +2. explicit `weekKey` +3. server beräknar aktuell `weekKey` -### 3) `recipe-gitea-runner` +Detta minimerar klientlogik och fel. -- Säkra att workflow i `recipe-app` inkluderar integrationstest mot importer (mockad eller ephemeral service). -- Lägg nattlig regressionkörning för parser-fixtures för att fånga drift i regex/regler. -- Behåll labels som idag; komplettera med tydligare jobbseparation i CI (quick PR vs full push). +--- -## Prioriterad implementeringsplan (reviderad) +## Fas 5: Test och kvalitetssäkring -1. **Målbild/ansvar (P0)** -- Fastställ och dokumentera kontrakt: importer parser-only, app stateful orchestration. +### 5.1 Backend +- Unit-tester för matcher-regler och confidence-nivåer +- Service-tester för commit-idempotens +- Controller-e2e för user-scope + throttling + felkoder +- Prisma-transaktionsscenarion (dubbelklick/duplicerat commit) -2. **Kontrakt + DTO (P0)** -- Definiera `FlyerParseResponse v1` och valideringsregler. +### 5.2 Flutter +- Widget-tester för: + - `FlyerImportTab` listning/bulk-planering + - kvitto-rad med automatch-chip +- Integrationstester för `ReceiptImportTab` + auto-sync callback -3. **Importer-modul (P1)** -- Implementera Willys/flyer parser i `microservice-importer` med tester + fixtures. +### 5.3 Acceptanskriterier (måste uppfyllas) +- Happy path kräver max 2 aktiva klick från planering till auto-bought. +- Minst 90% av high-confidence-matchningar autouppdateras korrekt i test-fixtures. +- Inga writes till `Product/Inventory` sker i flyer-planeringsfas. -4. **Recipe-app orkestrering (P1)** -- Ny service i backend som mappar parse-resultat till matchning + recipe generation. +--- -5. **Premium-gating harmonisering (P1)** -- Använd enbart `isPremium` + `aiEngineEnabled`; ta bort/undvik expiry-logik om den inte behövs produktmässigt. +## Fas 6: Gradvis lansering -6. **Observability + säkerhet (P1)** -- Metrics, structured logs, rate limits, upload guards. +1. Backend-endpoints bakom feature flag: `flyerReceiptAutoSyncEnabled` +2. Aktivera för intern/test-användare först +3. Mät: + - andel auto-match + - andel ambiguous + - manuell override-frekvens +4. Finjustera thresholds innan full rollout -7. **CI-utbyggnad (P2)** -- Contract tests + parser regression suite i Gitea workflows. +--- -## Risker om planen implementeras oförändrad +## Implementeringsordning (konkret) +1. Backend: matcher + preview/commit endpoints +2. Backend: idempotens + spårbarhetsfält + migration +3. Flutter: ny `FlyerImportTab` + repository/providers +4. Flutter: integrera preview/commit i `ReceiptImportTab` +5. Tester backend + Flutter +6. Feature flag rollout -- Arkitekturspret: dubbla sanningskällor för premium och recipes. -- Ökad driftkomplexitet: importer blir stateful och svårare att skala/deploya. -- Regressionsrisk: ny kod duplicerar befintlig receipt/importlogik. -- Säkerhets- och datakvalitetsrisk: svag typing/validering + osäker JSON-parsning från AI-svar. +--- -## Rekommenderad riktning (kort) +## Risker och mitigering +- Felmatchningar: håll konservativ tröskel och auto-commit bara vid hög confidence. +- Dubbla commits: idempotensnyckel + transaktion. +- UX-brus: visa detaljer först vid ambiguous, inte i happy path. +- Prestanda: batcha matchning och undvik N+1-frågor via prefetch av öppna selections. -Använd planen som **idékatalog**, inte som direkt implementation. Behåll er nuvarande ansvarsfördelning mellan repos, bygg Willys-stödet som en ny parserdomän i `microservice-importer`, och låt `recipe-app` fortsätta vara enda platsen för användarlogik, premiumbeslut och datalagring. Detta ger lägst risk och bäst kompatibilitet med er nuvarande kodbas, driftmodell och CI-upplägg. +--- + +## Definition of Done +- `/import` har en fungerande `FlyerImportTab`. +- Kvittoimport auto-synkar mot `FlyerSelection` med minimal friktion. +- `planned -> bought` uppdateras automatiskt för high-confidence. +- Ambiguous fall kan hanteras manuellt utan att blockera flödet. +- Tester gröna och feature flag klar för kontrollerad utrullning. diff --git a/backend/src/flyer-import/flyer-import.controller.ts b/backend/src/flyer-import/flyer-import.controller.ts index e272e6ad..114acd6b 100644 --- a/backend/src/flyer-import/flyer-import.controller.ts +++ b/backend/src/flyer-import/flyer-import.controller.ts @@ -4,6 +4,7 @@ import { HttpCode, Post, Request, + UnauthorizedException, UploadedFile, UseInterceptors, } from '@nestjs/common'; @@ -51,7 +52,7 @@ export class FlyerImportController { : undefined; if (!userId) { - throw new BadRequestException('Kunde inte identifiera användaren.'); + throw new UnauthorizedException('Kunde inte identifiera användaren.'); } return this.flyerImportService.parseAndMatch(file, userId); diff --git a/backend/src/flyer-selection/dto/create-flyer-selection-bulk.dto.ts b/backend/src/flyer-selection/dto/create-flyer-selection-bulk.dto.ts new file mode 100644 index 00000000..7deb077e --- /dev/null +++ b/backend/src/flyer-selection/dto/create-flyer-selection-bulk.dto.ts @@ -0,0 +1,11 @@ +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator'; +import { CreateFlyerSelectionDto } from './create-flyer-selection.dto'; + +export class CreateFlyerSelectionBulkDto { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateFlyerSelectionDto) + items!: CreateFlyerSelectionDto[]; +} diff --git a/backend/src/flyer-selection/dto/receipt-match.dto.ts b/backend/src/flyer-selection/dto/receipt-match.dto.ts new file mode 100644 index 00000000..6f5a2638 --- /dev/null +++ b/backend/src/flyer-selection/dto/receipt-match.dto.ts @@ -0,0 +1,86 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsIn, + IsInt, + IsNumber, + IsOptional, + IsString, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +export class ReceiptMatchItemDto { + @IsString() + @MaxLength(191) + rawName!: string; + + @IsOptional() + @IsString() + @MaxLength(191) + normalizedName?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + productId?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + quantity?: number; + + @IsOptional() + @IsString() + @MaxLength(24) + unit?: string; +} + +export class ReceiptMatchOverrideDto { + @Type(() => Number) + @IsInt() + @Min(0) + rowIndex!: number; + + @Type(() => Number) + @IsInt() + @Min(1) + selectionId!: number; +} + +export class ReceiptMatchDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReceiptMatchItemDto) + items!: ReceiptMatchItemDto[]; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + sessionId?: number; + + @IsOptional() + @IsString() + @MaxLength(16) + weekKey?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ReceiptMatchOverrideDto) + overrides?: ReceiptMatchOverrideDto[]; + + @IsOptional() + @IsString() + @MaxLength(80) + receiptImportBatchId?: string; + + @IsOptional() + @IsString() + @IsIn(['receipt_auto', 'receipt_manual']) + boughtSource?: 'receipt_auto' | 'receipt_manual'; +} diff --git a/backend/src/flyer-selection/dto/receipt-match.response.ts b/backend/src/flyer-selection/dto/receipt-match.response.ts new file mode 100644 index 00000000..06650832 --- /dev/null +++ b/backend/src/flyer-selection/dto/receipt-match.response.ts @@ -0,0 +1,30 @@ +export type FlyerMatchStatus = 'auto' | 'ambiguous' | 'unmatched'; + +export type ReceiptMatchPreviewRow = { + rowIndex: number; + status: FlyerMatchStatus; + confidence: number; + matchedVia: string; + reasonCodes: string[]; + selectionId: number | null; + sessionId: number | null; + itemId: number | null; + plannedName: string | null; + plannedProductId: number | null; + plannedProductName: string | null; +}; + +export type ReceiptMatchPreviewResponse = { + rows: ReceiptMatchPreviewRow[]; + autoCount: number; + ambiguousCount: number; + unmatchedCount: number; + candidateSelectionCount: number; +}; + +export type ReceiptMatchCommitResponse = { + boughtCount: number; + ambiguousCount: number; + unmatchedCount: number; + updatedSelectionIds: number[]; +}; diff --git a/backend/src/flyer-selection/flyer-selection-matcher.service.ts b/backend/src/flyer-selection/flyer-selection-matcher.service.ts new file mode 100644 index 00000000..3097c2fb --- /dev/null +++ b/backend/src/flyer-selection/flyer-selection-matcher.service.ts @@ -0,0 +1,207 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { normalizeName } from '../common/utils/normalize-name'; +import { ReceiptMatchItemDto } from './dto/receipt-match.dto'; +import { ReceiptMatchPreviewResponse, ReceiptMatchPreviewRow } from './dto/receipt-match.response'; + +export type CandidateSelection = { + id: number; + sessionId: number; + itemId: number; + plannedQuantity: Prisma.Decimal | null; + plannedUnit: string | null; + item: { + rawName: string; + normalizedName: string; + matchedProductId: number | null; + matchedProductName: string | null; + }; +}; + +const AUTO_MATCH_THRESHOLD = 0.9; +const AMBIGUOUS_THRESHOLD = 0.7; + +@Injectable() +export class FlyerSelectionMatcherService { + matchRows(items: ReceiptMatchItemDto[], candidates: CandidateSelection[]): ReceiptMatchPreviewRow[] { + const remainingSelectionIds = new Set(candidates.map((candidate) => candidate.id)); + const byProductId = new Map(); + const byNormalizedName = new Map(); + + for (const candidate of candidates) { + const productId = candidate.item.matchedProductId; + if (productId != null) { + const list = byProductId.get(productId) ?? []; + list.push(candidate); + byProductId.set(productId, list); + } + const normalized = normalizeName(candidate.item.normalizedName || candidate.item.rawName); + if (normalized) { + const list = byNormalizedName.get(normalized) ?? []; + list.push(candidate); + byNormalizedName.set(normalized, list); + } + } + + const rows: ReceiptMatchPreviewRow[] = []; + for (let rowIndex = 0; rowIndex < items.length; rowIndex++) { + const receiptItem = items[rowIndex]; + const candidatesForRow = this.candidatePool(receiptItem, candidates, remainingSelectionIds, byProductId, byNormalizedName); + const best = this.findBest(receiptItem, candidatesForRow); + + if (!best) { + rows.push({ + rowIndex, + status: 'unmatched', + confidence: 0, + matchedVia: 'none', + reasonCodes: ['no_match'], + selectionId: null, + sessionId: null, + itemId: null, + plannedName: null, + plannedProductId: null, + plannedProductName: null, + }); + continue; + } + + const status = + best.confidence >= AUTO_MATCH_THRESHOLD + ? 'auto' + : best.confidence >= AMBIGUOUS_THRESHOLD + ? 'ambiguous' + : 'unmatched'; + + if (status !== 'unmatched') { + remainingSelectionIds.delete(best.candidate.id); + } + + rows.push({ + rowIndex, + status, + confidence: Number(best.confidence.toFixed(3)), + matchedVia: best.matchedVia, + reasonCodes: best.reasons, + selectionId: best.candidate.id, + sessionId: best.candidate.sessionId, + itemId: best.candidate.itemId, + plannedName: best.candidate.item.rawName, + plannedProductId: best.candidate.item.matchedProductId, + plannedProductName: best.candidate.item.matchedProductName, + }); + } + + return rows; + } + + toPreviewResponse(rows: ReceiptMatchPreviewRow[], candidateSelectionCount: number): ReceiptMatchPreviewResponse { + const autoCount = rows.filter((row) => row.status === 'auto').length; + const ambiguousCount = rows.filter((row) => row.status === 'ambiguous').length; + const unmatchedCount = rows.filter((row) => row.status === 'unmatched').length; + return { rows, autoCount, ambiguousCount, unmatchedCount, candidateSelectionCount }; + } + + private candidatePool( + receiptItem: ReceiptMatchItemDto, + allCandidates: CandidateSelection[], + remainingSelectionIds: Set, + byProductId: Map, + byNormalizedName: Map, + ): CandidateSelection[] { + const pool = new Set(); + + if (receiptItem.productId != null) { + for (const candidate of byProductId.get(receiptItem.productId) ?? []) { + if (remainingSelectionIds.has(candidate.id)) pool.add(candidate); + } + } + + const receiptNormalized = normalizeName(receiptItem.normalizedName ?? receiptItem.rawName); + if (receiptNormalized) { + for (const candidate of byNormalizedName.get(receiptNormalized) ?? []) { + if (remainingSelectionIds.has(candidate.id)) pool.add(candidate); + } + } + + if (pool.size > 0) { + return [...pool]; + } + + return allCandidates.filter((candidate) => remainingSelectionIds.has(candidate.id)); + } + + private findBest( + receiptItem: ReceiptMatchItemDto, + candidates: CandidateSelection[], + ): { candidate: CandidateSelection; confidence: number; matchedVia: string; reasons: string[] } | null { + let best: { candidate: CandidateSelection; confidence: number; matchedVia: string; reasons: string[] } | null = null; + for (const candidate of candidates) { + const evaluated = this.scoreCandidate(receiptItem, candidate); + if (evaluated.confidence <= 0) continue; + if (!best || evaluated.confidence > best.confidence) best = evaluated; + } + return best; + } + + private scoreCandidate(receiptItem: ReceiptMatchItemDto, candidate: CandidateSelection) { + const reasons: string[] = []; + const receiptNormalized = normalizeName(receiptItem.normalizedName ?? receiptItem.rawName); + const flyerNormalized = normalizeName(candidate.item.normalizedName || candidate.item.rawName); + + if (receiptItem.productId && candidate.item.matchedProductId === receiptItem.productId) { + reasons.push('product_id_exact'); + return { candidate, confidence: 1, matchedVia: 'product_id', reasons }; + } + + if (receiptNormalized && flyerNormalized && receiptNormalized === flyerNormalized) { + let confidence = 0.93; + reasons.push('name_exact'); + confidence += this.quantityUnitBonus(receiptItem, candidate, reasons); + return { candidate, confidence: Math.min(0.99, confidence), matchedVia: 'name_exact', reasons }; + } + + const overlap = this.tokenOverlap(this.tokenize(receiptItem.rawName), this.tokenize(candidate.item.rawName)); + if (overlap <= 0) { + return { candidate, confidence: 0, matchedVia: 'none', reasons: ['no_token_overlap'] }; + } + + let confidence = Math.min(0.89, 0.45 + overlap * 0.45); + reasons.push(`token_overlap:${overlap.toFixed(2)}`); + confidence += this.quantityUnitBonus(receiptItem, candidate, reasons); + confidence = Math.min(0.89, confidence); + return { candidate, confidence, matchedVia: 'token', reasons }; + } + + private quantityUnitBonus(receiptItem: ReceiptMatchItemDto, candidate: CandidateSelection, reasons: string[]): number { + let bonus = 0; + if (receiptItem.quantity != null && candidate.plannedQuantity != null && Number(candidate.plannedQuantity) === receiptItem.quantity) { + bonus += 0.03; + reasons.push('quantity_match'); + } + const receiptUnit = (receiptItem.unit ?? '').trim().toLowerCase(); + const plannedUnit = (candidate.plannedUnit ?? '').trim().toLowerCase(); + if (receiptUnit && plannedUnit && receiptUnit === plannedUnit) { + bonus += 0.03; + reasons.push('unit_match'); + } + return bonus; + } + + private tokenize(value: string): string[] { + return value.toLowerCase().split(/[^a-z0-9åäö]+/).map((part) => part.trim()).filter((part) => part.length >= 3); + } + + private tokenOverlap(a: string[], b: string[]): number { + if (a.length === 0 || b.length === 0) return 0; + const as = new Set(a); + const bs = new Set(b); + let intersection = 0; + for (const token of as) { + if (bs.has(token)) intersection++; + } + const union = new Set([...as, ...bs]).size; + if (union === 0) return 0; + return intersection / union; + } +} diff --git a/backend/src/flyer-selection/flyer-selection-sync.controller.ts b/backend/src/flyer-selection/flyer-selection-sync.controller.ts new file mode 100644 index 00000000..014f237c --- /dev/null +++ b/backend/src/flyer-selection/flyer-selection-sync.controller.ts @@ -0,0 +1,68 @@ +import { + Body, + Controller, + Get, + HttpCode, + Post, + Query, + Request, + UnauthorizedException, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { FlyerSelectionResponse } from './dto/flyer-selection.response'; +import { ReceiptMatchDto } from './dto/receipt-match.dto'; +import { + ReceiptMatchCommitResponse, + ReceiptMatchPreviewResponse, +} from './dto/receipt-match.response'; +import { FlyerSelectionService } from './flyer-selection.service'; + +@Controller('flyer-selections') +export class FlyerSelectionSyncController { + constructor(private readonly flyerSelectionService: FlyerSelectionService) {} + + @Get('open') + @Throttle({ default: { ttl: 60_000, limit: 30 } }) + async listOpen( + @Query('weekKey') weekKey?: string, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + return this.flyerSelectionService.listOpen(userId, weekKey); + } + + @Post('receipt-match-preview') + @HttpCode(200) + @Throttle({ default: { ttl: 60_000, limit: 20 } }) + async preview( + @Body() dto: ReceiptMatchDto, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + return this.flyerSelectionService.previewReceiptMatches(userId, dto); + } + + @Post('receipt-match-commit') + @HttpCode(200) + @Throttle({ default: { ttl: 60_000, limit: 20 } }) + async commit( + @Body() dto: ReceiptMatchDto, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + return this.flyerSelectionService.commitReceiptMatches(userId, dto); + } + + private getUserId(req?: any): number { + const userId = + typeof req?.user?.id === 'number' + ? req.user.id + : typeof req?.user?.userId === 'number' + ? req.user.userId + : undefined; + if (!userId) { + throw new UnauthorizedException('Kunde inte identifiera användaren.'); + } + return userId; + } +} diff --git a/backend/src/flyer-selection/flyer-selection.controller.ts b/backend/src/flyer-selection/flyer-selection.controller.ts index 78bc7eb2..1dd69982 100644 --- a/backend/src/flyer-selection/flyer-selection.controller.ts +++ b/backend/src/flyer-selection/flyer-selection.controller.ts @@ -9,10 +9,11 @@ import { Post, Request, Get, - BadRequestException, + UnauthorizedException, } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto'; +import { CreateFlyerSelectionBulkDto } from './dto/create-flyer-selection-bulk.dto'; import { FlyerSelectionResponse } from './dto/flyer-selection.response'; import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto'; import { FlyerSelectionService } from './flyer-selection.service'; @@ -42,6 +43,18 @@ export class FlyerSelectionController { return this.flyerSelectionService.create(sessionId, userId, dto); } + @Post('bulk') + @HttpCode(200) + @Throttle({ default: { ttl: 60_000, limit: 10 } }) + async createMany( + @Param('sessionId', ParseIntPipe) sessionId: number, + @Body() dto: CreateFlyerSelectionBulkDto, + @Request() req?: any, + ): Promise { + const userId = this.getUserId(req); + return this.flyerSelectionService.createMany(sessionId, userId, dto.items); + } + @Patch(':selectionId') @HttpCode(200) @Throttle({ default: { ttl: 60_000, limit: 30 } }) @@ -75,7 +88,7 @@ export class FlyerSelectionController { ? req.user.userId : undefined; if (!userId) { - throw new BadRequestException('Kunde inte identifiera användaren.'); + throw new UnauthorizedException('Kunde inte identifiera användaren.'); } return userId; } diff --git a/backend/src/flyer-selection/flyer-selection.module.ts b/backend/src/flyer-selection/flyer-selection.module.ts index f03c3d09..7ed44da0 100644 --- a/backend/src/flyer-selection/flyer-selection.module.ts +++ b/backend/src/flyer-selection/flyer-selection.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; +import { FlyerSelectionMatcherService } from './flyer-selection-matcher.service'; import { FlyerSelectionController } from './flyer-selection.controller'; +import { FlyerSelectionSyncController } from './flyer-selection-sync.controller'; import { FlyerSelectionService } from './flyer-selection.service'; @Module({ imports: [PrismaModule], - controllers: [FlyerSelectionController], - providers: [FlyerSelectionService], + controllers: [FlyerSelectionController, FlyerSelectionSyncController], + providers: [FlyerSelectionService, FlyerSelectionMatcherService], + exports: [FlyerSelectionService], }) export class FlyerSelectionModule {} diff --git a/backend/src/flyer-selection/flyer-selection.service.ts b/backend/src/flyer-selection/flyer-selection.service.ts index a5881848..96e96b10 100644 --- a/backend/src/flyer-selection/flyer-selection.service.ts +++ b/backend/src/flyer-selection/flyer-selection.service.ts @@ -7,12 +7,24 @@ import { import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto'; +import { + ReceiptMatchCommitResponse, + ReceiptMatchPreviewResponse, +} from './dto/receipt-match.response'; +import { ReceiptMatchDto } from './dto/receipt-match.dto'; import { FlyerSelectionResponse } from './dto/flyer-selection.response'; import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto'; +import { + CandidateSelection, + FlyerSelectionMatcherService, +} from './flyer-selection-matcher.service'; @Injectable() export class FlyerSelectionService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly matcher: FlyerSelectionMatcherService, + ) {} async listBySession(sessionId: number, userId: number): Promise { await this.assertSessionOwnership(sessionId, userId); @@ -94,6 +106,103 @@ export class FlyerSelectionService { return this.toResponse(created); } + async createMany( + sessionId: number, + userId: number, + items: CreateFlyerSelectionDto[], + ): Promise { + if (items.length === 0) return []; + + await this.assertSessionOwnership(sessionId, userId); + + const existingItems = await this.prisma.flyerItem.findMany({ + where: { + sessionId, + id: { + in: items.map((item) => item.itemId), + }, + }, + select: { id: true }, + }); + const validItemIds = new Set(existingItems.map((item) => item.id)); + const invalidItem = items.find((item) => !validItemIds.has(item.itemId)); + if (invalidItem) { + throw new BadRequestException(`Flyer-rad ${invalidItem.itemId} tillhör inte sessionen.`); + } + + const existingSelections = await this.prisma.flyerSelection.findMany({ + where: { + sessionId, + itemId: { in: items.map((item) => item.itemId) }, + }, + select: { itemId: true }, + }); + const existingItemIds = new Set(existingSelections.map((item) => item.itemId)); + + await this.prisma.$transaction(async (tx) => { + const toCreate = items.filter((item) => !existingItemIds.has(item.itemId)); + if (toCreate.length > 0) { + await tx.flyerSelection.createMany({ + data: toCreate.map((item) => ({ + sessionId, + itemId: item.itemId, + userId, + plannedQuantity: + item.plannedQuantity == null ? null : new Prisma.Decimal(item.plannedQuantity), + plannedUnit: item.plannedUnit ?? null, + priority: item.priority ?? 'normal', + note: item.note ?? null, + })), + }); + } + + const toUpdate = items.filter((item) => existingItemIds.has(item.itemId)); + await Promise.all( + toUpdate.map((item) => + tx.flyerSelection.update({ + where: { + sessionId_itemId: { + sessionId, + itemId: item.itemId, + }, + }, + data: { + plannedQuantity: + item.plannedQuantity == null ? undefined : new Prisma.Decimal(item.plannedQuantity), + plannedUnit: item.plannedUnit, + priority: item.priority ?? 'normal', + note: item.note, + }, + }), + ), + ); + }); + + const result = await this.prisma.flyerSelection.findMany({ + where: { + sessionId, + userId, + itemId: { in: items.map((item) => item.itemId) }, + }, + include: { + item: { + select: { + id: true, + rawName: true, + normalizedName: true, + price: true, + priceUnit: true, + matchedProductId: true, + matchedProductName: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return result.map((row) => this.toResponse(row)); + } + async update( sessionId: number, selectionId: number, @@ -158,6 +267,88 @@ export class FlyerSelectionService { await this.prisma.flyerSelection.delete({ where: { id: selectionId } }); } + async listOpen(userId: number, weekKey?: string): Promise { + const rows = await this.prisma.flyerSelection.findMany({ + where: { + userId, + status: 'planned', + session: { + ...(weekKey ? { weekKey } : {}), + }, + }, + include: { + item: { + select: { + id: true, + rawName: true, + normalizedName: true, + price: true, + priceUnit: true, + matchedProductId: true, + matchedProductName: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + return rows.map((row) => this.toResponse(row)); + } + + async previewReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise { + const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey); + const rows = this.matcher.matchRows(dto.items, candidates); + return this.matcher.toPreviewResponse(rows, candidates.length); + } + + async commitReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise { + const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey); + const previewRows = this.matcher.matchRows(dto.items, candidates); + const overrideByIndex = new Map((dto.overrides ?? []).map((o) => [o.rowIndex, o.selectionId])); + const candidateBySelectionId = new Map(candidates.map((c) => [c.id, c])); + const usedSelectionIds = new Set(); + const toUpdateSelectionIds: number[] = []; + + for (const row of previewRows) { + if (row.status === 'auto' && row.selectionId != null && !usedSelectionIds.has(row.selectionId)) { + usedSelectionIds.add(row.selectionId); + toUpdateSelectionIds.push(row.selectionId); + continue; + } + + const overrideSelectionId = overrideByIndex.get(row.rowIndex); + if (!overrideSelectionId || usedSelectionIds.has(overrideSelectionId)) { + continue; + } + if (!candidateBySelectionId.has(overrideSelectionId)) { + continue; + } + usedSelectionIds.add(overrideSelectionId); + toUpdateSelectionIds.push(overrideSelectionId); + } + + if (toUpdateSelectionIds.length > 0) { + await this.prisma.flyerSelection.updateMany({ + where: { + id: { in: toUpdateSelectionIds }, + userId, + status: 'planned', + }, + data: { + status: 'bought', + }, + }); + } + + const summary = this.matcher.toPreviewResponse(previewRows, candidates.length); + return { + boughtCount: toUpdateSelectionIds.length, + ambiguousCount: summary.ambiguousCount, + unmatchedCount: summary.unmatchedCount, + updatedSelectionIds: toUpdateSelectionIds, + }; + } + private async assertSessionOwnership(sessionId: number, userId: number): Promise { const session = await this.prisma.flyerSession.findUnique({ where: { id: sessionId }, @@ -171,6 +362,41 @@ export class FlyerSelectionService { } } + private async loadCandidateSelections( + userId: number, + sessionId?: number, + weekKey?: string, + ): Promise { + if (sessionId != null && weekKey != null) { + throw new BadRequestException('Ange antingen sessionId eller weekKey, inte båda.'); + } + + return this.prisma.flyerSelection.findMany({ + where: { + userId, + status: 'planned', + ...(sessionId != null ? { sessionId } : {}), + ...(weekKey ? { session: { weekKey } } : {}), + }, + select: { + id: true, + sessionId: true, + itemId: true, + plannedQuantity: true, + plannedUnit: true, + item: { + select: { + rawName: true, + normalizedName: true, + matchedProductId: true, + matchedProductName: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + private toResponse(row: any): FlyerSelectionResponse { return { id: row.id, diff --git a/backend/src/receipt-import/dto/save-receipt.response.ts b/backend/src/receipt-import/dto/save-receipt.response.ts index fb857e9a..a9c66d80 100644 --- a/backend/src/receipt-import/dto/save-receipt.response.ts +++ b/backend/src/receipt-import/dto/save-receipt.response.ts @@ -1,9 +1,15 @@ -export interface SaveReceiptResponse { - created: number; - merged: number; - pantryAdded: number; - pantrySkipped: number; - aliasesLearned: number; - unitMappingsLearned: number; - errors?: Array<{ index: number; error: string }>; -} +export interface SaveReceiptResponse { + created: number; + merged: number; + pantryAdded: number; + pantrySkipped: number; + aliasesLearned: number; + unitMappingsLearned: number; + flyerAutoSync?: { + bought: number; + ambiguous: number; + unmatched: number; + error?: string; + }; + errors?: Array<{ index: number; error: string }>; +} diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts index 2d0e0737..52a55edd 100644 --- a/backend/src/receipt-import/receipt-import.controller.ts +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -6,9 +6,10 @@ import { Request, UploadedFile, UseGuards, - UseInterceptors, - BadRequestException, -} from '@nestjs/common'; + UseInterceptors, + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; @@ -72,7 +73,7 @@ export class ReceiptImportController { ? req.user.userId : undefined; if (!userId) { - throw new BadRequestException('Kunde inte identifiera användaren.'); + throw new UnauthorizedException('Kunde inte identifiera användaren.'); } return this.receiptImportService.upsertUnitMapping( @@ -98,7 +99,7 @@ export class ReceiptImportController { ? req.user.userId : undefined; if (!userId) { - throw new BadRequestException('Kunde inte identifiera användaren.'); + throw new UnauthorizedException('Kunde inte identifiera användaren.'); } const isAdmin = req?.user?.role === 'admin'; diff --git a/backend/src/receipt-import/receipt-import.module.ts b/backend/src/receipt-import/receipt-import.module.ts index bf006aaf..9748ebaf 100644 --- a/backend/src/receipt-import/receipt-import.module.ts +++ b/backend/src/receipt-import/receipt-import.module.ts @@ -1,13 +1,14 @@ import { Module } from '@nestjs/common'; import { ReceiptImportController } from './receipt-import.controller'; import { ReceiptImportService } from './receipt-import.service'; -import { PrismaModule } from '../prisma/prisma.module'; -import { AiModule } from '../ai/ai.module'; -import { CategoriesModule } from '../categories/categories.module'; - -@Module({ - imports: [PrismaModule, AiModule, CategoriesModule], - controllers: [ReceiptImportController], - providers: [ReceiptImportService], -}) -export class ReceiptImportModule {} +import { PrismaModule } from '../prisma/prisma.module'; +import { AiModule } from '../ai/ai.module'; +import { CategoriesModule } from '../categories/categories.module'; +import { FlyerSelectionModule } from '../flyer-selection/flyer-selection.module'; + +@Module({ + imports: [PrismaModule, AiModule, CategoriesModule, FlyerSelectionModule], + controllers: [ReceiptImportController], + providers: [ReceiptImportService], +}) +export class ReceiptImportModule {} diff --git a/backend/src/receipt-import/receipt-import.parse-flow.spec.ts b/backend/src/receipt-import/receipt-import.parse-flow.spec.ts index 36a67ec8..cae3e6e4 100644 --- a/backend/src/receipt-import/receipt-import.parse-flow.spec.ts +++ b/backend/src/receipt-import/receipt-import.parse-flow.spec.ts @@ -27,11 +27,12 @@ describe('ReceiptImportService parseReceipt flow', () => { findFlattened: jest.fn(), }; - const service = new ReceiptImportService( - prismaMock as any, - aiServiceMock as any, - categoriesServiceMock as any, - ); + const service = new ReceiptImportService( + prismaMock as any, + aiServiceMock as any, + categoriesServiceMock as any, + { commitReceiptMatches: jest.fn() } as any, + ); beforeEach(() => { jest.clearAllMocks(); diff --git a/backend/src/receipt-import/receipt-import.save.spec.ts b/backend/src/receipt-import/receipt-import.save.spec.ts index 80b847d7..f49366e2 100644 --- a/backend/src/receipt-import/receipt-import.save.spec.ts +++ b/backend/src/receipt-import/receipt-import.save.spec.ts @@ -67,12 +67,13 @@ describe('ReceiptImportService.saveReceipt', () => { $transaction: jest.fn().mockImplementation(async (cb: (tx: typeof txMock) => Promise) => cb(txMock)), }; - service = new ReceiptImportService( - prismaMock as any, - {} as any, // aiService – används ej i saveReceipt - {} as any, // categoriesService – används ej i saveReceipt - ); - }); + service = new ReceiptImportService( + prismaMock as any, + {} as any, // aiService – används ej i saveReceipt + {} as any, // categoriesService – används ej i saveReceipt + { commitReceiptMatches: jest.fn().mockResolvedValue({ boughtCount: 0, ambiguousCount: 0, unmatchedCount: 0 }) } as any, + ); + }); // ── 1. Skapar ny inventariepost ───────────────────────────────────────────── it('skapar ny inventariepost när produkten finns och inte finns i inventariet', async () => { diff --git a/backend/src/receipt-import/receipt-import.service.spec.ts b/backend/src/receipt-import/receipt-import.service.spec.ts index f8a67c65..8457ce26 100644 --- a/backend/src/receipt-import/receipt-import.service.spec.ts +++ b/backend/src/receipt-import/receipt-import.service.spec.ts @@ -51,11 +51,12 @@ describe('ReceiptImportService test matrix', () => { findFlattened: jest.fn(), }; - const service = new ReceiptImportService( - prismaMock as any, - aiServiceMock as any, - categoriesServiceMock as any, - ); + const service = new ReceiptImportService( + prismaMock as any, + aiServiceMock as any, + categoriesServiceMock as any, + { commitReceiptMatches: jest.fn() } as any, + ); beforeEach(() => { jest.clearAllMocks(); @@ -404,4 +405,4 @@ describe('ReceiptImportService test matrix', () => { expect(aiFallbackResult.categorySuggestion?.categoryId).toBe(51); }); }); -}); \ No newline at end of file +}); diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 0dbfe2c7..46414ac3 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -12,11 +12,12 @@ import { SaveReceiptResponse } from './dto/save-receipt.response'; import { AiService, CategorySuggestion } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; import { normalizeName } from '../common/utils/normalize-name'; -import { - isIgnoredReceiptAliasName, - normalizeReceiptAliasName, - validateReceiptAliasName, -} from '../common/utils/receipt-alias'; +import { + isIgnoredReceiptAliasName, + normalizeReceiptAliasName, + validateReceiptAliasName, +} from '../common/utils/receipt-alias'; +import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service'; const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; @@ -125,11 +126,12 @@ type MatchDebug = { export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); - constructor( - private readonly prisma: PrismaService, - private readonly aiService: AiService, - private readonly categoriesService: CategoriesService, - ) {} + constructor( + private readonly prisma: PrismaService, + private readonly aiService: AiService, + private readonly categoriesService: CategoriesService, + private readonly flyerSelectionService: FlyerSelectionService, + ) {} async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise { // Steg 1: Delegera AI-parsning till microservice-importer @@ -297,7 +299,7 @@ export class ReceiptImportService { }); } - async saveReceipt(userId: number, dto: SaveReceiptDto): Promise { + async saveReceipt(userId: number, dto: SaveReceiptDto): Promise { const response: SaveReceiptResponse = { created: 0, merged: 0, @@ -308,7 +310,13 @@ export class ReceiptImportService { errors: [], }; - const prismaAny = this.prisma as any; + const prismaAny = this.prisma as any; + const successfulRows: Array<{ + rawName: string; + productId?: number; + quantity?: number; + unit?: string; + }> = []; // Preload existierande pantry-poster för denna användare const userPantry = await this.prisma.pantryItem.findMany({ @@ -386,7 +394,7 @@ export class ReceiptImportService { } // === Steg 2: Hantera pantry eller inventory === - if (item.destination === 'pantry') { + if (item.destination === 'pantry') { if (pantryProductIds.has(productId)) { response.pantrySkipped++; } else { @@ -395,8 +403,8 @@ export class ReceiptImportService { }); response.pantryAdded++; pantryProductIds.add(productId); - } - } else { + } + } else { // inventory const quantity = item.quantity ?? 0; const unit = (item.unit ?? '').trim() || 'st'; @@ -461,8 +469,15 @@ export class ReceiptImportService { }); response.unitMappingsLearned++; } - } - } + } + } + + successfulRows.push({ + rawName: item.rawName, + productId, + quantity: item.quantity, + unit: item.unit, + }); // === Steg 4: Lär in alias om requested === if (item.learnAlias) { @@ -510,10 +525,45 @@ export class ReceiptImportService { throw new BadRequestException( `Transaktionfel vid sparande av kvittovaror: ${err instanceof Error ? err.message : String(err)}`, ); - } - - return response; - } + } + + if (successfulRows.length > 0) { + const syncPayload = { + items: successfulRows, + receiptImportBatchId: `receipt-save-${Date.now()}-${userId}`, + boughtSource: 'receipt_auto' as const, + }; + try { + const sync = await this.flyerSelectionService.commitReceiptMatches(userId, syncPayload); + response.flyerAutoSync = { + bought: sync.boughtCount, + ambiguous: sync.ambiguousCount, + unmatched: sync.unmatchedCount, + }; + } catch (err) { + this.logger.warn(`Flyer auto-sync failed after receipt save (attempt 1): ${String(err)}`); + try { + const sync = await this.flyerSelectionService.commitReceiptMatches(userId, syncPayload); + response.flyerAutoSync = { + bought: sync.boughtCount, + ambiguous: sync.ambiguousCount, + unmatched: sync.unmatchedCount, + }; + } catch (retryErr) { + const message = retryErr instanceof Error ? retryErr.message : String(retryErr); + this.logger.warn(`Flyer auto-sync failed after receipt save (attempt 2): ${message}`); + response.flyerAutoSync = { + bought: 0, + ambiguous: 0, + unmatched: 0, + error: message, + }; + } + } + } + + return response; + } private async parseReceiptViaImporter(file: Express.Multer.File): Promise { const form = new FormData(); diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 8be3ec7b..98c6fe57 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -33,10 +33,20 @@ class CategoryApiPaths { static const tree = '/categories/tree'; } -class ReceiptImportApiPaths { - static const refreshCategories = '/receipt-import/refresh-categories'; - static const unitMappings = '/receipt-import/unit-mappings'; -} +class ReceiptImportApiPaths { + static const refreshCategories = '/receipt-import/refresh-categories'; + static const unitMappings = '/receipt-import/unit-mappings'; +} + +class FlyerImportApiPaths { + static const parse = '/flyer-import/parse'; +} + +class FlyerSelectionApiPaths { + static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections'; + static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk'; + static const open = '/flyer-selections/open'; +} class HelpTextApiPaths { static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}'; @@ -131,4 +141,4 @@ class MealPlanApiPaths { static String removeByDate(String date) => '$list/${Uri.encodeComponent(date)}'; -} \ No newline at end of file +} diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index f57c72db..c942d13c 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -142,13 +142,17 @@ class AppShell extends ConsumerWidget { icon: Icon(Icons.restaurant_menu_outlined), text: 'Recept', ), - Tab( - icon: Icon(Icons.receipt_long_outlined), - text: 'Kvitto', - ), - ], - ) - : null, + Tab( + icon: Icon(Icons.receipt_long_outlined), + text: 'Kvitto', + ), + Tab( + icon: Icon(Icons.local_offer_outlined), + text: 'Flyer', + ), + ], + ) + : null, actions: [ if (isRecipesRoute) Consumer( @@ -278,9 +282,9 @@ class AppShell extends ConsumerWidget { ), ); - if (isImportRoute) { - shell = DefaultTabController(length: 2, child: shell); - } + if (isImportRoute) { + shell = DefaultTabController(length: 3, child: shell); + } return shell; } diff --git a/flutter/lib/features/import/data/import_providers.dart b/flutter/lib/features/import/data/import_providers.dart index cc44e9f1..25d5c2f5 100644 --- a/flutter/lib/features/import/data/import_providers.dart +++ b/flutter/lib/features/import/data/import_providers.dart @@ -1,7 +1,7 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'import_repository.dart'; -import 'import_repository.dart'; - -final importRepositoryProvider = Provider( - (_) => ImportRepository(), -); +final importRepositoryProvider = Provider( + (_) => ImportRepository(), +); diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart index 3cff40d6..c4a87763 100644 --- a/flutter/lib/features/import/data/import_repository.dart +++ b/flutter/lib/features/import/data/import_repository.dart @@ -5,10 +5,11 @@ import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'dart:developer' as developer; -import '../../../core/api/api_paths.dart'; -import '../../../core/api/api_exception.dart'; -import '../domain/help_text_content.dart'; -import '../domain/quick_import_result.dart'; +import '../../../core/api/api_paths.dart'; +import '../../../core/api/api_exception.dart'; +import '../domain/flyer_import_result.dart'; +import '../domain/help_text_content.dart'; +import '../domain/quick_import_result.dart'; /// Handles communication with the quick-import API endpoint. /// @@ -60,7 +61,7 @@ class ImportRepository { /// Upload a receipt file for parsing (Fas 6b). /// Returns a list of parsed receipt items. - Future> importReceiptFile({ + Future> importReceiptFile({ required Uint8List bytes, required String filename, String? token, @@ -142,7 +143,83 @@ class ImportRepository { developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e); rethrow; } - } + } + + Future importFlyerFile({ + required Uint8List bytes, + required String filename, + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${FlyerImportApiPaths.parse}'); + final request = http.MultipartRequest('POST', uri); + + if (token != null) { + request.headers['Authorization'] = 'Bearer $token'; + } + + request.files.add( + http.MultipartFile.fromBytes('file', bytes, filename: filename), + ); + + final streamed = await _client.send(request).timeout( + const Duration(seconds: 120), + onTimeout: () { + throw ApiException( + type: ApiErrorType.network, + message: 'Flyerimporten tog för lång tid. Försök igen.', + ); + }, + ); + final response = await http.Response.fromStream(streamed); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Fel vid flyerimport: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! Map) { + throw ApiException( + type: ApiErrorType.unknown, + message: 'Felaktigt svar från flyerimport.', + ); + } + + return FlyerImportResult.fromJson(parsed); + } + + Future>> createFlyerSelectionsBulk({ + required int sessionId, + required List> items, + String? token, + }) async { + final uri = Uri.parse('$_baseUrl${FlyerSelectionApiPaths.bulkBySession(sessionId)}'); + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + if (token != null) 'Authorization': 'Bearer $token', + }, + body: jsonEncode({ + 'items': items, + }), + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException( + type: _mapStatusCodeToErrorType(response.statusCode), + message: 'Kunde inte skapa flyer-selections: ${response.body}', + statusCode: response.statusCode, + ); + } + + final parsed = _parseResponse(response); + if (parsed is! List) return const []; + return parsed.cast>(); + } /// Upload a file (PDF or image) for recipe extraction. /// @@ -335,7 +412,7 @@ class ImportRepository { ); } - final result = _parseResponse(response) as Map; + final result = _parseResponse(response) as Map; developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository'); return result; } catch (e) { diff --git a/flutter/lib/features/import/domain/flyer_import_item.dart b/flutter/lib/features/import/domain/flyer_import_item.dart new file mode 100644 index 00000000..69382bdf --- /dev/null +++ b/flutter/lib/features/import/domain/flyer_import_item.dart @@ -0,0 +1,43 @@ +class FlyerImportItem { + final int? flyerItemId; + final String rawName; + final String normalizedName; + final String? category; + final double? price; + final String? priceUnit; + final String? offerText; + final int? matchedProductId; + final String? matchedProductName; + final String? matchedVia; + final double? matchConfidence; + + FlyerImportItem({ + required this.flyerItemId, + required this.rawName, + required this.normalizedName, + this.category, + this.price, + this.priceUnit, + this.offerText, + this.matchedProductId, + this.matchedProductName, + this.matchedVia, + this.matchConfidence, + }); + + factory FlyerImportItem.fromJson(Map json) { + return FlyerImportItem( + flyerItemId: (json['flyerItemId'] as num?)?.toInt(), + rawName: json['rawName'] as String? ?? '', + normalizedName: json['normalizedName'] as String? ?? '', + category: json['category'] as String?, + price: (json['price'] as num?)?.toDouble(), + priceUnit: json['priceUnit'] as String?, + offerText: json['offerText'] as String?, + matchedProductId: (json['matchedProductId'] as num?)?.toInt(), + matchedProductName: json['matchedProductName'] as String?, + matchedVia: json['matchedVia'] as String?, + matchConfidence: (json['matchConfidence'] as num?)?.toDouble(), + ); + } +} diff --git a/flutter/lib/features/import/domain/flyer_import_result.dart b/flutter/lib/features/import/domain/flyer_import_result.dart new file mode 100644 index 00000000..9b4bc073 --- /dev/null +++ b/flutter/lib/features/import/domain/flyer_import_result.dart @@ -0,0 +1,29 @@ +import 'flyer_import_item.dart'; + +class FlyerImportResult { + final int? sessionId; + final List items; + final List warnings; + + FlyerImportResult({ + required this.sessionId, + required this.items, + required this.warnings, + }); + + factory FlyerImportResult.fromJson(Map json) { + final rawItems = (json['items'] as List?) ?? const []; + final warnings = (json['warnings'] as List?) + ?.map((warning) => warning.toString()) + .toList() ?? + const []; + + return FlyerImportResult( + sessionId: (json['sessionId'] as num?)?.toInt(), + items: rawItems + .map((item) => FlyerImportItem.fromJson(item as Map)) + .toList(), + warnings: warnings, + ); + } +} diff --git a/flutter/lib/features/import/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart new file mode 100644 index 00000000..6b0fd768 --- /dev/null +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -0,0 +1,196 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../auth/data/auth_providers.dart'; +import '../data/import_providers.dart'; +import '../domain/flyer_import_item.dart'; +import '../domain/flyer_import_result.dart'; +import 'error_dialog.dart'; + +class FlyerImportTab extends ConsumerStatefulWidget { + const FlyerImportTab({super.key}); + + @override + ConsumerState createState() => _FlyerImportTabState(); +} + +class _FlyerImportTabState extends ConsumerState { + bool _isLoading = false; + bool _isSaving = false; + PlatformFile? _pickedFile; + FlyerImportResult? _result; + final Map _selected = {}; + + Future _pickFile() async { + final result = await FilePicker.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf', 'txt'], + withData: true, + ); + if (result == null || result.files.isEmpty) return; + setState(() => _pickedFile = result.files.first); + } + + Future _parseFlyer() async { + final file = _pickedFile; + if (file?.bytes == null) return; + + setState(() => _isLoading = true); + try { + final token = await ref.read(authStateProvider.future); + final repo = ref.read(importRepositoryProvider); + final parsed = await repo.importFlyerFile( + bytes: file!.bytes!, + filename: file.name, + token: token, + ); + + if (!mounted) return; + final selected = {}; + for (var i = 0; i < parsed.items.length; i++) { + selected[i] = parsed.items[i].matchedProductId != null; + } + setState(() { + _result = parsed; + _selected + ..clear() + ..addAll(selected); + }); + } catch (e) { + if (mounted) showErrorDialog(context, 'Flyerimport misslyckades: $e'); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _planSelected() async { + final result = _result; + if (result?.sessionId == null) return; + + final itemsToSave = >[]; + for (var i = 0; i < result!.items.length; i++) { + final item = result.items[i]; + final isSelected = _selected[i] == true; + if (!isSelected || item.flyerItemId == null) continue; + itemsToSave.add({ + 'itemId': item.flyerItemId, + 'plannedQuantity': 1, + 'plannedUnit': item.priceUnit, + 'priority': 'normal', + }); + } + + if (itemsToSave.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Markera minst en rad att planera.')), + ); + return; + } + + setState(() => _isSaving = true); + try { + final token = await ref.read(authStateProvider.future); + final repo = ref.read(importRepositoryProvider); + final saved = await repo.createFlyerSelectionsBulk( + sessionId: result.sessionId!, + items: itemsToSave, + token: token, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${saved.length} varor planerade.')), + ); + } catch (e) { + if (mounted) showErrorDialog(context, 'Kunde inte planera varor: $e'); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final items = _result?.items ?? const []; + final selectedCount = _selected.values.where((value) => value).length; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ladda upp flyer (PDF/txt), granska rader och planera inköp med ett klick.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _isLoading ? null : _pickFile, + icon: const Icon(Icons.attach_file), + label: Text(_pickedFile?.name ?? 'Välj flyerfil'), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: (!_isLoading && _pickedFile?.bytes != null) ? _parseFlyer : null, + icon: const Icon(Icons.auto_awesome), + label: const Text('Importera flyer'), + ), + if (_isLoading) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(), + ], + if (items.isNotEmpty) ...[ + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('${items.length} rader hittades', style: theme.textTheme.titleSmall), + TextButton( + onPressed: () { + final target = selectedCount < items.length; + setState(() { + for (var i = 0; i < items.length; i++) { + _selected[i] = target; + } + }); + }, + child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), + ), + ], + ), + const SizedBox(height: 8), + ...items.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return CheckboxListTile( + value: _selected[index] ?? false, + onChanged: (value) => setState(() => _selected[index] = value ?? false), + title: Text(item.rawName), + subtitle: Text([ + if (item.offerText != null && item.offerText!.isNotEmpty) item.offerText!, + if (item.matchedProductName != null) 'Match: ${item.matchedProductName}', + ].join(' · ')), + controlAffinity: ListTileControlAffinity.leading, + ); + }), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: (_isSaving || selectedCount == 0) ? null : _planSelected, + icon: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.playlist_add_check), + label: Text('Planera $selectedCount markerade'), + ), + ), + ], + ], + ), + ); + } +} diff --git a/flutter/lib/features/import/presentation/import_screen.dart b/flutter/lib/features/import/presentation/import_screen.dart index f74274f5..8ec4217c 100644 --- a/flutter/lib/features/import/presentation/import_screen.dart +++ b/flutter/lib/features/import/presentation/import_screen.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; -import 'recipe_import_tab.dart'; -import 'receipt_import_tab.dart'; +import 'flyer_import_tab.dart'; +import 'recipe_import_tab.dart'; +import 'receipt_import_tab.dart'; -/// Main import screen with tabs: Recept | Kvitto. +/// Main import screen with tabs: Recept | Kvitto | Flyer. /// /// Fas 6a: Recept-fliken är implementerad. /// Fas 6b: Kvitto-fliken läggs till i ett senare steg. @@ -18,10 +19,11 @@ class _ImportScreenState extends State { @override Widget build(BuildContext context) { return const TabBarView( - children: [ - RecipeImportTab(), - ReceiptImportTab(), - ], - ); - } -} + children: [ + RecipeImportTab(), + ReceiptImportTab(), + FlyerImportTab(), + ], + ); + } +} diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index a3be6b17..319cfad1 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -708,8 +708,9 @@ class _ReceiptImportTabState extends ConsumerState { final pantryAdded = response['pantryAdded'] as int? ?? 0; final pantrySkipped = response['pantrySkipped'] as int? ?? 0; final aliasesLearned = response['aliasesLearned'] as int? ?? 0; - final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0; - final errors = response['errors'] as List? ?? []; + final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0; + final flyerAutoSync = response['flyerAutoSync'] as Map?; + final errors = response['errors'] as List? ?? []; final parts = [ if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie', @@ -717,8 +718,12 @@ class _ReceiptImportTabState extends ConsumerState { if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager', if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager', if (aliasesLearned > 0) '$aliasesLearned alias inlärda', - if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda', - ]; + if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda', + if ((flyerAutoSync?['bought'] as int? ?? 0) > 0) + '${flyerAutoSync?['bought']} planerade flyer-varor markerade som köpta', + if ((flyerAutoSync?['ambiguous'] as int? ?? 0) > 0) + '${flyerAutoSync?['ambiguous']} flyer-matchningar kräver kontroll', + ]; if (errors.isNotEmpty) { final errorParts = [];