From e658f2e6f1061fa63e8930291ba8c7eb292e8dea Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Mon, 18 May 2026 23:27:20 +0200 Subject: [PATCH] chore(ci): update project documentation and flyer import features Update project documentation with recent CI improvements and flyer import enhancements: - Add ESLint configuration for backend and Dart lints for Flutter - Document Prisma query logging via PRISMA_LOG_QUERIES environment variable - Update NEXT_STEPS.md, README.md, and TEKNISK_BESKRIVNING.md with new features - Add isOffer, offerLimitText, comparisonPrice, comparisonUnit, parseConfidence, and parseReasons fields to FlyerImportItem - Update FlyerImportResponse type to include new fields - Extend file picker to support image formats (png, jpg, jpeg, webp) - Add offer badge display and price formatting in Flutter UI - Implement PDF preview functionality for flyer import --- NEXT_STEPS.md | 14 +- README.md | 10 +- TEKNISK_BESKRIVNING.md | 17 +- .../flyer-import/dto/flyer-import.response.ts | 58 +- .../src/flyer-import/flyer-import.service.ts | 624 +++++++++--------- .../import/domain/flyer_import_item.dart | 104 +-- .../import/presentation/flyer_import_tab.dart | 124 +++- 7 files changed, 563 insertions(+), 388 deletions(-) diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 745a3651..48e70d57 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -41,9 +41,17 @@ MVP ar uppnadd nar en vanlig anvandare kan importera, granska och spara kvitto/r - Deploy, healthcheck och testkorning ar reproducerbara i driftmiljo. -## Nyligen klart - -## Utförda steg (2026-05-13) +## Nyligen klart + +## Utförda steg (2026-05-18) + +- [x] **ESLint i backend + CI:** ESLint-konfiguration tillagd i backend och CI-workflow uppdaterad med lint-step för PR/push. +- [x] **Dart lint-konfig aktiverad:** `flutter/analysis_options.yaml` tillagd för att säkerställa `flutter_lints` i analyskörningar. +- [x] **Prisma query logging styrbar per miljö:** `PRISMA_LOG_QUERIES` implementerad i backend samt kopplad i `compose.yml`. +- [x] **Dokumenterat aktivering av query-loggar:** Instruktion att sätta `PRISMA_LOG_QUERIES=1` och starta om `recipe-api` i test/staging. +- [x] **Korrigerat testförväntan i receipt-import:** Security-test för saknat användar-id uppdaterat till `UnauthorizedException`. + +## Utförda steg (2026-05-13) - [x] **Centralt hjälptextsystem (backend):** Nytt `HelpTextsModule` med service, controller och DTO. `GET /api/help-texts/:key` returnerar rätt hjälptext baserat pÃ¥ användarroll (prioritetsordning: admin → user → default). `PUT /api/help-texts/:key/:scope` kräver admin-roll. - [x] **Prisma-migration:** `20260513150000_add_help_texts` — `HelpText`-tabell med `@@unique([key, scope])`-constraint och index. Seed-data för `receipt_import` (default + admin-scope) pÃ¥ svenska. diff --git a/README.md b/README.md index 871f86d2..4e0b905b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ -# Nyheter och förbättringar (2026-05-13) +# Nyheter och förbättringar (2026-05-18) + +- **CI: ESLint för backend:** ESLint är infört i backend (`backend/eslint.config.mjs`) och körs i GitHub Actions (`.github/workflows/test.yml`) via steget `Lint backend`. +- **CI: Dart lints aktiverade:** `flutter/analysis_options.yaml` är tillagd med `include: package:flutter_lints/flutter.yaml`, sÃ¥ `flutter analyze` använder explicita lint-regler. +- **Prisma query logging i test/staging:** Backend stödjer nu env-styrd query-loggning via `PRISMA_LOG_QUERIES` i `backend/src/prisma/prisma.service.ts`. +- **Compose-stöd för loggning:** `compose.yml` har `PRISMA_LOG_QUERIES: "${PRISMA_LOG_QUERIES:-0}"` för säker default av. +- **Testfix receipt-import:** Säkerhetstestet för saknat användar-id i `upsertUnitMapping` är uppdaterat till `UnauthorizedException`, i linje med controllerns beteende. + +# Nyheter och förbättringar (2026-05-13) - **Centralt hjälptextsystem:** Nytt backend-modul (`HelpTextsModule`) med `GET /api/help-texts/:key` (rollmedveten) och `PUT /api/help-texts/:key/:scope` (admin). Stöd för scopade hjälptexter: `admin`, `user`, `default` med prioritetsordning beroende pÃ¥ användarroll. - **Prisma-migration:** `20260513150000_add_help_texts` — skapar `HelpText`-tabell och seedar initiala hjälptexter för kvittoimport (standard + admin-variant) pÃ¥ svenska. diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index da181f0d..b1daddbb 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -14,9 +14,18 @@ Verifiering: Se även: README.md för användarflöde, och AI-FUNKTIONER.md för AI-detaljer. -# Prisma-migreringar: P3009 recovery och lessons learned - -# Drift och deploy (2026-05-11) +# Prisma-migreringar: P3009 recovery och lessons learned + +# Nyheter och förbättringar (2026-05-18) + +- **Backend linting i CI:** ESLint är infört för backend (`backend/eslint.config.mjs`, `npm run lint`) och körs i `.github/workflows/test.yml`. +- **Flutter lint-konfiguration:** `flutter/analysis_options.yaml` är tillagd och inkluderar `package:flutter_lints/flutter.yaml`. +- **Prisma query logging (miljöstyrd):** `PrismaService` konfigurerar loggnivÃ¥er via env-variabeln `PRISMA_LOG_QUERIES`. +- **Runtime-konfiguration:** `compose.yml` exponerar `PRISMA_LOG_QUERIES` till `recipe-api` med default `0`. +- **Aktivering i testmiljö:** Sätt `PRISMA_LOG_QUERIES=1` och starta om `recipe-api` för att fÃ¥ SQL query-loggar. +- **Verifierad testjustering:** `receipt-import.security.spec.ts` validerar nu `UnauthorizedException` vid saknat användar-id i `upsertUnitMapping`. + +# Drift och deploy (2026-05-11) - **Flutter build-artifacts:** Byggda filer i `flutter/build/` och `.flutter-plugins-dependencies` ska inte versionshanteras. Vid deploy pÃ¥ server: kör `git restore flutter/build flutter/.flutter-plugins-dependencies` och `git clean -fd flutter/build` innan `git pull`. - **Vanliga fel:** Om du fÃ¥r felmeddelandet "Your local changes to the following files would be overwritten by merge", beror det pÃ¥ att genererade filer är modifierade lokalt. Se till att alltid rensa dessa innan uppdatering. @@ -2091,4 +2100,4 @@ För att aktivera Prisma query logging i testmiljön: > **Notera:** > - Aktivera endast i test/staging, inte i produktion. > - Loggarna kan vara omfattande och pÃ¥verka prestanda. -> - Variabeln är avsiktligt inte dokumenterad i huvudkonfigurationen för att undvika oavsiktlig aktivering. \ No newline at end of file +> - Variabeln är avsiktligt inte dokumenterad i huvudkonfigurationen för att undvika oavsiktlig aktivering. diff --git a/backend/src/flyer-import/dto/flyer-import.response.ts b/backend/src/flyer-import/dto/flyer-import.response.ts index 116afcb2..8c834b4a 100644 --- a/backend/src/flyer-import/dto/flyer-import.response.ts +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -1,28 +1,30 @@ -export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none'; - -export type FlyerImportItem = { - flyerItemId: number | null; - rawName: string; - normalizedName: string; - category: string | null; - price: number | null; - priceUnit: string | null; - comparisonPrice: number | null; - comparisonUnit: string | null; - offerText: string | null; - parseConfidence: number; - parseReasons: string[]; - matchedProductId: number | null; - matchedProductName: string | null; - matchedVia: FlyerImportMatchVia; - matchConfidence: number; - matchReasons: string[]; -}; - -export type FlyerImportResponse = { - sessionId: number | null; - retailer: 'willys'; - parserVersion: 'v1'; - items: FlyerImportItem[]; - warnings: string[]; -}; +export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none'; + +export type FlyerImportItem = { + flyerItemId: number | null; + rawName: string; + normalizedName: string; + category: string | null; + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + offerText: string | null; + isOffer: boolean; + offerLimitText: string | null; + parseConfidence: number; + parseReasons: string[]; + matchedProductId: number | null; + matchedProductName: string | null; + matchedVia: FlyerImportMatchVia; + matchConfidence: number; + matchReasons: string[]; +}; + +export type FlyerImportResponse = { + sessionId: number | null; + retailer: 'willys'; + parserVersion: 'v1'; + items: FlyerImportItem[]; + warnings: string[]; +}; diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index aa624bf4..009888c8 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -1,299 +1,325 @@ -import { - BadRequestException, - Injectable, - Logger, - ServiceUnavailableException, -} from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { PrismaService } from '../prisma/prisma.service'; -import { normalizeName } from '../common/utils/normalize-name'; -import { - FlyerImportItem, - FlyerImportMatchVia, - FlyerImportResponse, -} from './dto/flyer-import.response'; - -const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; - -type FlyerParseItem = { - rawName: string; - normalizedName: string; - category: string | null; - price: number | null; - priceUnit: string | null; - comparisonPrice: number | null; - comparisonUnit: string | null; - offerText: string | null; - confidence: number; - reasonCodes: string[]; -}; - -type FlyerParseResponse = { - retailer: 'willys'; - parserVersion: 'v1'; - items: FlyerParseItem[]; - warnings: string[]; -}; - -type ProductLite = { - id: number; - name: string; - canonicalName: string | null; -}; - -@Injectable() -export class FlyerImportService { - private readonly logger = new Logger(FlyerImportService.name); - - constructor(private readonly prisma: PrismaService) {} - - async parseAndMatch(file: Express.Multer.File, userId: number): Promise { - const parsed = await this.parseViaImporter(file); - - const [products, aliases] = await Promise.all([ - this.prisma.product.findMany({ - where: { ownerId: userId, isActive: true }, - select: { id: true, name: true, canonicalName: true }, - }), - this.prisma.receiptAlias.findMany({ - where: { - OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }], - }, - select: { receiptName: true, productId: true }, - }), - ]); - - const aliasToProduct = new Map(); - for (const alias of aliases) { - const normalized = normalizeName(alias.receiptName); - if (!normalized) continue; - if (!aliasToProduct.has(normalized)) { - aliasToProduct.set(normalized, alias.productId); - } - } - - const productById = new Map(); - for (const product of products) { - productById.set(product.id, product); - } - - const items: FlyerImportItem[] = parsed.items.map((item) => { - const match = this.matchItem(item, products, aliasToProduct, productById); - return { - flyerItemId: null, - rawName: item.rawName, - normalizedName: item.normalizedName, - category: item.category, - price: item.price, - priceUnit: item.priceUnit, - comparisonPrice: item.comparisonPrice, - comparisonUnit: item.comparisonUnit, - offerText: item.offerText, - parseConfidence: item.confidence, - parseReasons: item.reasonCodes, - matchedProductId: match.product?.id ?? null, - matchedProductName: match.product?.name ?? null, - matchedVia: match.via, - matchConfidence: match.confidence, - matchReasons: match.reasons, - }; - }); - - const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items); - - return { - sessionId: persistedItems.sessionId, - retailer: parsed.retailer, - parserVersion: parsed.parserVersion, - items: persistedItems.items, - warnings: parsed.warnings, - }; - } - - private async persistSessionWithItems( - userId: number, - retailer: 'willys', - items: FlyerImportItem[], - ): Promise<{ sessionId: number; items: FlyerImportItem[] }> { - const weekKey = this.toWeekKey(new Date()); - - const session = await this.prisma.flyerSession.create({ - data: { - userId, - retailer, - weekKey, - status: 'draft', - }, - select: { id: true }, - }); - - const savedItems: FlyerImportItem[] = []; - for (const item of items) { - const created = await this.prisma.flyerItem.create({ - data: { - sessionId: session.id, - rawName: item.rawName, - normalizedName: item.normalizedName, - categoryHint: item.category, - price: item.price != null ? new Prisma.Decimal(item.price) : null, - priceUnit: item.priceUnit, - comparisonPrice: - item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null, - comparisonUnit: item.comparisonUnit, - offerText: item.offerText, - parseConfidence: item.parseConfidence, - parseReasons: item.parseReasons, - matchedProductId: item.matchedProductId, - matchedProductName: item.matchedProductName, - matchedVia: item.matchedVia, - matchConfidence: item.matchConfidence, - matchReasons: item.matchReasons, - }, - select: { id: true }, - }); - - savedItems.push({ ...item, flyerItemId: created.id }); - } - - return { sessionId: session.id, items: savedItems }; - } - - private toWeekKey(date: Date): string { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); - const dayNum = d.getUTCDay() || 7; - d.setUTCDate(d.getUTCDate() + 4 - dayNum); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); - return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; - } - - private matchItem( - item: FlyerParseItem, - products: ProductLite[], - aliasToProduct: Map, - productById: Map, - ): { - product: ProductLite | null; - via: FlyerImportMatchVia; - confidence: number; - reasons: string[]; - } { - const normalized = normalizeName(item.rawName || item.normalizedName); - if (!normalized) { - return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] }; - } - - const aliasedProductId = aliasToProduct.get(normalized); - if (aliasedProductId) { - const product = productById.get(aliasedProductId) ?? null; - return { - product, - via: product ? 'alias' : 'none', - confidence: product ? 1 : 0, - reasons: product ? ['alias_exact'] : ['alias_points_to_missing_product'], - }; - } - - for (const product of products) { - const pn = normalizeName(product.name); - const cn = product.canonicalName ? normalizeName(product.canonicalName) : null; - if (normalized === pn || (cn && normalized === cn)) { - return { - product, - via: 'exact', - confidence: 0.96, - reasons: ['normalized_exact'], - }; - } - } - - let best: { product: ProductLite; confidence: number; overlap: number } | null = null; - const itemTokens = this.tokenize(item.rawName); - for (const product of products) { - const productTokens = this.tokenize(product.canonicalName ?? product.name); - const overlap = this.tokenOverlap(itemTokens, productTokens); - if (overlap <= 0) continue; - const confidence = Math.min(0.92, 0.5 + overlap * 0.4); - if (!best || confidence > best.confidence) { - best = { product, confidence, overlap }; - } - } - - if (best && best.confidence >= 0.66) { - return { - product: best.product, - via: 'token', - confidence: best.confidence, - reasons: [`token_overlap:${best.overlap.toFixed(2)}`], - }; - } - - return { - product: null, - via: 'none', - confidence: 0, - reasons: ['no_match'], - }; - } - - 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; - } - - private async parseViaImporter(file: Express.Multer.File): Promise { - const form = new FormData(); - form.append( - 'file', - new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }), - file.originalname, - ); - form.append('retailer', 'willys'); - - let response: Response; - try { - response = await fetch(`${IMPORTER_SERVICE_URL}/api/flyer/parse`, { - method: 'POST', - body: form, - }); - } catch (err) { - this.logger.error(`Kunde inte nÃ¥ importer-api för flyer-parse: ${String(err)}`); - throw new ServiceUnavailableException('Importer-tjänsten är inte tillgänglig just nu.'); - } - - if (!response.ok) { - let message = `Importer-tjänsten svarade ${response.status}`; - try { - const body = (await response.json()) as { message?: string }; - if (typeof body.message === 'string' && body.message.trim()) { - message = body.message; - } - } catch { - // ignore parse issues - } - - if (response.status >= 400 && response.status < 500) { - throw new BadRequestException(message); - } - throw new ServiceUnavailableException(message); - } - - return response.json() as Promise; - } -} +import { + BadRequestException, + Injectable, + Logger, + ServiceUnavailableException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { normalizeName } from '../common/utils/normalize-name'; +import { + FlyerImportItem, + FlyerImportMatchVia, + FlyerImportResponse, +} from './dto/flyer-import.response'; + +const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; + +type FlyerParseItem = { + rawName: string; + normalizedName: string; + category: string | null; + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + offerText: string | null; + confidence: number; + reasonCodes: string[]; +}; + +type FlyerParseResponse = { + retailer: 'willys'; + parserVersion: 'v1'; + items: FlyerParseItem[]; + warnings: string[]; +}; + +type ProductLite = { + id: number; + name: string; + canonicalName: string | null; +}; + +@Injectable() +export class FlyerImportService { + private readonly logger = new Logger(FlyerImportService.name); + + constructor(private readonly prisma: PrismaService) {} + + async parseAndMatch(file: Express.Multer.File, userId: number): Promise { + const parsed = await this.parseViaImporter(file); + + const [products, aliases] = await Promise.all([ + this.prisma.product.findMany({ + where: { ownerId: userId, isActive: true }, + select: { id: true, name: true, canonicalName: true }, + }), + this.prisma.receiptAlias.findMany({ + where: { + OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }], + }, + select: { receiptName: true, productId: true }, + }), + ]); + + const aliasToProduct = new Map(); + for (const alias of aliases) { + const normalized = normalizeName(alias.receiptName); + if (!normalized) continue; + if (!aliasToProduct.has(normalized)) { + aliasToProduct.set(normalized, alias.productId); + } + } + + const productById = new Map(); + for (const product of products) { + productById.set(product.id, product); + } + + const items: FlyerImportItem[] = parsed.items.map((item) => { + const match = this.matchItem(item, products, aliasToProduct, productById); + const offerLimitText = this.extractOfferLimitText(item.offerText); + return { + flyerItemId: null, + rawName: item.rawName, + normalizedName: item.normalizedName, + category: item.category, + price: item.price, + priceUnit: item.priceUnit, + comparisonPrice: item.comparisonPrice, + comparisonUnit: item.comparisonUnit, + offerText: item.offerText, + isOffer: this.isOfferItem(item), + offerLimitText, + parseConfidence: item.confidence, + parseReasons: item.reasonCodes, + matchedProductId: match.product?.id ?? null, + matchedProductName: match.product?.name ?? null, + matchedVia: match.via, + matchConfidence: match.confidence, + matchReasons: match.reasons, + }; + }); + + const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items); + + return { + sessionId: persistedItems.sessionId, + retailer: parsed.retailer, + parserVersion: parsed.parserVersion, + items: persistedItems.items, + warnings: parsed.warnings, + }; + } + + private async persistSessionWithItems( + userId: number, + retailer: 'willys', + items: FlyerImportItem[], + ): Promise<{ sessionId: number; items: FlyerImportItem[] }> { + const weekKey = this.toWeekKey(new Date()); + + const session = await this.prisma.flyerSession.create({ + data: { + userId, + retailer, + weekKey, + status: 'draft', + }, + select: { id: true }, + }); + + const savedItems: FlyerImportItem[] = []; + for (const item of items) { + const created = await this.prisma.flyerItem.create({ + data: { + sessionId: session.id, + rawName: item.rawName, + normalizedName: item.normalizedName, + categoryHint: item.category, + price: item.price != null ? new Prisma.Decimal(item.price) : null, + priceUnit: item.priceUnit, + comparisonPrice: + item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null, + comparisonUnit: item.comparisonUnit, + offerText: item.offerText, + parseConfidence: item.parseConfidence, + parseReasons: item.parseReasons, + matchedProductId: item.matchedProductId, + matchedProductName: item.matchedProductName, + matchedVia: item.matchedVia, + matchConfidence: item.matchConfidence, + matchReasons: item.matchReasons, + }, + select: { id: true }, + }); + + savedItems.push({ ...item, flyerItemId: created.id }); + } + + return { sessionId: session.id, items: savedItems }; + } + + private toWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; + } + + private matchItem( + item: FlyerParseItem, + products: ProductLite[], + aliasToProduct: Map, + productById: Map, + ): { + product: ProductLite | null; + via: FlyerImportMatchVia; + confidence: number; + reasons: string[]; + } { + const normalized = normalizeName(item.rawName || item.normalizedName); + if (!normalized) { + return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] }; + } + + const aliasedProductId = aliasToProduct.get(normalized); + if (aliasedProductId) { + const product = productById.get(aliasedProductId) ?? null; + return { + product, + via: product ? 'alias' : 'none', + confidence: product ? 1 : 0, + reasons: product ? ['alias_exact'] : ['alias_points_to_missing_product'], + }; + } + + for (const product of products) { + const pn = normalizeName(product.name); + const cn = product.canonicalName ? normalizeName(product.canonicalName) : null; + if (normalized === pn || (cn && normalized === cn)) { + return { + product, + via: 'exact', + confidence: 0.96, + reasons: ['normalized_exact'], + }; + } + } + + let best: { product: ProductLite; confidence: number; overlap: number } | null = null; + const itemTokens = this.tokenize(item.rawName); + for (const product of products) { + const productTokens = this.tokenize(product.canonicalName ?? product.name); + const overlap = this.tokenOverlap(itemTokens, productTokens); + if (overlap <= 0) continue; + const confidence = Math.min(0.92, 0.5 + overlap * 0.4); + if (!best || confidence > best.confidence) { + best = { product, confidence, overlap }; + } + } + + if (best && best.confidence >= 0.66) { + return { + product: best.product, + via: 'token', + confidence: best.confidence, + reasons: [`token_overlap:${best.overlap.toFixed(2)}`], + }; + } + + return { + product: null, + via: 'none', + confidence: 0, + reasons: ['no_match'], + }; + } + + 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; + } + + private isOfferItem(item: FlyerParseItem): boolean { + return item.price != null || item.comparisonPrice != null || !!item.offerText?.trim(); + } + + private extractOfferLimitText(offerText: string | null): string | null { + if (!offerText) return null; + + const normalized = offerText.replace(/\s+/g, ' ' ).trim(); + if (!normalized) return null; + + const limitMatch = normalized.match(/(?:max|högst)\s+[^,.;]+(?:hushÃ¥ll|kund)?/i); + if (limitMatch?.[0]) { + return limitMatch[0].trim(); + } + + const householdMatch = normalized.match(/[^,.;]*(?:hushÃ¥ll|kund)[^,.;]*/i); + if (householdMatch?.[0]) { + return householdMatch[0].trim(); + } + + return null; + } + + private async parseViaImporter(file: Express.Multer.File): Promise { + const form = new FormData(); + form.append( + 'file', + new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }), + file.originalname, + ); + form.append('retailer', 'willys'); + + let response: Response; + try { + response = await fetch(`${IMPORTER_SERVICE_URL}/api/flyer/parse`, { + method: 'POST', + body: form, + }); + } catch (err) { + this.logger.error(`Kunde inte nÃ¥ importer-api för flyer-parse: ${String(err)}`); + throw new ServiceUnavailableException('Importer-tjänsten är inte tillgänglig just nu.'); + } + + if (!response.ok) { + let message = `Importer-tjänsten svarade ${response.status}`; + try { + const body = (await response.json()) as { message?: string }; + if (typeof body.message === 'string' && body.message.trim()) { + message = body.message; + } + } catch { + // ignore parse issues + } + + if (response.status >= 400 && response.status < 500) { + throw new BadRequestException(message); + } + throw new ServiceUnavailableException(message); + } + + return response.json() as Promise; + } +} diff --git a/flutter/lib/features/import/domain/flyer_import_item.dart b/flutter/lib/features/import/domain/flyer_import_item.dart index 69382bdf..68f2a2b7 100644 --- a/flutter/lib/features/import/domain/flyer_import_item.dart +++ b/flutter/lib/features/import/domain/flyer_import_item.dart @@ -1,43 +1,61 @@ -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(), - ); - } -} +class FlyerImportItem { + final int? flyerItemId; + final String rawName; + final String normalizedName; + final String? category; + final double? price; + final String? priceUnit; + final String? offerText; + final bool isOffer; + final String? offerLimitText; + final double? comparisonPrice; + final String? comparisonUnit; + final double? parseConfidence; + final List parseReasons; + 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.isOffer = false, + this.offerLimitText, + this.comparisonPrice, + this.comparisonUnit, + this.parseConfidence, + this.parseReasons = const [], + 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?, + isOffer: json['isOffer'] == true, + offerLimitText: json['offerLimitText'] as String?, + comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(), + comparisonUnit: json['comparisonUnit'] as String?, + parseConfidence: (json['parseConfidence'] as num?)?.toDouble(), + parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [], + 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/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart index 6b0fd768..60110a88 100644 --- a/flutter/lib/features/import/presentation/flyer_import_tab.dart +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/utils/pdf_opener.dart'; import '../../auth/data/auth_providers.dart'; import '../data/import_providers.dart'; import '../domain/flyer_import_item.dart'; @@ -25,7 +26,7 @@ class _FlyerImportTabState extends ConsumerState { Future _pickFile() async { final result = await FilePicker.pickFiles( type: FileType.custom, - allowedExtensions: ['pdf', 'txt'], + allowedExtensions: ['pdf', 'txt', 'png', 'jpg', 'jpeg', 'webp'], withData: true, ); if (result == null || result.files.isEmpty) return; @@ -108,6 +109,91 @@ class _FlyerImportTabState extends ConsumerState { } } + String _formatPrice(double? price, String? unit) { + if (price == null) return ''; + final raw = price.toStringAsFixed(2).replaceAll('.', ','); + final unitPart = (unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : ''; + return '$raw kr$unitPart'; + } + + Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) { + final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null; + if (!hasOffer) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: Colors.red.shade200), + ), + child: Text( + 'ERBJUDANDE', + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.red.shade900, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + Widget _buildFlyerPreview(ThemeData theme) { + final file = _pickedFile; + final bytes = file?.bytes; + if (bytes == null) return const SizedBox.shrink(); + + final filename = file?.name ?? ''; + final fallbackExt = filename.contains('.') ? filename.split('.').last : ''; + final ext = (file?.extension ?? fallbackExt).toLowerCase(); + final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext); + + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + dense: true, + leading: Icon( + isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined, + color: theme.colorScheme.primary, + ), + title: const Text('Flyerförhandsvisning'), + subtitle: Text(file?.name ?? ''), + trailing: isImage + ? null + : OutlinedButton.icon( + icon: const Icon(Icons.open_in_new, size: 16), + label: const Text('Visa flyer'), + style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact), + onPressed: () async { + final opened = await openPdfBytes(bytes); + if (!context.mounted || opened) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'), + ), + ); + }, + ), + ), + if (isImage) + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 420), + child: Image.memory(bytes, fit: BoxFit.contain), + ), + ), + ) + else + const SizedBox(height: 8), + ], + ), + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -120,14 +206,14 @@ class _FlyerImportTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Ladda upp flyer (PDF/txt), granska rader och planera inköp med ett klick.', + 'Ladda upp flyer, granska erbjudanden 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'), + label: Text(_pickedFile?.name ?? 'Välj flyerfil'), ), const SizedBox(height: 12), FilledButton.icon( @@ -135,6 +221,8 @@ class _FlyerImportTabState extends ConsumerState { icon: const Icon(Icons.auto_awesome), label: const Text('Importera flyer'), ), + const SizedBox(height: 12), + _buildFlyerPreview(theme), if (_isLoading) ...[ const SizedBox(height: 12), const LinearProgressIndicator(), @@ -154,7 +242,7 @@ class _FlyerImportTabState extends ConsumerState { } }); }, - child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), + child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), ), ], ), @@ -162,14 +250,29 @@ class _FlyerImportTabState extends ConsumerState { ...items.asMap().entries.map((entry) { final index = entry.key; final item = entry.value; + final priceText = _formatPrice(item.price, item.priceUnit); + final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit); + final limitText = item.offerLimitText?.trim(); + 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(' · ')), + title: Row( + children: [ + Expanded(child: Text(item.rawName)), + _buildOfferBadge(item, theme), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (priceText.isNotEmpty) Text('Pris: $priceText'), + if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), + if (limitText != null && limitText.isNotEmpty) Text('Begränsning: $limitText'), + if ((item.offerText?.trim().isNotEmpty ?? false)) Text(item.offerText!.trim()), + if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'), + ], + ), controlAffinity: ListTileControlAffinity.leading, ); }), @@ -193,4 +296,5 @@ class _FlyerImportTabState extends ConsumerState { ), ); } -} +} +