chore(import): improve error handling and add flyer integration
- Replace BadRequestException with UnauthorizedException for authentication failures in flyer-import and flyer-selection controllers - Add bulk selection endpoint in FlyerSelectionController for creating multiple selections in one request - Update FlyerSelectionModule to include new FlyerSelectionMatcherService and FlyerSelectionSyncController - Extend FlyerSelectionService with createMany method for bulk operations - Add new DTOs for bulk selection and receipt matching functionality - Update ReceiptImportService to accept FlyerSelectionService dependency and track successful rows - Extend SaveReceiptResponse with flyerAutoSync field for receipt-to-flyer matching results - Add new API paths for flyer import and selection endpoints - Update Flutter UI to include Flyer import tab and adjust tab controller length - Add new domain models and repository methods for flyer import functionality - Update test files to include new FlyerSelectionService dependency - Modify .kilo plan documentation to reflect current system architecture
This commit is contained in:
@@ -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:
|
Detta följer båda underlagen:
|
||||||
- premium-gating av AI
|
1) ingen befintlig flyer-UX i `/import` ännu (behöver byggas)
|
||||||
- PDF-import av Willys-underlag
|
2) maximal automation i punkt 3 (sync med kvittoimport)
|
||||||
- produktmatchning mot inventory
|
|
||||||
- receptgenerering med AI (Mistral) eller mallar
|
|
||||||
|
|
||||||
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å:
|
## Fas 1: Backend-kontrakt för auto-sync
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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.
|
- `POST /flyer-selections/receipt-match-preview`
|
||||||
- Fallback-first-princip vid AI-fel (ligger i linje med era befintliga principer).
|
- Input: kvittorader (normaliserad struktur), `weekKey` (optional), `sessionId` (optional)
|
||||||
- Intention att normalisera och strukturera råtext innan beslut i senare led.
|
- Output: matchförslag per kvittorad + confidence + reasonCodes + kandidat-selection
|
||||||
- Fokus på svenska enheter/uttryck som passar era kvittoflöden.
|
- 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)**
|
- `GET /flyer-selections/open`
|
||||||
- Planen föreslår Prisma/DB i importer-flödet (`prisma.user`, `encryptedData.create`, premiumfält m.m.).
|
- Query: `weekKey`, `retailer`, pagination
|
||||||
- Er importer är dokumenterat stateless och DB-lös.
|
- Returnerar öppna selections (`status=planned`) för snabb klienthämtning
|
||||||
- Rekommendation: all user/premium/inventory/recipe persistence ska ligga i `recipe-app` backend, inte i `microservice-importer`.
|
|
||||||
|
|
||||||
2. **Premium-modell avviker från faktisk datamodell**
|
Notering: Behåll befintliga CRUD-rutter under `/flyer-sessions/:sessionId/selections`.
|
||||||
- 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.
|
|
||||||
|
|
||||||
3. **Framework mismatch (Express vs NestJS)**
|
### 1.2 Matchningsmotor (service-nivå)
|
||||||
- Planen innehåller Express-routerexempel medan repos är NestJS-moduler/controllers/services.
|
I `FlyerSelectionService` lägg till en intern matcher med prioriterad strategi:
|
||||||
- Rekommendation: all ny implementation bör följa NestJS module/service/controller + DTO + class-validator.
|
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**
|
Regler:
|
||||||
- Planen antar tabeller/fält som inte finns i nuvarande schema (`encryptedData`, snake_case-kolumner osv).
|
- En `FlyerSelection` kan bara konsumeras en gång per commit.
|
||||||
- Rekommendation: mappa mot faktiska modeller (`User`, `Product`, `InventoryItem`, `Recipe*`) eller skapa tydlig migrationplan med namngiven adapter.
|
- 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**
|
### 1.3 Datamodell-justeringar (om behövs)
|
||||||
- Ni har redan importerad kvittoparsning med regelmotor + AI-fallback och premium/ai-scope.
|
Nuvarande schema räcker i stort, men planera följande icke-blockerande förbättringar:
|
||||||
- Rekommendation: undvik ”nytt parallellt flöde”; bygg som utökning av befintliga receipt/import pipelines.
|
- `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.
|
### 2.1 Lägg till `FlyerImportTab`
|
||||||
- Lägg till strikt validering (zod eller class-validator) för:
|
Uppdatera `ImportScreen` så tabbar blir:
|
||||||
- parsed flyer rows
|
1. Recept
|
||||||
- matched product payload
|
2. Kvitto
|
||||||
- generated recipe payload
|
3. Flyer
|
||||||
- Definiera versionerat kontrakt (`v1`) så importer och app kan deployas oberoende.
|
|
||||||
|
|
||||||
### 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.
|
### 2.2 Flyer-tabens minimalklick-flöde
|
||||||
- Förbättra med pipeline:
|
Steg i UI:
|
||||||
1) textblock-normalisering
|
1. Upload flyerfil
|
||||||
2) radklassificering (kategori, produkt, prisrad, metadata)
|
2. Visa parserader med förvald checkbox för matchade varor
|
||||||
3) enhetsnormalisering (`förp`, `st`, `kg`)
|
3. Primär CTA: `Planera markerade` (bulk-create/upsert)
|
||||||
4) probabilistisk matchscore per fält
|
4. Direkt visning av statuschips (`planned/bought/skipped`)
|
||||||
- Lägg in rule priority + fallback AI bara för osäkra rader (som ni redan gör i receipt flow).
|
|
||||||
|
|
||||||
### 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.
|
### 2.3 Statusöversikt
|
||||||
- Förslag:
|
I flyer-tabben visa sektioner:
|
||||||
- kombinera alias > exact-normalized > token-similarity > levenshtein.
|
- `Planerade` (öppna)
|
||||||
- category-guardrails (finns redan i receipt-flöde, återanvänd).
|
- `Nyligen köpta` (autouppdaterade från kvitto)
|
||||||
- trösklar per kategori (mejeri/kött behöver striktare gränser än exotiska varor).
|
- `Ej matchade vid senaste kvitto` (för snabb manuell hantering)
|
||||||
- returnera `matchedVia`, `confidence`, `reasonCodes` för UI-debugg och lärande.
|
|
||||||
|
|
||||||
### E. AI-generering
|
---
|
||||||
|
|
||||||
- Planen gör fri JSON-parsning från modelltext; hög risk för parse-fel.
|
## Fas 3: Integrera auto-sync i `ReceiptImportTab`
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 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).
|
### 3.2 Zero-click commit i happy path
|
||||||
- Lägg rate limiting även på nya flyer-endpoints.
|
Defaultbeteende vid `Lägg till markerade`:
|
||||||
- Undvik filsystemberoende (`multer dest + fs.unlinkSync`) om möjligt; använd bufferbaserad pipeline.
|
- auto-committa alla matcher med confidence `>=0.90`
|
||||||
- Lägg explicit content-type + storleksgräns + filsignature-validering.
|
- 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:
|
### 3.3 Manuell override (endast vid behov)
|
||||||
- `backend-node24`: contract tests mellan app/importer.
|
Lägg till valfri expandrad i resultatlistan:
|
||||||
- `backend-node24`: parser regression suite med fixtures från riktiga Willys-underlag.
|
- “Föreslagen flyer-match” + knapp `Bekräfta ändå`
|
||||||
- Lägg minimikrav i CI:
|
- används endast för ambiguous fall
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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.
|
### 4.1 Payload-standard (receipt -> matcher)
|
||||||
- Returnera endast normaliserad och validerad struktur, aldrig användarspecifika beslut.
|
Standardisera kvittorad till:
|
||||||
- Återanvänd befintlig robusthet:
|
- `rowId` (lokalt index eller UUID)
|
||||||
- fallback parsing
|
- `rawName`
|
||||||
- timeout/retry-mönster
|
- `normalizedName`
|
||||||
- global exception shape.
|
- `productId` (om redan mappad)
|
||||||
- Bygg fixture-driven tester för Willys-format (varianter med OCR-brus, multipack, kampanjtext).
|
- `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:
|
### 4.3 WeekKey-hantering
|
||||||
- anropa importer
|
Fallback-ordning vid matchning:
|
||||||
- matcha mot user-scopade produkter
|
1. explicit `sessionId`
|
||||||
- premium-gata AI-recept (isPremium + aiEngineEnabled)
|
2. explicit `weekKey`
|
||||||
- spara recept via befintliga modeller
|
3. server beräknar aktuell `weekKey`
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 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)**
|
### 5.1 Backend
|
||||||
- Fastställ och dokumentera kontrakt: importer parser-only, app stateful orchestration.
|
- 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)**
|
### 5.2 Flutter
|
||||||
- Definiera `FlyerParseResponse v1` och valideringsregler.
|
- Widget-tester för:
|
||||||
|
- `FlyerImportTab` listning/bulk-planering
|
||||||
|
- kvitto-rad med automatch-chip
|
||||||
|
- Integrationstester för `ReceiptImportTab` + auto-sync callback
|
||||||
|
|
||||||
3. **Importer-modul (P1)**
|
### 5.3 Acceptanskriterier (måste uppfyllas)
|
||||||
- Implementera Willys/flyer parser i `microservice-importer` med tester + fixtures.
|
- 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)**
|
## Fas 6: Gradvis lansering
|
||||||
- Använd enbart `isPremium` + `aiEngineEnabled`; ta bort/undvik expiry-logik om den inte behövs produktmässigt.
|
|
||||||
|
|
||||||
6. **Observability + säkerhet (P1)**
|
1. Backend-endpoints bakom feature flag: `flyerReceiptAutoSyncEnabled`
|
||||||
- Metrics, structured logs, rate limits, upload guards.
|
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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
Post,
|
Post,
|
||||||
Request,
|
Request,
|
||||||
|
UnauthorizedException,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -51,7 +52,7 @@ export class FlyerImportController {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException('Kunde inte identifiera användaren.');
|
throw new UnauthorizedException('Kunde inte identifiera användaren.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.flyerImportService.parseAndMatch(file, userId);
|
return this.flyerImportService.parseAndMatch(file, userId);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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<number, CandidateSelection[]>();
|
||||||
|
const byNormalizedName = new Map<string, CandidateSelection[]>();
|
||||||
|
|
||||||
|
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<number>,
|
||||||
|
byProductId: Map<number, CandidateSelection[]>,
|
||||||
|
byNormalizedName: Map<string, CandidateSelection[]>,
|
||||||
|
): CandidateSelection[] {
|
||||||
|
const pool = new Set<CandidateSelection>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FlyerSelectionResponse[]> {
|
||||||
|
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<ReceiptMatchPreviewResponse> {
|
||||||
|
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<ReceiptMatchCommitResponse> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Request,
|
Request,
|
||||||
Get,
|
Get,
|
||||||
BadRequestException,
|
UnauthorizedException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto';
|
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 { FlyerSelectionResponse } from './dto/flyer-selection.response';
|
||||||
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
|
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
|
||||||
import { FlyerSelectionService } from './flyer-selection.service';
|
import { FlyerSelectionService } from './flyer-selection.service';
|
||||||
@@ -42,6 +43,18 @@ export class FlyerSelectionController {
|
|||||||
return this.flyerSelectionService.create(sessionId, userId, dto);
|
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<FlyerSelectionResponse[]> {
|
||||||
|
const userId = this.getUserId(req);
|
||||||
|
return this.flyerSelectionService.createMany(sessionId, userId, dto.items);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':selectionId')
|
@Patch(':selectionId')
|
||||||
@HttpCode(200)
|
@HttpCode(200)
|
||||||
@Throttle({ default: { ttl: 60_000, limit: 30 } })
|
@Throttle({ default: { ttl: 60_000, limit: 30 } })
|
||||||
@@ -75,7 +88,7 @@ export class FlyerSelectionController {
|
|||||||
? req.user.userId
|
? req.user.userId
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException('Kunde inte identifiera användaren.');
|
throw new UnauthorizedException('Kunde inte identifiera användaren.');
|
||||||
}
|
}
|
||||||
return userId;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { FlyerSelectionMatcherService } from './flyer-selection-matcher.service';
|
||||||
import { FlyerSelectionController } from './flyer-selection.controller';
|
import { FlyerSelectionController } from './flyer-selection.controller';
|
||||||
|
import { FlyerSelectionSyncController } from './flyer-selection-sync.controller';
|
||||||
import { FlyerSelectionService } from './flyer-selection.service';
|
import { FlyerSelectionService } from './flyer-selection.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [FlyerSelectionController],
|
controllers: [FlyerSelectionController, FlyerSelectionSyncController],
|
||||||
providers: [FlyerSelectionService],
|
providers: [FlyerSelectionService, FlyerSelectionMatcherService],
|
||||||
|
exports: [FlyerSelectionService],
|
||||||
})
|
})
|
||||||
export class FlyerSelectionModule {}
|
export class FlyerSelectionModule {}
|
||||||
|
|||||||
@@ -7,12 +7,24 @@ import {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto';
|
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 { FlyerSelectionResponse } from './dto/flyer-selection.response';
|
||||||
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
|
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
|
||||||
|
import {
|
||||||
|
CandidateSelection,
|
||||||
|
FlyerSelectionMatcherService,
|
||||||
|
} from './flyer-selection-matcher.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FlyerSelectionService {
|
export class FlyerSelectionService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly matcher: FlyerSelectionMatcherService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async listBySession(sessionId: number, userId: number): Promise<FlyerSelectionResponse[]> {
|
async listBySession(sessionId: number, userId: number): Promise<FlyerSelectionResponse[]> {
|
||||||
await this.assertSessionOwnership(sessionId, userId);
|
await this.assertSessionOwnership(sessionId, userId);
|
||||||
@@ -94,6 +106,103 @@ export class FlyerSelectionService {
|
|||||||
return this.toResponse(created);
|
return this.toResponse(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createMany(
|
||||||
|
sessionId: number,
|
||||||
|
userId: number,
|
||||||
|
items: CreateFlyerSelectionDto[],
|
||||||
|
): Promise<FlyerSelectionResponse[]> {
|
||||||
|
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(
|
async update(
|
||||||
sessionId: number,
|
sessionId: number,
|
||||||
selectionId: number,
|
selectionId: number,
|
||||||
@@ -158,6 +267,88 @@ export class FlyerSelectionService {
|
|||||||
await this.prisma.flyerSelection.delete({ where: { id: selectionId } });
|
await this.prisma.flyerSelection.delete({ where: { id: selectionId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listOpen(userId: number, weekKey?: string): Promise<FlyerSelectionResponse[]> {
|
||||||
|
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<ReceiptMatchPreviewResponse> {
|
||||||
|
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<ReceiptMatchCommitResponse> {
|
||||||
|
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<number>();
|
||||||
|
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<void> {
|
private async assertSessionOwnership(sessionId: number, userId: number): Promise<void> {
|
||||||
const session = await this.prisma.flyerSession.findUnique({
|
const session = await this.prisma.flyerSession.findUnique({
|
||||||
where: { id: sessionId },
|
where: { id: sessionId },
|
||||||
@@ -171,6 +362,41 @@ export class FlyerSelectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadCandidateSelections(
|
||||||
|
userId: number,
|
||||||
|
sessionId?: number,
|
||||||
|
weekKey?: string,
|
||||||
|
): Promise<CandidateSelection[]> {
|
||||||
|
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 {
|
private toResponse(row: any): FlyerSelectionResponse {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
export interface SaveReceiptResponse {
|
export interface SaveReceiptResponse {
|
||||||
created: number;
|
created: number;
|
||||||
merged: number;
|
merged: number;
|
||||||
pantryAdded: number;
|
pantryAdded: number;
|
||||||
pantrySkipped: number;
|
pantrySkipped: number;
|
||||||
aliasesLearned: number;
|
aliasesLearned: number;
|
||||||
unitMappingsLearned: number;
|
unitMappingsLearned: number;
|
||||||
errors?: Array<{ index: number; error: string }>;
|
flyerAutoSync?: {
|
||||||
}
|
bought: number;
|
||||||
|
ambiguous: number;
|
||||||
|
unmatched: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
errors?: Array<{ index: number; error: string }>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import {
|
|||||||
Request,
|
Request,
|
||||||
UploadedFile,
|
UploadedFile,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
@@ -72,7 +73,7 @@ export class ReceiptImportController {
|
|||||||
? req.user.userId
|
? req.user.userId
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException('Kunde inte identifiera användaren.');
|
throw new UnauthorizedException('Kunde inte identifiera användaren.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.receiptImportService.upsertUnitMapping(
|
return this.receiptImportService.upsertUnitMapping(
|
||||||
@@ -98,7 +99,7 @@ export class ReceiptImportController {
|
|||||||
? req.user.userId
|
? req.user.userId
|
||||||
: undefined;
|
: undefined;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new BadRequestException('Kunde inte identifiera användaren.');
|
throw new UnauthorizedException('Kunde inte identifiera användaren.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = req?.user?.role === 'admin';
|
const isAdmin = req?.user?.role === 'admin';
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ReceiptImportController } from './receipt-import.controller';
|
import { ReceiptImportController } from './receipt-import.controller';
|
||||||
import { ReceiptImportService } from './receipt-import.service';
|
import { ReceiptImportService } from './receipt-import.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { AiModule } from '../ai/ai.module';
|
import { AiModule } from '../ai/ai.module';
|
||||||
import { CategoriesModule } from '../categories/categories.module';
|
import { CategoriesModule } from '../categories/categories.module';
|
||||||
|
import { FlyerSelectionModule } from '../flyer-selection/flyer-selection.module';
|
||||||
@Module({
|
|
||||||
imports: [PrismaModule, AiModule, CategoriesModule],
|
@Module({
|
||||||
controllers: [ReceiptImportController],
|
imports: [PrismaModule, AiModule, CategoriesModule, FlyerSelectionModule],
|
||||||
providers: [ReceiptImportService],
|
controllers: [ReceiptImportController],
|
||||||
})
|
providers: [ReceiptImportService],
|
||||||
export class ReceiptImportModule {}
|
})
|
||||||
|
export class ReceiptImportModule {}
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ describe('ReceiptImportService parseReceipt flow', () => {
|
|||||||
findFlattened: jest.fn(),
|
findFlattened: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new ReceiptImportService(
|
const service = new ReceiptImportService(
|
||||||
prismaMock as any,
|
prismaMock as any,
|
||||||
aiServiceMock as any,
|
aiServiceMock as any,
|
||||||
categoriesServiceMock as any,
|
categoriesServiceMock as any,
|
||||||
);
|
{ commitReceiptMatches: jest.fn() } as any,
|
||||||
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -67,12 +67,13 @@ describe('ReceiptImportService.saveReceipt', () => {
|
|||||||
$transaction: jest.fn().mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock)),
|
$transaction: jest.fn().mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock)),
|
||||||
};
|
};
|
||||||
|
|
||||||
service = new ReceiptImportService(
|
service = new ReceiptImportService(
|
||||||
prismaMock as any,
|
prismaMock as any,
|
||||||
{} as any, // aiService – används ej i saveReceipt
|
{} as any, // aiService – används ej i saveReceipt
|
||||||
{} as any, // categoriesService – 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 ─────────────────────────────────────────────
|
// ── 1. Skapar ny inventariepost ─────────────────────────────────────────────
|
||||||
it('skapar ny inventariepost när produkten finns och inte finns i inventariet', async () => {
|
it('skapar ny inventariepost när produkten finns och inte finns i inventariet', async () => {
|
||||||
|
|||||||
@@ -51,11 +51,12 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
findFlattened: jest.fn(),
|
findFlattened: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const service = new ReceiptImportService(
|
const service = new ReceiptImportService(
|
||||||
prismaMock as any,
|
prismaMock as any,
|
||||||
aiServiceMock as any,
|
aiServiceMock as any,
|
||||||
categoriesServiceMock as any,
|
categoriesServiceMock as any,
|
||||||
);
|
{ commitReceiptMatches: jest.fn() } as any,
|
||||||
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
@@ -404,4 +405,4 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
expect(aiFallbackResult.categorySuggestion?.categoryId).toBe(51);
|
expect(aiFallbackResult.categorySuggestion?.categoryId).toBe(51);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ import { SaveReceiptResponse } from './dto/save-receipt.response';
|
|||||||
import { AiService, CategorySuggestion } from '../ai/ai.service';
|
import { AiService, CategorySuggestion } from '../ai/ai.service';
|
||||||
import { CategoriesService } from '../categories/categories.service';
|
import { CategoriesService } from '../categories/categories.service';
|
||||||
import { normalizeName } from '../common/utils/normalize-name';
|
import { normalizeName } from '../common/utils/normalize-name';
|
||||||
import {
|
import {
|
||||||
isIgnoredReceiptAliasName,
|
isIgnoredReceiptAliasName,
|
||||||
normalizeReceiptAliasName,
|
normalizeReceiptAliasName,
|
||||||
validateReceiptAliasName,
|
validateReceiptAliasName,
|
||||||
} from '../common/utils/receipt-alias';
|
} from '../common/utils/receipt-alias';
|
||||||
|
import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service';
|
||||||
|
|
||||||
const IMPORTER_SERVICE_URL =
|
const IMPORTER_SERVICE_URL =
|
||||||
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
||||||
@@ -125,11 +126,12 @@ type MatchDebug = {
|
|||||||
export class ReceiptImportService {
|
export class ReceiptImportService {
|
||||||
private readonly logger = new Logger(ReceiptImportService.name);
|
private readonly logger = new Logger(ReceiptImportService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly aiService: AiService,
|
private readonly aiService: AiService,
|
||||||
private readonly categoriesService: CategoriesService,
|
private readonly categoriesService: CategoriesService,
|
||||||
) {}
|
private readonly flyerSelectionService: FlyerSelectionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
|
||||||
// Steg 1: Delegera AI-parsning till microservice-importer
|
// Steg 1: Delegera AI-parsning till microservice-importer
|
||||||
@@ -297,7 +299,7 @@ export class ReceiptImportService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveReceipt(userId: number, dto: SaveReceiptDto): Promise<SaveReceiptResponse> {
|
async saveReceipt(userId: number, dto: SaveReceiptDto): Promise<SaveReceiptResponse> {
|
||||||
const response: SaveReceiptResponse = {
|
const response: SaveReceiptResponse = {
|
||||||
created: 0,
|
created: 0,
|
||||||
merged: 0,
|
merged: 0,
|
||||||
@@ -308,7 +310,13 @@ export class ReceiptImportService {
|
|||||||
errors: [],
|
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
|
// Preload existierande pantry-poster för denna användare
|
||||||
const userPantry = await this.prisma.pantryItem.findMany({
|
const userPantry = await this.prisma.pantryItem.findMany({
|
||||||
@@ -386,7 +394,7 @@ export class ReceiptImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Steg 2: Hantera pantry eller inventory ===
|
// === Steg 2: Hantera pantry eller inventory ===
|
||||||
if (item.destination === 'pantry') {
|
if (item.destination === 'pantry') {
|
||||||
if (pantryProductIds.has(productId)) {
|
if (pantryProductIds.has(productId)) {
|
||||||
response.pantrySkipped++;
|
response.pantrySkipped++;
|
||||||
} else {
|
} else {
|
||||||
@@ -395,8 +403,8 @@ export class ReceiptImportService {
|
|||||||
});
|
});
|
||||||
response.pantryAdded++;
|
response.pantryAdded++;
|
||||||
pantryProductIds.add(productId);
|
pantryProductIds.add(productId);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// inventory
|
// inventory
|
||||||
const quantity = item.quantity ?? 0;
|
const quantity = item.quantity ?? 0;
|
||||||
const unit = (item.unit ?? '').trim() || 'st';
|
const unit = (item.unit ?? '').trim() || 'st';
|
||||||
@@ -461,8 +469,15 @@ export class ReceiptImportService {
|
|||||||
});
|
});
|
||||||
response.unitMappingsLearned++;
|
response.unitMappingsLearned++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
successfulRows.push({
|
||||||
|
rawName: item.rawName,
|
||||||
|
productId,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit: item.unit,
|
||||||
|
});
|
||||||
|
|
||||||
// === Steg 4: Lär in alias om requested ===
|
// === Steg 4: Lär in alias om requested ===
|
||||||
if (item.learnAlias) {
|
if (item.learnAlias) {
|
||||||
@@ -510,10 +525,45 @@ export class ReceiptImportService {
|
|||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Transaktionfel vid sparande av kvittovaror: ${err instanceof Error ? err.message : String(err)}`,
|
`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<ParsedReceiptItem[]> {
|
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|||||||
@@ -33,10 +33,20 @@ class CategoryApiPaths {
|
|||||||
static const tree = '/categories/tree';
|
static const tree = '/categories/tree';
|
||||||
}
|
}
|
||||||
|
|
||||||
class ReceiptImportApiPaths {
|
class ReceiptImportApiPaths {
|
||||||
static const refreshCategories = '/receipt-import/refresh-categories';
|
static const refreshCategories = '/receipt-import/refresh-categories';
|
||||||
static const unitMappings = '/receipt-import/unit-mappings';
|
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 {
|
class HelpTextApiPaths {
|
||||||
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}';
|
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}';
|
||||||
@@ -131,4 +141,4 @@ class MealPlanApiPaths {
|
|||||||
|
|
||||||
static String removeByDate(String date) =>
|
static String removeByDate(String date) =>
|
||||||
'$list/${Uri.encodeComponent(date)}';
|
'$list/${Uri.encodeComponent(date)}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,13 +142,17 @@ class AppShell extends ConsumerWidget {
|
|||||||
icon: Icon(Icons.restaurant_menu_outlined),
|
icon: Icon(Icons.restaurant_menu_outlined),
|
||||||
text: 'Recept',
|
text: 'Recept',
|
||||||
),
|
),
|
||||||
Tab(
|
Tab(
|
||||||
icon: Icon(Icons.receipt_long_outlined),
|
icon: Icon(Icons.receipt_long_outlined),
|
||||||
text: 'Kvitto',
|
text: 'Kvitto',
|
||||||
),
|
),
|
||||||
],
|
Tab(
|
||||||
)
|
icon: Icon(Icons.local_offer_outlined),
|
||||||
: null,
|
text: 'Flyer',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
actions: [
|
actions: [
|
||||||
if (isRecipesRoute)
|
if (isRecipesRoute)
|
||||||
Consumer(
|
Consumer(
|
||||||
@@ -278,9 +282,9 @@ class AppShell extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isImportRoute) {
|
if (isImportRoute) {
|
||||||
shell = DefaultTabController(length: 2, child: shell);
|
shell = DefaultTabController(length: 3, child: shell);
|
||||||
}
|
}
|
||||||
|
|
||||||
return shell;
|
return shell;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
(_) => ImportRepository(),
|
||||||
final importRepositoryProvider = Provider<ImportRepository>(
|
);
|
||||||
(_) => ImportRepository(),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import 'dart:typed_data';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import '../../../core/api/api_paths.dart';
|
import '../../../core/api/api_paths.dart';
|
||||||
import '../../../core/api/api_exception.dart';
|
import '../../../core/api/api_exception.dart';
|
||||||
import '../domain/help_text_content.dart';
|
import '../domain/flyer_import_result.dart';
|
||||||
import '../domain/quick_import_result.dart';
|
import '../domain/help_text_content.dart';
|
||||||
|
import '../domain/quick_import_result.dart';
|
||||||
|
|
||||||
/// Handles communication with the quick-import API endpoint.
|
/// Handles communication with the quick-import API endpoint.
|
||||||
///
|
///
|
||||||
@@ -60,7 +61,7 @@ class ImportRepository {
|
|||||||
|
|
||||||
/// Upload a receipt file for parsing (Fas 6b).
|
/// Upload a receipt file for parsing (Fas 6b).
|
||||||
/// Returns a list of parsed receipt items.
|
/// Returns a list of parsed receipt items.
|
||||||
Future<List<ParsedReceiptItem>> importReceiptFile({
|
Future<List<ParsedReceiptItem>> importReceiptFile({
|
||||||
required Uint8List bytes,
|
required Uint8List bytes,
|
||||||
required String filename,
|
required String filename,
|
||||||
String? token,
|
String? token,
|
||||||
@@ -142,7 +143,83 @@ class ImportRepository {
|
|||||||
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
|
developer.log('Exception during receipt import: $e', name: 'ImportRepository', error: e);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<FlyerImportResult> 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<String, dynamic>) {
|
||||||
|
throw ApiException(
|
||||||
|
type: ApiErrorType.unknown,
|
||||||
|
message: 'Felaktigt svar från flyerimport.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FlyerImportResult.fromJson(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> createFlyerSelectionsBulk({
|
||||||
|
required int sessionId,
|
||||||
|
required List<Map<String, dynamic>> 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<Map<String, dynamic>>();
|
||||||
|
}
|
||||||
|
|
||||||
/// Upload a file (PDF or image) for recipe extraction.
|
/// Upload a file (PDF or image) for recipe extraction.
|
||||||
///
|
///
|
||||||
@@ -335,7 +412,7 @@ class ImportRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final result = _parseResponse(response) as Map<String, dynamic>;
|
final result = _parseResponse(response) as Map<String, dynamic>;
|
||||||
developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository');
|
developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository');
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -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<String, dynamic> 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'flyer_import_item.dart';
|
||||||
|
|
||||||
|
class FlyerImportResult {
|
||||||
|
final int? sessionId;
|
||||||
|
final List<FlyerImportItem> items;
|
||||||
|
final List<String> warnings;
|
||||||
|
|
||||||
|
FlyerImportResult({
|
||||||
|
required this.sessionId,
|
||||||
|
required this.items,
|
||||||
|
required this.warnings,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FlyerImportResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawItems = (json['items'] as List?) ?? const [];
|
||||||
|
final warnings = (json['warnings'] as List?)
|
||||||
|
?.map((warning) => warning.toString())
|
||||||
|
.toList() ??
|
||||||
|
const <String>[];
|
||||||
|
|
||||||
|
return FlyerImportResult(
|
||||||
|
sessionId: (json['sessionId'] as num?)?.toInt(),
|
||||||
|
items: rawItems
|
||||||
|
.map((item) => FlyerImportItem.fromJson(item as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
warnings: warnings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FlyerImportTab> createState() => _FlyerImportTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _isSaving = false;
|
||||||
|
PlatformFile? _pickedFile;
|
||||||
|
FlyerImportResult? _result;
|
||||||
|
final Map<int, bool> _selected = {};
|
||||||
|
|
||||||
|
Future<void> _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<void> _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 = <int, bool>{};
|
||||||
|
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<void> _planSelected() async {
|
||||||
|
final result = _result;
|
||||||
|
if (result?.sessionId == null) return;
|
||||||
|
|
||||||
|
final itemsToSave = <Map<String, dynamic>>[];
|
||||||
|
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 <FlyerImportItem>[];
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'recipe_import_tab.dart';
|
import 'flyer_import_tab.dart';
|
||||||
import 'receipt_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 6a: Recept-fliken är implementerad.
|
||||||
/// Fas 6b: Kvitto-fliken läggs till i ett senare steg.
|
/// Fas 6b: Kvitto-fliken läggs till i ett senare steg.
|
||||||
@@ -18,10 +19,11 @@ class _ImportScreenState extends State<ImportScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const TabBarView(
|
return const TabBarView(
|
||||||
children: [
|
children: [
|
||||||
RecipeImportTab(),
|
RecipeImportTab(),
|
||||||
ReceiptImportTab(),
|
ReceiptImportTab(),
|
||||||
],
|
FlyerImportTab(),
|
||||||
);
|
],
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -708,8 +708,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
final pantryAdded = response['pantryAdded'] as int? ?? 0;
|
final pantryAdded = response['pantryAdded'] as int? ?? 0;
|
||||||
final pantrySkipped = response['pantrySkipped'] as int? ?? 0;
|
final pantrySkipped = response['pantrySkipped'] as int? ?? 0;
|
||||||
final aliasesLearned = response['aliasesLearned'] as int? ?? 0;
|
final aliasesLearned = response['aliasesLearned'] as int? ?? 0;
|
||||||
final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0;
|
final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0;
|
||||||
final errors = response['errors'] as List? ?? [];
|
final flyerAutoSync = response['flyerAutoSync'] as Map<String, dynamic>?;
|
||||||
|
final errors = response['errors'] as List? ?? [];
|
||||||
|
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
||||||
@@ -717,8 +718,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager',
|
if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager',
|
||||||
if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager',
|
if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager',
|
||||||
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
|
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) {
|
if (errors.isNotEmpty) {
|
||||||
final errorParts = <String>[];
|
final errorParts = <String>[];
|
||||||
|
|||||||
Reference in New Issue
Block a user