From b04d1579153fd25b9a5719ba822c17f87b94638b Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 24 May 2026 19:32:13 +0200 Subject: [PATCH] feat(flyer-import): add detailed product signals and display names - Added `signals` and `displayNameDetailed` fields to FlyerItem model in Prisma schema - Introduced `FlyerImportSignals` type with origin countries, labels, quality flags, variant, and packaging - Added `displayNameDetailed` field to FlyerImportItem DTO and Flutter model - Implemented utility functions for signal extraction and display name building - Updated flyer import service to persist and return signals/category data - Enhanced Flutter UI to display detailed product information including badges for signals - Added new test coverage for signals persistence and display name generation - Added new import-common module for shared import utilities - Created database migration for new fields - Added Kilo plan for feature development --- .kilo/plans/1779641992740-glowing-sailor.md | 198 ++++++++++++ .../migration.sql | 4 + backend/prisma/schema.prisma | 2 + .../flyer-import/dto/flyer-import.response.ts | 10 + .../src/flyer-import/flyer-import.module.ts | 5 +- .../flyer-import/flyer-import.service.spec.ts | 187 +++++++++++- .../src/flyer-import/flyer-import.service.ts | 289 +++++++++++++----- .../category-resolver.service.spec.ts | 36 +++ .../category-resolver.service.ts | 114 +++++++ .../import-common/import-display-name.util.ts | 15 + .../src/import-common/import-item.types.ts | 38 +++ .../import-common/import-signals.util.spec.ts | 45 +++ .../src/import-common/import-signals.util.ts | 103 +++++++ .../import/domain/flyer_import_item.dart | 111 +++++-- .../import/presentation/flyer_import_tab.dart | 45 ++- .../import/domain/flyer_import_item_test.dart | 29 ++ 16 files changed, 1124 insertions(+), 107 deletions(-) create mode 100644 .kilo/plans/1779641992740-glowing-sailor.md create mode 100644 backend/prisma/migrations/20260524193000_add_flyer_item_signals_and_display_name/migration.sql create mode 100644 backend/src/import-common/category-resolver.service.spec.ts create mode 100644 backend/src/import-common/category-resolver.service.ts create mode 100644 backend/src/import-common/import-display-name.util.ts create mode 100644 backend/src/import-common/import-item.types.ts create mode 100644 backend/src/import-common/import-signals.util.spec.ts create mode 100644 backend/src/import-common/import-signals.util.ts create mode 100644 flutter/test/features/import/domain/flyer_import_item_test.dart diff --git a/.kilo/plans/1779641992740-glowing-sailor.md b/.kilo/plans/1779641992740-glowing-sailor.md new file mode 100644 index 00000000..d50d397c --- /dev/null +++ b/.kilo/plans/1779641992740-glowing-sailor.md @@ -0,0 +1,198 @@ +# Plan: Harmonisera flyer-import och kvitto-import + +## Mål +Implementera en gemensam importmodell och matchningspipeline så att flyer-import och kvitto-import beter sig så likt som möjligt, med fokus på: +- Automatisk strukturering av namn/brand/vikt samt bundle-detaljer +- Automatiskt kategoriupplösning (`categoryHint -> categoryId`) +- Matchning mot befintliga produkter via normaliserade namn + signaler +- Ingen automatisk skapning av produkter +- Förberedelse för framtida automation via strukturerade signaler (`signals` JSON) + +## Icke-mål (denna implementation) +- Ingen auto-create av produkter i produktkatalog +- Ingen ändring av övergripande UI-flöde (manuell import/validering kvar) +- Ingen full omskrivning av receipt-import; vi extraherar och återanvänder delar stegvis + +## Nuvarande gap (från kodbasen) +1. `FlyerItem.categoryId` sätts till `null` i parse-flödet trots `categoryHint`. +2. Flyer-matchning använder enklare strategi än receipt-import (färre regler/signalvikter). +3. Ingen strukturerad lagring av ursprung/etiketter (t.ex. Sverige, Eko) i flyer. +4. Bundleinformation finns men exponeras inte tydligt som detaljnamn i payload. +5. Receipt och flyer använder olika “kontrakt” för mellanrepresentation. + +## Övergripande design +Inför en gemensam intern domänmodell för importerade rader (backend), och låt både flyer- och kvittoflöde mappa till den innan kategori/matchning. + +### Gemensam intern modell (ny) +`ImportedItemCandidate` (internt, ej API-brytande initialt): +- `rawName`, `normalizedName`, `brand` +- `weight`, `bundleWeight`, `isBundle`, `bundleItems` +- `price`, `priceUnit`, `comparisonPrice`, `comparisonUnit` +- `categoryHint`, `categoryId` +- `matchedProductId`, `matchedProductName`, `matchedVia`, `matchConfidence`, `matchReasons` +- `signals` (JSON): + - `originCountries: string[]` + - `labels: string[]` (ekologisk, laktosfri, etc) + - `qualityFlags: string[]` (normaliserade flaggor, ex `eco`) + - `variant: string | null` + - `packaging: string | null` +- `displayNameDetailed` (beräknat fält, kan persistas eller beräknas vid response) + +## Faser och implementation + +## Fas 1: Datamodell och migration +1. Uppdatera `backend/prisma/schema.prisma`: + - Lägg till `signals Json?` på `FlyerItem` + - Lägg till `displayNameDetailed String?` på `FlyerItem` +2. Skapa Prisma-migration. +3. Säkerställ bakåtkompatibilitet: + - Nullabla fält + - Ingen ändring av befintliga constraints/index som bryter drift +4. (Valfritt i samma fas) indexera vanligt använda JSON-signaler senare först efter verifierad nytta. + +### Acceptanskriterier fas 1 +- Migration appliceras lokalt utan dataförlust. +- Befintliga endpoints fungerar med gamla rader (`signals = null`). + +## Fas 2: Gemensamma normaliserings-/signalverktyg +1. Skapa gemensam utility-modul i backend, exempel: + - `backend/src/import-common/import-item.types.ts` + - `backend/src/import-common/import-signals.util.ts` + - `backend/src/import-common/import-display-name.util.ts` +2. Implementera signal-extraktion från textfält (`rawName`, `brand`, `offerText`): + - Ursprungsländer till `originCountries` + - Etiketter/märkningar till `labels`/`qualityFlags` + - Pack-format till `packaging` +3. Normalisera utan att förlora information: + - Ta bort signalord från primär matchsträng men spara i `signals` + - Ex: `Fläskytterfilé (Sverige)` -> matchsträng `flaskytterfile`, `signals.originCountries=["Sverige"]` +4. Implementera `displayNameDetailed`: + - Bundle: inkludera `bundleItems` i visningsnamn + - Ex: `Kaptenens Favoriter (Chumlax 3x100g + Alaska pollock 3x100g)` + +### Acceptanskriterier fas 2 +- Signals extraheras deterministiskt för kända mönster (Sverige/Tyskland/Eko/Ekologiskt). +- `displayNameDetailed` genereras för bundles. + +## Fas 3: Kategoriupplösning i flyer (paritet med kvitto) +1. Extrahera/återanvänd kategori-regelmotorn från receipt-import till gemensam tjänst: + - Ex: `backend/src/import-common/category-resolver.service.ts` +2. Använd den i flyer-import efter normalisering: + - `categoryHint` + signaltext + regler -> `categoryId` +3. Prioritet: + - Produktmatchad kategori (om säkert matchad produkt har kategori) kan väga högst + - Annars regelbaserad kategori + - Annars behåll `categoryHint` utan `categoryId` +4. Specifika regler för kött/fläskytterfilé verifieras. + +### Acceptanskriterier fas 3 +- `Fläskytterfilé` får korrekt `categoryId` i flyer-session. +- `categoryId` sätts automatiskt för en betydande andel rader med tydlig signal. + +## Fas 4: Matchningsparitet flyer <-> kvitto +1. Bryt ut matchning till gemensam matcher (eller harmonisera algoritm): + - alias exact + - canonical/normalized exact + - token/fuzzy + - bonus för brand/weight/signalträffar +2. Matchning ska använda signalrensad namnsträng + metadata: + - Länder och eco-etiketter ska inte sabotera namnmatch +3. Standardisera reason codes mellan flöden (så långt möjligt utan brytande API): + - `alias_exact`, `normalized_exact`, `token_overlap:*`, `no_match` +4. Behåll strikt policy: ingen auto-create produkt. + +### Acceptanskriterier fas 4 +- Färre `no_match` på samma flyer-input jämfört med baseline. +- Matchningsorsaker blir mer förklarbara och konsekventa. + +## Fas 5: API/DTO och persistens +1. Uppdatera flyer DTO: + - `backend/src/flyer-import/dto/flyer-import.response.ts` + - Lägg till `signals` och `displayNameDetailed`. +2. Uppdatera persistens i `flyer-import.service.ts`: + - Spara `signals`, `displayNameDetailed`, `categoryId`. +3. Säkerställ att `getSession`, `getLatestSession`, `updateSessionItem` returnerar nya fält. +4. Behåll kompatibilitet mot klient: + - Nya fält adderas utan att ta bort befintliga. + +### Acceptanskriterier fas 5 +- Response innehåller tydlig bundle-info och signaler per rad. +- Inga regressions i existerande frontend-parsing. + +## Fas 6: Frontend (flyer import-tab) +1. Uppdatera domänmodeller i Flutter: + - `flutter/lib/features/import/domain/flyer_import_item.dart` + - ev. session/result-objekt +2. Visa `displayNameDetailed` där tillgängligt, annars fallback `rawName`. +3. Visa `bundleItems` tydligt i list-/detaljrad. +4. Visa badge/metadata för signaler (`Sverige`, `Ekologisk`) utan att skriva över produktnamn. +5. Säkerställ att manuellt urval till inköpslista fortsätter fungera. + +### Acceptanskriterier fas 6 +- Bundle-rader är tydligare i UI. +- Ursprung/eko syns som metadata. + +## Fas 7: Teststrategi + +### Backend enhetstester +- `flyer-normalizer.service.spec.ts` + - extraktion av `signals` (origin/labels) + - bundle-detaljnamn +- Ny kategori-resolver-spec + - `Fläskytterfilé` -> köttkategori +- `flyer-import.service.spec.ts` + - `categoryId` sätts vid tydlig signal + - `signals` och `displayNameDetailed` persisteras/returneras +- Matchningstester + - namn med land/eko matchar korrekt produkt + +### Integrationstester +- End-to-end parseAndMatch med representativ flyer-fixture. +- Verifiera att inga produkter auto-skaps. +- Verifiera att shopping-list insertion fungerar med/utan `matchedProductId`. + +### Frontendtester +- Serialisering av nya fält i import-session. +- Rendering av `displayNameDetailed` + `bundleItems`. + +## Fas 8: Mätning och rollout +1. Lägg till enkel före/efter-mätning i logg/trace: + - andel `no_match` + - andel med satt `categoryId` +2. Soft rollout via feature flag (om möjligt), annars stegvis release. +3. Utvärdera verkliga flyer-sessioner innan vidare automatisering. + +## Konkreta filer att ändra (planerad) +- `backend/prisma/schema.prisma` +- `backend/src/flyer-import/flyer-import.service.ts` +- `backend/src/flyer-import/services/flyer-normalizer.service.ts` +- `backend/src/flyer-import/dto/flyer-import.response.ts` +- `backend/src/receipt-import/receipt-import.service.ts` (endast för extraktion/återanvändning av gemensamma delar) +- Nya gemensamma filer under `backend/src/import-common/*` +- `flutter/lib/features/import/domain/flyer_import_item.dart` +- `flutter/lib/features/import/data/flyer_import_session.dart` +- `flutter/lib/features/import/presentation/flyer_import_tab.dart` +- Relevanta spec/test-filer i backend + flutter + +## Risker och mitigering +- Risk: API-kontraktsändringar bryter klient. + - Mitigering: endast additive fält, fallback på gamla fält. +- Risk: Felkategori vid aggressiva regler. + - Mitigering: regelprioritet + reason-codes + tester för edge cases. +- Risk: Övermatchning av produkter. + - Mitigering: tröskelvärden + konservativ confidence för fuzzy. + +## Leveransordning (rekommenderad) +1. Fas 1–2 (schema + signals + utilities) +2. Fas 3 (kategoriupplösning flyer) +3. Fas 4 (matchningsparitet) +4. Fas 5 (DTO/persistens) +5. Fas 6 (frontend) +6. Fas 7–8 (tester + mätning/rollout) + +## Definition of Done +- Flyer och kvitto använder samma centrala regler för kategorisering/matchning där möjligt. +- Flyer-rader innehåller `signals` och tydligare produktrepresentation (`displayNameDetailed`, bundle-innehåll). +- `categoryId` sätts automatiskt i flyer när tillräcklig signal finns (inkl. fläskytterfilé-fall). +- Ingen automatisk produktskapning sker. +- Tester uppdaterade och gröna. diff --git a/backend/prisma/migrations/20260524193000_add_flyer_item_signals_and_display_name/migration.sql b/backend/prisma/migrations/20260524193000_add_flyer_item_signals_and_display_name/migration.sql new file mode 100644 index 00000000..d7a15fe1 --- /dev/null +++ b/backend/prisma/migrations/20260524193000_add_flyer_item_signals_and_display_name/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE `FlyerItem` + ADD COLUMN `signals` JSON NULL, + ADD COLUMN `displayNameDetailed` VARCHAR(191) NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b9b601be..ee59a37c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -325,6 +325,8 @@ model FlyerItem { bundleWeight String? isBundle Boolean @default(false) bundleItems Json? + signals Json? + displayNameDetailed String? offerText String? parseConfidence Float parseReasons Json? diff --git a/backend/src/flyer-import/dto/flyer-import.response.ts b/backend/src/flyer-import/dto/flyer-import.response.ts index 25cf1046..9e1e5f74 100644 --- a/backend/src/flyer-import/dto/flyer-import.response.ts +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -1,5 +1,13 @@ export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none'; +export type FlyerImportSignals = { + originCountries: string[]; + labels: string[]; + qualityFlags: string[]; + variant: string | null; + packaging: string | null; +}; + export type FlyerReasonDescriptor = { code: string; kind: 'parse' | 'match'; @@ -24,6 +32,8 @@ export type FlyerImportItem = { bundleWeight: string | null; isBundle: boolean; bundleItems: string[]; + displayNameDetailed: string | null; + signals: FlyerImportSignals | null; offerText: string | null; isOffer: boolean; offerLimitText: string | null; diff --git a/backend/src/flyer-import/flyer-import.module.ts b/backend/src/flyer-import/flyer-import.module.ts index 0a6966c3..d9518b9e 100644 --- a/backend/src/flyer-import/flyer-import.module.ts +++ b/backend/src/flyer-import/flyer-import.module.ts @@ -5,15 +5,18 @@ import { FlyerImportService } from './flyer-import.service'; import { TextExtractorService } from './services/text-extractor.service'; import { AiFlyerParserService } from './services/ai-flyer-parser.service'; import { FlyerNormalizerService } from './services/flyer-normalizer.service'; +import { CategoriesModule } from '../categories/categories.module'; +import { CategoryResolverService } from '../import-common/category-resolver.service'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, CategoriesModule], controllers: [FlyerImportController], providers: [ FlyerImportService, TextExtractorService, AiFlyerParserService, FlyerNormalizerService, + CategoryResolverService, ], }) export class FlyerImportModule {} diff --git a/backend/src/flyer-import/flyer-import.service.spec.ts b/backend/src/flyer-import/flyer-import.service.spec.ts index 09c3c7a0..280f2354 100644 --- a/backend/src/flyer-import/flyer-import.service.spec.ts +++ b/backend/src/flyer-import/flyer-import.service.spec.ts @@ -3,25 +3,44 @@ import { FlyerImportService } from './flyer-import.service'; describe('FlyerImportService', () => { const prismaMock = { + product: { + findMany: jest.fn(), + }, + receiptAlias: { + findMany: jest.fn(), + }, flyerSession: { findFirst: jest.fn(), findUnique: jest.fn(), + create: jest.fn(), }, flyerItem: { findUnique: jest.fn(), update: jest.fn(), + create: jest.fn(), + }, + aiTrace: { + create: jest.fn(), }, category: { findUnique: jest.fn(), }, }; - const createService = () => + const createService = (overrides?: { + categoriesService?: any; + categoryResolver?: any; + textExtractor?: any; + aiParser?: any; + normalizer?: any; + }) => new FlyerImportService( prismaMock as any, - {} as any, - {} as any, - {} as any, + overrides?.categoriesService ?? { findFlattened: jest.fn().mockResolvedValue([]) }, + overrides?.categoryResolver ?? { resolveForFlyer: jest.fn().mockReturnValue(null) }, + overrides?.textExtractor ?? {}, + overrides?.aiParser ?? {}, + overrides?.normalizer ?? {}, ); beforeEach(() => { @@ -78,6 +97,8 @@ describe('FlyerImportService', () => { bundleWeight: null, isBundle: false, bundleItems: [], + displayNameDetailed: 'Tomat', + signals: { originCountries: ['Sverige'], labels: [], qualityFlags: [], variant: null, packaging: null }, offerText: 'Max 2 kop/hushall', parseConfidence: 0.9, parseReasons: ['ai_parsed'], @@ -97,6 +118,8 @@ describe('FlyerImportService', () => { expect(result.items).toHaveLength(1); expect(result.items[0].flyerItemId).toBe(99); expect(result.items[0].matchedVia).toBe('exact'); + expect(result.items[0].displayNameDetailed).toBe('Tomat'); + expect(result.items[0].signals?.originCountries).toEqual(['Sverige']); expect(result.items[0].parseReasonsDetailed[0].title).toBe('AI-tolkad rad'); expect(result.items[0].matchReasonsDetailed[0].title).toBe('Exakt normaliserad matchning'); expect(result.sourceAvailable).toBe(false); @@ -140,6 +163,162 @@ describe('FlyerImportService', () => { }); }); + describe('parseAndMatch', () => { + it('persists and returns signals/displayNameDetailed/categoryId in parse pipeline', async () => { + prismaMock.product.findMany.mockResolvedValue([ + { id: 11, name: 'Fläskytterfilé', canonicalName: 'Fläskytterfilé', categoryId: 7 }, + ]); + prismaMock.receiptAlias.findMany.mockResolvedValue([]); + prismaMock.flyerSession.create.mockResolvedValue({ id: 200 }); + prismaMock.flyerItem.create + .mockResolvedValueOnce({ id: 1001 }); + prismaMock.aiTrace.create.mockResolvedValue({ id: 1 }); + + const categoriesService = { findFlattened: jest.fn().mockResolvedValue([]) }; + const categoryResolver = { resolveForFlyer: jest.fn().mockReturnValue(7) }; + const textExtractor = { extractText: jest.fn().mockResolvedValue('raw flyer text') }; + const aiParser = { + parseWithAI: jest.fn().mockResolvedValue({ + items: [ + { + rawName: 'Fläskytterfilé (Sverige) EKO', + normalizedName: 'flaskytterfile sverige eko', + brand: 'Garant', + category: 'Kött', + price: 99.9, + unit: 'kg', + comparisonPrice: null, + comparisonUnit: null, + weight: '900g', + bundleWeight: null, + isBundle: true, + bundleItems: ['Del 1', 'Del 2'], + offer: 'ekologiskt från Sverige', + confidence: 0.93, + reasonCodes: ['ai_parsed'], + }, + ], + trace: { prompt: null, rawOutput: null, chunkCount: 1, retryCount: 0 }, + }), + }; + const normalizer = { + normalize: jest.fn().mockReturnValue([ + { + rawName: 'Fläskytterfilé (Sverige) EKO', + normalizedName: 'flaskytterfile sverige eko', + brand: 'Garant', + categoryHint: 'Kött', + price: 99.9, + priceUnit: 'kg', + comparisonPrice: null, + comparisonUnit: null, + weight: '900g', + bundleWeight: null, + isBundle: true, + bundleItems: ['Del 1', 'Del 2'], + offerText: 'ekologiskt från Sverige', + parseConfidence: 0.93, + parseReasons: ['ai_parsed'], + }, + ]), + }; + + const service = createService({ + categoriesService, + categoryResolver, + textExtractor, + aiParser, + normalizer, + }); + + const result = await service.parseAndMatch( + { + originalname: 'flyer.pdf', + mimetype: 'application/pdf', + size: 10, + buffer: Buffer.from('pdf'), + } as any, + 1, + ); + + expect(result.items).toHaveLength(1); + expect(result.items[0].displayNameDetailed).toBe('Fläskytterfilé (Sverige) EKO (Del 1 + Del 2)'); + expect(result.items[0].signals?.originCountries).toEqual(['Sverige']); + expect(result.items[0].signals?.qualityFlags).toContain('eco'); + expect(result.items[0].categoryId).toBe(7); + expect(result.items[0].normalizedName).toBe('flaskytterfile'); + expect(prismaMock.flyerItem.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + displayNameDetailed: 'Fläskytterfilé (Sverige) EKO (Del 1 + Del 2)', + categoryId: 7, + signals: expect.objectContaining({ originCountries: ['Sverige'] }), + }), + }), + ); + }); + + it('logs warning when categories fallback is used', async () => { + prismaMock.product.findMany.mockResolvedValue([]); + prismaMock.receiptAlias.findMany.mockResolvedValue([]); + prismaMock.flyerSession.create.mockResolvedValue({ id: 201 }); + prismaMock.flyerItem.create.mockResolvedValue({ id: 1002 }); + prismaMock.aiTrace.create.mockResolvedValue({ id: 2 }); + + const categoriesService = { findFlattened: jest.fn().mockRejectedValue(new Error('db down')) }; + const textExtractor = { extractText: jest.fn().mockResolvedValue('raw') }; + const aiParser = { + parseWithAI: jest.fn().mockResolvedValue({ + items: [{ rawName: 'Tomat' }], + trace: { prompt: null, rawOutput: null, chunkCount: 1, retryCount: 0 }, + }), + }; + const normalizer = { + normalize: jest.fn().mockReturnValue([ + { + rawName: 'Tomat', + normalizedName: 'tomat', + brand: null, + categoryHint: null, + price: null, + priceUnit: null, + comparisonPrice: null, + comparisonUnit: null, + weight: null, + bundleWeight: null, + isBundle: false, + bundleItems: [], + offerText: null, + parseConfidence: 0.9, + parseReasons: ['ai_parsed'], + }, + ]), + }; + + const service = createService({ + categoriesService, + textExtractor, + aiParser, + normalizer, + }); + const warnSpy = jest.spyOn((service as any).logger, 'warn'); + + await service.parseAndMatch( + { + originalname: 'flyer.pdf', + mimetype: 'application/pdf', + size: 10, + buffer: Buffer.from('pdf'), + } as any, + 1, + ); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Could not load categories for flyer import'), + ); + }); + }); + describe('getLatestSession', () => { it('returns empty response when no sessions exist', async () => { prismaMock.flyerSession.findFirst.mockResolvedValue(null); diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index d660cfe1..f584d241 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -6,9 +6,10 @@ import { NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { PrismaService } from '../prisma/prisma.service'; -import { normalizeName } from '../common/utils/normalize-name'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { normalizeName } from '../common/utils/normalize-name'; +import { CategoriesService } from '../categories/categories.service'; import { FlyerImportItem, FlyerImportMatchVia, @@ -18,6 +19,10 @@ import { TextExtractorService } from './services/text-extractor.service'; import { AiFlyerParserService } from './services/ai-flyer-parser.service'; import { FlyerNormalizerService } from './services/flyer-normalizer.service'; import { describeMatchReason, describeParseReason } from './services/reason-codes'; +import { CategoryResolverService } from '../import-common/category-resolver.service'; +import { buildDisplayNameDetailed } from '../import-common/import-display-name.util'; +import { extractImportSignals } from '../import-common/import-signals.util'; +import { ImportedItemSignals } from '../import-common/import-item.types'; type FlyerParseItem = { rawName: string; @@ -58,11 +63,12 @@ type ExtractedOfferSignals = { hasCampaignPattern: boolean; }; -type ProductLite = { - id: number; - name: string; - canonicalName: string | null; -}; +type ProductLite = { + id: number; + name: string; + canonicalName: string | null; + categoryId: number | null; +}; @Injectable() export class FlyerImportService { @@ -70,29 +76,37 @@ export class FlyerImportService { private readonly MAX_BUNDLE_ITEMS = 20; private readonly MAX_BUNDLE_ITEM_LENGTH = 120; - constructor( - private readonly prisma: PrismaService, - private readonly textExtractor: TextExtractorService, - private readonly aiParser: AiFlyerParserService, - private readonly normalizer: FlyerNormalizerService, - ) {} + constructor( + private readonly prisma: PrismaService, + private readonly categoriesService: CategoriesService, + private readonly categoryResolver: CategoryResolverService, + private readonly textExtractor: TextExtractorService, + private readonly aiParser: AiFlyerParserService, + private readonly normalizer: FlyerNormalizerService, + ) {} async parseAndMatch(file: Express.Multer.File, userId: number): Promise { const startedAt = Date.now(); const parsed = await this.parseViaInternal(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 [products, aliases, categories] = await Promise.all([ + this.prisma.product.findMany({ + where: { ownerId: userId, isActive: true }, + select: { id: true, name: true, canonicalName: true, categoryId: true }, + }), + this.prisma.receiptAlias.findMany({ + where: { + OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }], + }, + select: { receiptName: true, productId: true }, + }), + this.categoriesService.findFlattened().catch((error) => { + this.logger.warn( + `Could not load categories for flyer import, proceeding without rule categories: ${error instanceof Error ? error.message : String(error)}`, + ); + return []; + }), + ]); const aliasToProduct = new Map(); for (const alias of aliases) { @@ -109,20 +123,39 @@ export class FlyerImportService { } const items: FlyerImportItem[] = parsed.items.map((item) => { - const match = this.matchItem(item, products, aliasToProduct, productById); + const signalData = extractImportSignals({ + rawName: item.rawName, + brand: item.brand, + offerText: item.offerText, + }); + + const match = this.matchItem(item, signalData.normalizedMatchName, signalData.signals, products, aliasToProduct, productById); const signals = this.extractOfferSignals(item.offerText); const price = item.price ?? signals.price; const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit; const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice; const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit; const offerLimitText = this.extractOfferLimitText(item.offerText); + const displayNameDetailed = buildDisplayNameDetailed({ + rawName: item.rawName, + isBundle: item.isBundle, + bundleItems: this.sanitizeBundleItems(item.bundleItems), + }); + const categoryId = this.categoryResolver.resolveForFlyer({ + categories, + signalText: [item.rawName, item.brand ?? '', item.offerText ?? ''].join(' ').trim(), + categoryHint: item.category, + matchedProductCategoryId: match.product?.categoryId ?? null, + matchConfidence: match.confidence, + }); + return { flyerItemId: null, rawName: item.rawName, - normalizedName: item.normalizedName, + normalizedName: signalData.normalizedMatchName || item.normalizedName, brand: item.brand, category: item.category, - categoryId: null, + categoryId, price, priceUnit, comparisonPrice, @@ -131,6 +164,8 @@ export class FlyerImportService { bundleWeight: item.bundleWeight, isBundle: item.isBundle, bundleItems: this.sanitizeBundleItems(item.bundleItems), + displayNameDetailed, + signals: signalData.signals, offerText: item.offerText, isOffer: this.isOfferItem(item, signals.hasCampaignPattern), offerLimitText, @@ -145,6 +180,8 @@ export class FlyerImportService { matchReasonsDetailed: this.describeMatchReasons(match.reasons), }; }); + + this.logImportMetrics(items); const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file); @@ -385,36 +422,40 @@ export class FlyerImportService { 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, - brand: item.brand, - categoryHint: item.category, - categoryId: item.categoryId, - 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, - weight: item.weight, - bundleWeight: item.bundleWeight, - isBundle: item.isBundle, - bundleItems: item.bundleItems, - 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 }, - }); + const savedItems: FlyerImportItem[] = []; + for (const item of items) { + const createData: Prisma.FlyerItemUncheckedCreateInput = { + sessionId: session.id, + rawName: item.rawName, + normalizedName: item.normalizedName, + brand: item.brand, + categoryHint: item.category, + categoryId: item.categoryId, + 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, + weight: item.weight, + bundleWeight: item.bundleWeight, + isBundle: item.isBundle, + bundleItems: item.bundleItems, + displayNameDetailed: item.displayNameDetailed, + signals: item.signals as Prisma.InputJsonValue, + offerText: item.offerText, + parseConfidence: item.parseConfidence, + parseReasons: item.parseReasons, + matchedProductId: item.matchedProductId, + matchedProductName: item.matchedProductName, + matchedVia: item.matchedVia, + matchConfidence: item.matchConfidence, + matchReasons: item.matchReasons, + }; + + const created = await this.prisma.flyerItem.create({ + data: createData, + select: { id: true }, + }); savedItems.push({ ...item, flyerItemId: created.id }); } @@ -431,21 +472,23 @@ export class FlyerImportService { return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; } - private matchItem( - item: FlyerParseItem, - products: ProductLite[], - aliasToProduct: Map, - productById: Map, - ): { + private matchItem( + item: FlyerParseItem, + normalizedMatchName: string, + itemSignals: ImportedItemSignals, + 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 normalized = normalizedMatchName || normalizeName(item.normalizedName || item.rawName); + if (!normalized) { + return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] }; + } const aliasedProductId = aliasToProduct.get(normalized); if (aliasedProductId) { @@ -471,17 +514,30 @@ export class FlyerImportService { } } - 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 }; - } - } + let best: { product: ProductLite; confidence: number; overlap: number } | null = null; + const itemTokens = this.tokenize(normalized); + for (const product of products) { + const productTokens = this.tokenize(product.canonicalName ?? product.name); + const overlap = this.tokenOverlap(itemTokens, productTokens); + if (overlap <= 0) continue; + + let confidence = Math.min(0.93, 0.48 + overlap * 0.42); + if (this.hasBrandSignal(item.brand, product)) { + confidence += 0.04; + } + if (this.hasWeightSignal(item.weight, product)) { + confidence += 0.03; + } + if (this.hasQualitySignal(itemSignals, product)) { + confidence += 0.03; + } + + confidence = Math.min(0.95, confidence); + + if (!best || confidence > best.confidence) { + best = { product, confidence, overlap }; + } + } if (best && best.confidence >= 0.66) { return { @@ -508,7 +564,7 @@ export class FlyerImportService { .filter((part) => part.length >= 3); } - private tokenOverlap(a: string[], b: string[]): number { + 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); @@ -518,8 +574,32 @@ export class FlyerImportService { } const union = new Set([...as, ...bs]).size; if (union === 0) return 0; - return intersection / union; - } + return intersection / union; + } + + private hasBrandSignal(brand: string | null, product: ProductLite): boolean { + if (!brand) return false; + const normalizedBrand = normalizeName(brand); + if (!normalizedBrand) return false; + + const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`); + return normalizedProduct.includes(normalizedBrand); + } + + private hasWeightSignal(weight: string | null, product: ProductLite): boolean { + if (!weight) return false; + const normalizedWeight = normalizeName(weight); + if (!normalizedWeight) return false; + + const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`); + return normalizedProduct.includes(normalizedWeight); + } + + private hasQualitySignal(signals: ImportedItemSignals, product: ProductLite): boolean { + if (!signals.qualityFlags.includes('eco')) return false; + const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`); + return /\beko\b|\bekolog/i.test(normalizedProduct); + } private isOfferItem(item: FlyerParseItem, hasCampaignPattern: boolean): boolean { return ( @@ -745,6 +825,8 @@ export class FlyerImportService { bundleWeight: string | null; isBundle: boolean; bundleItems: Prisma.JsonValue | null; + displayNameDetailed?: string | null; + signals?: Prisma.JsonValue | null; offerText: string | null; parseConfidence: number; parseReasons: Prisma.JsonValue | null; @@ -759,6 +841,24 @@ export class FlyerImportService { return value.map((entry) => String(entry)); }; + const toSignals = (value: Prisma.JsonValue | null | undefined): ImportedItemSignals | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const toArray = (key: string): string[] => { + const maybeArray = record[key]; + if (!Array.isArray(maybeArray)) return []; + return maybeArray.map((entry) => String(entry)); + }; + + return { + originCountries: toArray('originCountries'), + labels: toArray('labels'), + qualityFlags: toArray('qualityFlags'), + variant: typeof record.variant === 'string' ? record.variant : null, + packaging: typeof record.packaging === 'string' ? record.packaging : null, + }; + }; + const normalizedMatchVia = item.matchedVia === 'alias' || item.matchedVia === 'exact' || item.matchedVia === 'token' ? item.matchedVia @@ -784,6 +884,14 @@ export class FlyerImportService { bundleWeight: item.bundleWeight, isBundle: item.isBundle, bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)), + displayNameDetailed: + item.displayNameDetailed ?? + buildDisplayNameDetailed({ + rawName: item.rawName, + isBundle: item.isBundle, + bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)), + }), + signals: toSignals(item.signals), offerText: item.offerText, isOffer: item.price != null @@ -858,6 +966,8 @@ export class FlyerImportService { bundleWeight: string | null; isBundle: boolean; bundleItems: Prisma.JsonValue | null; + displayNameDetailed?: string | null; + signals?: Prisma.JsonValue | null; offerText: string | null; parseConfidence: number; parseReasons: Prisma.JsonValue | null; @@ -918,4 +1028,17 @@ export class FlyerImportService { .slice(0, this.MAX_BUNDLE_ITEMS) .map((entry) => entry.slice(0, this.MAX_BUNDLE_ITEM_LENGTH)); } + + private logImportMetrics(items: FlyerImportItem[]): void { + if (items.length === 0) return; + + const noMatchCount = items.filter((item) => item.matchReasons.includes('no_match')).length; + const categoryAssignedCount = items.filter((item) => item.categoryId != null).length; + const noMatchRatio = (noMatchCount / items.length) * 100; + const categoryAssignedRatio = (categoryAssignedCount / items.length) * 100; + + this.logger.log( + `Flyer import metrics: no_match=${noMatchCount}/${items.length} (${noMatchRatio.toFixed(1)}%), category_id=${categoryAssignedCount}/${items.length} (${categoryAssignedRatio.toFixed(1)}%)`, + ); + } } diff --git a/backend/src/import-common/category-resolver.service.spec.ts b/backend/src/import-common/category-resolver.service.spec.ts new file mode 100644 index 00000000..daca09a5 --- /dev/null +++ b/backend/src/import-common/category-resolver.service.spec.ts @@ -0,0 +1,36 @@ +import { CategoryResolverService } from './category-resolver.service'; + +describe('CategoryResolverService', () => { + const service = new CategoryResolverService(); + + const categories = [ + { id: 1, name: 'Kött, chark & fågel', path: 'Kött, chark & fågel' }, + { id: 2, name: 'Kött', path: 'Kött, chark & fågel > Kött' }, + { id: 3, name: 'Fläsk', path: 'Kött, chark & fågel > Kött > Fläsk' }, + { id: 4, name: 'Bröd', path: 'Bröd & kakor > Bröd' }, + ]; + + it('resolves Fläskytterfilé to pork category', () => { + const categoryId = service.resolveForFlyer({ + categories, + signalText: 'Fläskytterfilé Sverige', + categoryHint: null, + matchedProductCategoryId: null, + matchConfidence: 0, + }); + + expect(categoryId).toBe(3); + }); + + it('prefers matched product category when confidence is high', () => { + const categoryId = service.resolveForFlyer({ + categories, + signalText: 'Något annat', + categoryHint: 'Bröd', + matchedProductCategoryId: 99, + matchConfidence: 0.95, + }); + + expect(categoryId).toBe(99); + }); +}); diff --git a/backend/src/import-common/category-resolver.service.ts b/backend/src/import-common/category-resolver.service.ts new file mode 100644 index 00000000..e4667bfb --- /dev/null +++ b/backend/src/import-common/category-resolver.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@nestjs/common'; +import { FlatCategory } from '../categories/categories.service'; + +type ResolveFlyerCategoryParams = { + categories: FlatCategory[]; + signalText: string; + categoryHint: string | null; + matchedProductCategoryId: number | null; + matchConfidence: number; +}; + +@Injectable() +export class CategoryResolverService { + resolveForFlyer(params: ResolveFlyerCategoryParams): number | null { + if (params.matchedProductCategoryId != null && params.matchConfidence >= 0.9) { + return params.matchedProductCategoryId; + } + + const normalizedSignal = normalizeForRules(params.signalText); + + if (hasPorkLikeSignal(normalizedSignal)) { + const pork = this.resolvePorkCategory(params.categories); + if (pork) return pork.id; + } + + if (hasBreadLikeSignal(normalizedSignal)) { + const bread = this.resolveBreadCategory(params.categories); + if (bread) return bread.id; + } + + if (!params.categoryHint) return null; + return this.resolveByHint(params.categories, params.categoryHint)?.id ?? null; + } + + private resolveByHint(categories: FlatCategory[], categoryHint: string): FlatCategory | undefined { + const normalizedHint = normalizeForRules(categoryHint); + + return categories.find((category) => { + const normalizedName = normalizeForRules(category.name); + const normalizedPath = normalizeForRules(category.path); + return normalizedName === normalizedHint || normalizedPath === normalizedHint; + }); + } + + private resolvePorkCategory(categories: FlatCategory[]): FlatCategory | undefined { + return ( + categories.find( + (category) => + category.name.toLowerCase() === 'fläsk' && + category.path.toLowerCase().startsWith('kött, chark & fågel > kött > '), + ) || + categories.find( + (category) => + category.name.toLowerCase() === 'kött' && + category.path.toLowerCase() === 'kött, chark & fågel > kött', + ) || + categories.find((category) => category.path.toLowerCase() === 'kött, chark & fågel') + ); + } + + private resolveBreadCategory(categories: FlatCategory[]): FlatCategory | undefined { + return ( + categories.find( + (category) => + category.name.toLowerCase() === 'rostbröd' && + category.path.toLowerCase().startsWith('bröd & kakor > bröd > '), + ) || + categories.find( + (category) => + category.name.toLowerCase() === 'bröd' && + category.path.toLowerCase() === 'bröd & kakor > bröd', + ) || + categories.find((category) => category.path.toLowerCase() === 'bröd & kakor') + ); + } +} + +function normalizeForRules(value: string): string { + return value + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function hasPorkLikeSignal(normalized: string): boolean { + return ( + normalized.includes('bacon') || + normalized.includes('sidflask') || + normalized.includes('pancetta') || + normalized.includes('flask') || + normalized.includes('flaskytterfile') || + normalized.includes('ytterfile') || + normalized.includes('karre') || + normalized.includes('kotlett') + ); +} + +function hasBreadLikeSignal(normalized: string): boolean { + return ( + /\brostbrod\b/.test(normalized) || + /\brost\s*n\s*toast\b/.test(normalized) || + /\broast\s*n\s*toast\b/.test(normalized) || + /\btoastbrod\b/.test(normalized) || + /\bformbrod\b/.test(normalized) || + /\blantbrod\b/.test(normalized) || + /\bfullkornsbrod\b/.test(normalized) || + /\bfranska\b/.test(normalized) || + /\blimpa\b/.test(normalized) || + /\bbrod\b/.test(normalized) || + /\btoast\b/.test(normalized) + ); +} diff --git a/backend/src/import-common/import-display-name.util.ts b/backend/src/import-common/import-display-name.util.ts new file mode 100644 index 00000000..09147b12 --- /dev/null +++ b/backend/src/import-common/import-display-name.util.ts @@ -0,0 +1,15 @@ +export function buildDisplayNameDetailed(params: { + rawName: string; + isBundle: boolean; + bundleItems: string[]; +}): string { + const rawName = params.rawName.trim(); + if (!params.isBundle) return rawName; + + const items = params.bundleItems + .map((item) => item.trim()) + .filter((item) => item.length > 0); + + if (items.length === 0) return rawName; + return `${rawName} (${items.join(' + ')})`; +} diff --git a/backend/src/import-common/import-item.types.ts b/backend/src/import-common/import-item.types.ts new file mode 100644 index 00000000..d08e00b5 --- /dev/null +++ b/backend/src/import-common/import-item.types.ts @@ -0,0 +1,38 @@ +export type ImportedItemSignals = { + originCountries: string[]; + labels: string[]; + qualityFlags: string[]; + variant: string | null; + packaging: string | null; +}; + +export type ImportedItemCandidate = { + rawName: string; + normalizedName: string; + brand: string | null; + weight: string | null; + bundleWeight: string | null; + isBundle: boolean; + bundleItems: string[]; + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + categoryHint: string | null; + categoryId: number | null; + matchedProductId: number | null; + matchedProductName: string | null; + matchedVia: string; + matchConfidence: number; + matchReasons: string[]; + signals: ImportedItemSignals | null; + displayNameDetailed: string | null; +}; + +export const EMPTY_IMPORTED_SIGNALS: ImportedItemSignals = { + originCountries: [], + labels: [], + qualityFlags: [], + variant: null, + packaging: null, +}; diff --git a/backend/src/import-common/import-signals.util.spec.ts b/backend/src/import-common/import-signals.util.spec.ts new file mode 100644 index 00000000..16a1cee3 --- /dev/null +++ b/backend/src/import-common/import-signals.util.spec.ts @@ -0,0 +1,45 @@ +import { buildDisplayNameDetailed } from './import-display-name.util'; +import { extractImportSignals } from './import-signals.util'; + +describe('import signals utilities', () => { + it('extracts deterministic origin and eco labels', () => { + const result = extractImportSignals({ + rawName: 'Fläskytterfilé (Sverige) EKO', + brand: 'Garant', + offerText: 'Ekologiskt kött från Sverige', + }); + + expect(result.signals.originCountries).toEqual(['Sverige']); + expect(result.signals.labels).toContain('Ekologisk'); + expect(result.signals.qualityFlags).toContain('eco'); + expect(result.normalizedMatchName).toBe('flaskytterfile'); + }); + + it('extracts Germany and keeps labels deterministic', () => { + const result = extractImportSignals({ + rawName: 'Korv från Tyskland', + offerText: 'Tysk kvalitet', + }); + + expect(result.signals.originCountries).toEqual(['Tyskland']); + expect(result.signals.labels).toEqual([]); + }); + + it('builds detailed display name for bundle rows', () => { + expect( + buildDisplayNameDetailed({ + rawName: 'Kaptenens Favoriter', + isBundle: true, + bundleItems: ['Chumlax 3x100g', 'Alaska pollock 3x100g'], + }), + ).toBe('Kaptenens Favoriter (Chumlax 3x100g + Alaska pollock 3x100g)'); + }); + + it('extracts storpack packaging signal', () => { + const result = extractImportSignals({ + rawName: 'Kycklingfilé storpack', + }); + + expect(result.signals.packaging).toBe('storpack'); + }); +}); diff --git a/backend/src/import-common/import-signals.util.ts b/backend/src/import-common/import-signals.util.ts new file mode 100644 index 00000000..a6c0265d --- /dev/null +++ b/backend/src/import-common/import-signals.util.ts @@ -0,0 +1,103 @@ +import { normalizeName } from '../common/utils/normalize-name'; +import { + EMPTY_IMPORTED_SIGNALS, + ImportedItemSignals, +} from './import-item.types'; + +type SignalExtractionInput = { + rawName: string; + brand?: string | null; + offerText?: string | null; +}; + +const ORIGIN_COUNTRY_PATTERNS: Array<{ label: string; regex: RegExp }> = [ + { label: 'Sverige', regex: /\b(sverige|svensk(t|a)?|sweden)\b/i }, + { label: 'Tyskland', regex: /\b(tyskland|tysk(t|a)?|germany|deutschland)\b/i }, + { label: 'Norge', regex: /\b(norge|norsk(t|a)?)\b/i }, + { label: 'Danmark', regex: /\b(danmark|dansk(t|a)?)\b/i }, + { label: 'Finland', regex: /\b(finland|finsk(t|a)?)\b/i }, +]; + +const LABEL_PATTERNS: Array<{ label: string; qualityFlag: string | null; regex: RegExp }> = [ + { label: 'Ekologisk', qualityFlag: 'eco', regex: /\b(eko|ekologisk(t|a)?|organic)\b/i }, + { label: 'Laktosfri', qualityFlag: 'lactose_free', regex: /\b(laktosfri(tt|a)?|lactose\s*free)\b/i }, + { label: 'Glutenfri', qualityFlag: 'gluten_free', regex: /\b(glutenfri(tt|a)?|gluten\s*free)\b/i }, + { label: 'Vegansk', qualityFlag: 'vegan', regex: /\b(vegansk(t|a)?|vegan)\b/i }, + { label: 'Vegetarisk', qualityFlag: 'vegetarian', regex: /\b(vegetarisk(t|a)?|vegetarian)\b/i }, +]; + +export type SignalExtractionResult = { + signals: ImportedItemSignals; + normalizedMatchName: string; +}; + +export function extractImportSignals(input: SignalExtractionInput): SignalExtractionResult { + const text = [input.rawName, input.brand ?? '', input.offerText ?? ''] + .filter((part) => part.trim().length > 0) + .join(' '); + + const origins = ORIGIN_COUNTRY_PATTERNS + .filter((pattern) => pattern.regex.test(text)) + .map((pattern) => pattern.label); + + const labels = LABEL_PATTERNS + .filter((pattern) => pattern.regex.test(text)) + .map((pattern) => pattern.label); + + const qualityFlags = LABEL_PATTERNS + .filter((pattern) => pattern.qualityFlag && pattern.regex.test(text)) + .map((pattern) => pattern.qualityFlag as string); + + const packaging = resolvePackaging(text); + const variant = extractVariant(input.rawName); + + const signals: ImportedItemSignals = { + ...EMPTY_IMPORTED_SIGNALS, + originCountries: Array.from(new Set(origins)), + labels: Array.from(new Set(labels)), + qualityFlags: Array.from(new Set(qualityFlags)), + variant, + packaging, + }; + + const normalizedMatchName = normalizeForMatching(input.rawName); + + return { signals, normalizedMatchName }; +} + +export function normalizeForMatching(rawName: string): string { + let cleaned = rawName; + + for (const pattern of ORIGIN_COUNTRY_PATTERNS) { + cleaned = cleaned.replace(pattern.regex, ' '); + } + + for (const pattern of LABEL_PATTERNS) { + cleaned = cleaned.replace(pattern.regex, ' '); + } + + cleaned = cleaned.replace(/[()\[\]]/g, ' '); + cleaned = cleaned.replace(/\s+/g, ' ').trim(); + return normalizeName(cleaned) || normalizeName(rawName); +} + +function resolvePackaging(text: string): string | null { + const normalized = text.toLowerCase(); + if (/\b\d+\s*[x×]\s*\d+\s*(g|kg|ml|cl|dl|l)\b/.test(normalized)) { + return 'multipack'; + } + if (/\bstorpack\b/.test(normalized)) { + return 'storpack'; + } + if (/\b(2-pack|3-pack|4-pack|5-pack|6-pack|pack)\b/.test(normalized)) { + return 'pack'; + } + return null; +} + +function extractVariant(rawName: string): string | null { + const variantMatch = rawName.match(/\(([^)]+)\)/); + if (!variantMatch) return null; + const value = variantMatch[1].trim(); + return value.length > 0 ? value : null; +} diff --git a/flutter/lib/features/import/domain/flyer_import_item.dart b/flutter/lib/features/import/domain/flyer_import_item.dart index 79c45533..e9d4399f 100644 --- a/flutter/lib/features/import/domain/flyer_import_item.dart +++ b/flutter/lib/features/import/domain/flyer_import_item.dart @@ -1,15 +1,60 @@ import 'flyer_reason_descriptor.dart'; +class FlyerImportSignals { + final List originCountries; + final List labels; + final List qualityFlags; + final String? variant; + final String? packaging; + + const FlyerImportSignals({ + this.originCountries = const [], + this.labels = const [], + this.qualityFlags = const [], + this.variant, + this.packaging, + }); + + factory FlyerImportSignals.fromJson(Map json) { + return FlyerImportSignals( + originCountries: + (json['originCountries'] as List?)?.map((e) => e.toString()).toList() ?? const [], + labels: (json['labels'] as List?)?.map((e) => e.toString()).toList() ?? const [], + qualityFlags: + (json['qualityFlags'] as List?)?.map((e) => e.toString()).toList() ?? const [], + variant: json['variant'] as String?, + packaging: json['packaging'] as String?, + ); + } + + Map toJson() { + return { + 'originCountries': originCountries, + 'labels': labels, + 'qualityFlags': qualityFlags, + 'variant': variant, + 'packaging': packaging, + }; + } +} + class FlyerImportItem { final int? flyerItemId; - final String rawName; - final String normalizedName; + final String rawName; + final String? displayNameDetailed; + final String normalizedName; + final String? brand; final String? category; final int? categoryId; - final double? price; - final String? priceUnit; - final String? offerText; - final bool isOffer; + final double? price; + final String? priceUnit; + final String? weight; + final String? bundleWeight; + final bool isBundle; + final List bundleItems; + final FlyerImportSignals? signals; + final String? offerText; + final bool isOffer; final String? offerLimitText; final double? comparisonPrice; final String? comparisonUnit; @@ -24,14 +69,21 @@ class FlyerImportItem { final List matchReasonsDetailed; FlyerImportItem({ - required this.flyerItemId, - required this.rawName, - required this.normalizedName, + required this.flyerItemId, + required this.rawName, + this.displayNameDetailed, + required this.normalizedName, + this.brand, this.category, this.categoryId, - this.price, - this.priceUnit, - this.offerText, + this.price, + this.priceUnit, + this.weight, + this.bundleWeight, + this.isBundle = false, + this.bundleItems = const [], + this.signals, + this.offerText, this.isOffer = false, this.offerLimitText, this.comparisonPrice, @@ -49,14 +101,23 @@ class FlyerImportItem { factory FlyerImportItem.fromJson(Map json) { return FlyerImportItem( - flyerItemId: (json['flyerItemId'] as num?)?.toInt(), - rawName: json['rawName'] as String? ?? '', - normalizedName: json['normalizedName'] as String? ?? '', + flyerItemId: (json['flyerItemId'] as num?)?.toInt(), + rawName: json['rawName'] as String? ?? '', + displayNameDetailed: json['displayNameDetailed'] as String?, + normalizedName: json['normalizedName'] as String? ?? '', + brand: json['brand'] as String?, category: json['category'] as String?, categoryId: (json['categoryId'] as num?)?.toInt(), - price: (json['price'] as num?)?.toDouble(), - priceUnit: json['priceUnit'] as String?, - offerText: json['offerText'] as String?, + price: (json['price'] as num?)?.toDouble(), + priceUnit: json['priceUnit'] as String?, + weight: json['weight'] as String?, + bundleWeight: json['bundleWeight'] as String?, + isBundle: json['isBundle'] == true, + bundleItems: (json['bundleItems'] as List?)?.map((e) => e.toString()).toList() ?? const [], + signals: json['signals'] is Map + ? FlyerImportSignals.fromJson(Map.from(json['signals'] as Map)) + : null, + offerText: json['offerText'] as String?, isOffer: json['isOffer'] == true, offerLimitText: json['offerLimitText'] as String?, comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(), @@ -87,11 +148,18 @@ class FlyerImportItem { return { 'flyerItemId': flyerItemId, 'rawName': rawName, + 'displayNameDetailed': displayNameDetailed, 'normalizedName': normalizedName, + 'brand': brand, 'category': category, 'categoryId': categoryId, 'price': price, 'priceUnit': priceUnit, + 'weight': weight, + 'bundleWeight': bundleWeight, + 'isBundle': isBundle, + 'bundleItems': bundleItems, + 'signals': signals?.toJson(), 'offerText': offerText, 'isOffer': isOffer, 'offerLimitText': offerLimitText, @@ -119,11 +187,18 @@ class FlyerImportItem { return FlyerImportItem( flyerItemId: flyerItemId, rawName: rawName ?? this.rawName, + displayNameDetailed: displayNameDetailed, normalizedName: normalizedName, + brand: brand, category: category ?? this.category, categoryId: categoryId ?? this.categoryId, price: price, priceUnit: priceUnit, + weight: weight, + bundleWeight: bundleWeight, + isBundle: isBundle, + bundleItems: bundleItems, + signals: signals, offerText: offerText, isOffer: isOffer, offerLimitText: offerLimitText, diff --git a/flutter/lib/features/import/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart index 20e6efc6..1a7abdf1 100644 --- a/flutter/lib/features/import/presentation/flyer_import_tab.dart +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -501,6 +501,40 @@ class _FlyerImportTabState extends ConsumerState { ); } + Widget _buildMetadataBadges(FlyerImportItem item, ThemeData theme) { + final values = [ + ...(item.signals?.originCountries ?? const []), + ...(item.signals?.labels ?? const []), + ]; + if (values.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 6, + runSpacing: 6, + children: values + .map( + (value) => Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: theme.colorScheme.primary.withValues(alpha: 0.25), + ), + ), + child: Text( + value, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + ) + .toList(), + ); + } + Future _copyText(String value, String label) async { await Clipboard.setData(ClipboardData(text: value)); if (!mounted) return; @@ -744,7 +778,7 @@ class _FlyerImportTabState extends ConsumerState { }, title: Row( children: [ - Expanded(child: Text(item.rawName)), + Expanded(child: Text(item.displayNameDetailed ?? item.rawName)), IconButton( tooltip: 'Redigera', visualDensity: VisualDensity.compact, @@ -765,6 +799,10 @@ class _FlyerImportTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (priceText.isNotEmpty) Text('Pris: $priceText'), + if (item.isBundle && item.bundleItems.isNotEmpty) + Text('Paketinnehåll: ${item.bundleItems.join(' + ')}'), + if (item.brand != null && item.brand!.trim().isNotEmpty) + Text('Varumärke: ${item.brand}'), if ((item.category ?? '').trim().isNotEmpty) Text('Kategori: ${item.category}'), if (comparisonText.isNotEmpty) @@ -778,6 +816,11 @@ class _FlyerImportTabState extends ConsumerState { ), ), if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText), + if ((item.signals?.originCountries.isNotEmpty ?? false) || + (item.signals?.labels.isNotEmpty ?? false)) ...[ + const SizedBox(height: 6), + _buildMetadataBadges(item, theme), + ], if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'), if (detailedReasons.isNotEmpty) ...[ diff --git a/flutter/test/features/import/domain/flyer_import_item_test.dart b/flutter/test/features/import/domain/flyer_import_item_test.dart new file mode 100644 index 00000000..ce4791f0 --- /dev/null +++ b/flutter/test/features/import/domain/flyer_import_item_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:recipe_flutter/features/import/domain/flyer_import_item.dart'; + +void main() { + test('parses signals and detailed bundle name', () { + final item = FlyerImportItem.fromJson({ + 'flyerItemId': 7, + 'rawName': 'Kaptenens Favoriter', + 'displayNameDetailed': + 'Kaptenens Favoriter (Chumlax 3x100g + Alaska pollock 3x100g)', + 'normalizedName': 'kaptenens favoriter', + 'isBundle': true, + 'bundleItems': ['Chumlax 3x100g', 'Alaska pollock 3x100g'], + 'signals': { + 'originCountries': ['Sverige'], + 'labels': ['Ekologisk'], + 'qualityFlags': ['eco'], + 'variant': null, + 'packaging': 'multipack', + }, + }); + + expect(item.isBundle, isTrue); + expect(item.displayNameDetailed, contains('Chumlax 3x100g')); + expect(item.bundleItems, hasLength(2)); + expect(item.signals?.originCountries, ['Sverige']); + expect(item.toJson()['signals'], isA>()); + }); +}