From 67c3170067308e0e16eb8590ad0ed70749322481 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Thu, 21 May 2026 13:26:50 +0200 Subject: [PATCH] feat(flyer-import): add bundle support and new product fields - Add bundle support with isBundle, bundleWeight, and bundleItems fields - Add brand, weight, and comparisonUnit fields to FlyerItem model - Update AI flyer parser to extract bundle information - Add sanitization for bundle items in FlyerNormalizerService - Update DTOs and interfaces to include new fields - Add migration for new database fields - Update tests to cover bundle item handling --- .../migration.sql | 7 + backend/prisma/schema.prisma | 5 + .../flyer-import/dto/flyer-import.response.ts | 15 ++- .../flyer-import/flyer-import.service.spec.ts | 47 +++++++ .../src/flyer-import/flyer-import.service.ts | 122 ++++++++++++------ .../services/ai-flyer-parser.service.ts | 94 +++++++++----- .../services/flyer-normalizer.service.ts | 21 +++ 7 files changed, 239 insertions(+), 72 deletions(-) create mode 100644 backend/prisma/migrations/20260521131500_add_bundle_fields_to_flyer_item/migration.sql diff --git a/backend/prisma/migrations/20260521131500_add_bundle_fields_to_flyer_item/migration.sql b/backend/prisma/migrations/20260521131500_add_bundle_fields_to_flyer_item/migration.sql new file mode 100644 index 00000000..6d8d0712 --- /dev/null +++ b/backend/prisma/migrations/20260521131500_add_bundle_fields_to_flyer_item/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE `FlyerItem` + ADD COLUMN `brand` VARCHAR(191) NULL, + ADD COLUMN `weight` VARCHAR(191) NULL, + ADD COLUMN `bundleWeight` VARCHAR(191) NULL, + ADD COLUMN `isBundle` BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN `bundleItems` JSON NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d2f59a08..2d98eb2b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -313,12 +313,17 @@ model FlyerItem { sessionId Int rawName String normalizedName String + brand String? categoryHint String? categoryId Int? price Decimal? @db.Decimal(10, 2) priceUnit String? comparisonPrice Decimal? @db.Decimal(10, 2) comparisonUnit String? + weight String? + bundleWeight String? + isBundle Boolean @default(false) + bundleItems Json? 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 cfabfad6..93bd995b 100644 --- a/backend/src/flyer-import/dto/flyer-import.response.ts +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -4,13 +4,18 @@ export type FlyerImportItem = { flyerItemId: number | null; rawName: string; normalizedName: string; + brand: string | null; category: string | null; categoryId: number | null; - price: number | null; - priceUnit: string | null; - comparisonPrice: number | null; - comparisonUnit: string | null; - offerText: string | null; + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + weight: string | null; + bundleWeight: string | null; + isBundle: boolean; + bundleItems: string[]; + offerText: string | null; isOffer: boolean; offerLimitText: string | null; parseConfidence: number; diff --git a/backend/src/flyer-import/flyer-import.service.spec.ts b/backend/src/flyer-import/flyer-import.service.spec.ts index 53298ca7..e5b7db00 100644 --- a/backend/src/flyer-import/flyer-import.service.spec.ts +++ b/backend/src/flyer-import/flyer-import.service.spec.ts @@ -68,11 +68,16 @@ describe('FlyerImportService', () => { id: 99, rawName: 'Tomat', normalizedName: 'tomat', + brand: null, categoryHint: 'Gronsaker', price: { toNumber: () => 19.9 }, priceUnit: 'kg', comparisonPrice: null, comparisonUnit: null, + weight: null, + bundleWeight: null, + isBundle: false, + bundleItems: [], offerText: 'Max 2 kop/hushall', parseConfidence: 0.9, parseReasons: ['ai_parsed'], @@ -94,6 +99,43 @@ describe('FlyerImportService', () => { expect(result.items[0].matchedVia).toBe('exact'); expect(result.sourceAvailable).toBe(false); }); + + it('sanitizes bundleItems without breaking response mapping', async () => { + prismaMock.flyerSession.findFirst.mockResolvedValue({ + id: 51, + items: [ + { + id: 100, + rawName: 'Kaptenens Favoriter', + normalizedName: 'kaptenens favoriter', + brand: 'Kapten Royal', + categoryHint: 'Fisk', + price: { toNumber: () => 49.9 }, + priceUnit: 'pkt', + comparisonPrice: { toNumber: () => 83.17 }, + comparisonUnit: 'kg', + weight: null, + bundleWeight: '600g', + isBundle: true, + bundleItems: [' Chumlax 3x100g ', '', 'Alaska pollock 3x100g'], + offerText: 'Max 10 kop/hushall', + parseConfidence: 0.9, + parseReasons: ['ai_parsed'], + matchedProductId: null, + matchedProductName: null, + matchedVia: 'none', + matchConfidence: null, + matchReasons: [], + }, + ], + }); + + const service = createService(); + const result = await service.getSession(51, 1); + + expect(result.items[0].isBundle).toBe(true); + expect(result.items[0].bundleItems).toEqual(['Chumlax 3x100g', 'Alaska pollock 3x100g']); + }); }); describe('getLatestSession', () => { @@ -150,12 +192,17 @@ describe('FlyerImportService', () => { id: 12, rawName: 'Cocktailtomater', normalizedName: 'cocktailtomater', + brand: null, categoryHint: 'Mat > Grönsaker > Tomater', categoryId: 3, price: null, priceUnit: null, comparisonPrice: null, comparisonUnit: null, + weight: null, + bundleWeight: null, + isBundle: false, + bundleItems: [], offerText: null, parseConfidence: 1, parseReasons: [], diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index 54b6f723..ef97cccc 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -18,18 +18,23 @@ import { TextExtractorService } from './services/text-extractor.service'; import { AiFlyerParserService } from './services/ai-flyer-parser.service'; import { FlyerNormalizerService } from './services/flyer-normalizer.service'; -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 FlyerParseItem = { + rawName: string; + normalizedName: string; + brand: string | null; + category: string | null; + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + weight: string | null; + bundleWeight: string | null; + isBundle: boolean; + bundleItems: string[]; + offerText: string | null; + confidence: number; + reasonCodes: string[]; +}; type FlyerParseResponse = { retailer: 'willys'; @@ -54,7 +59,9 @@ type ProductLite = { @Injectable() export class FlyerImportService { - private readonly logger = new Logger(FlyerImportService.name); + private readonly logger = new Logger(FlyerImportService.name); + private readonly MAX_BUNDLE_ITEMS = 20; + private readonly MAX_BUNDLE_ITEM_LENGTH = 120; constructor( private readonly prisma: PrismaService, @@ -105,12 +112,17 @@ export class FlyerImportService { flyerItemId: null, rawName: item.rawName, normalizedName: item.normalizedName, + brand: item.brand, category: item.category, categoryId: null, price, priceUnit, comparisonPrice, comparisonUnit, + weight: item.weight, + bundleWeight: item.bundleWeight, + isBundle: item.isBundle, + bundleItems: this.sanitizeBundleItems(item.bundleItems), offerText: item.offerText, isOffer: this.isOfferItem(item, signals.hasCampaignPattern), offerLimitText, @@ -348,19 +360,24 @@ export class FlyerImportService { 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, - 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, - offerText: item.offerText, - parseConfidence: item.parseConfidence, + 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, @@ -592,18 +609,23 @@ export class FlyerImportService { const normalizedItems = this.normalizer.normalize(aiItems); // 4. Konvertera till intern FlyerParseItem-format - const items: FlyerParseItem[] = normalizedItems.map((item) => ({ - rawName: item.rawName, - normalizedName: item.normalizedName, - category: item.categoryHint, - price: item.price, - priceUnit: item.priceUnit, - comparisonPrice: item.comparisonPrice, - comparisonUnit: item.comparisonUnit, - offerText: item.offerText, - confidence: item.parseConfidence, - reasonCodes: item.parseReasons, - })); + const items: FlyerParseItem[] = normalizedItems.map((item) => ({ + rawName: item.rawName, + normalizedName: item.normalizedName, + brand: item.brand, + category: item.categoryHint, + price: item.price, + priceUnit: item.priceUnit, + comparisonPrice: item.comparisonPrice, + comparisonUnit: item.comparisonUnit, + weight: item.weight, + bundleWeight: item.bundleWeight, + isBundle: item.isBundle, + bundleItems: item.bundleItems, + offerText: item.offerText, + confidence: item.parseConfidence, + reasonCodes: item.parseReasons, + })); const warnings: string[] = []; if (items.length === 0) { @@ -634,6 +656,7 @@ export class FlyerImportService { id: number; rawName: string; normalizedName: string; + brand: string | null; categoryHint: string | null; categoryId: number | null; categoryRef?: { @@ -649,6 +672,10 @@ export class FlyerImportService { priceUnit: string | null; comparisonPrice: Prisma.Decimal | null; comparisonUnit: string | null; + weight: string | null; + bundleWeight: string | null; + isBundle: boolean; + bundleItems: Prisma.JsonValue | null; offerText: string | null; parseConfidence: number; parseReasons: Prisma.JsonValue | null; @@ -677,12 +704,17 @@ export class FlyerImportService { flyerItemId: item.id, rawName: item.rawName, normalizedName: item.normalizedName, + brand: item.brand, category: categoryPath, categoryId: item.categoryId, price: item.price != null ? item.price.toNumber() : offerSignals.price, priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit, comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice, comparisonUnit: this.normalizeUnit(item.comparisonUnit) ?? offerSignals.comparisonUnit, + weight: item.weight, + bundleWeight: item.bundleWeight, + isBundle: item.isBundle, + bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)), offerText: item.offerText, isOffer: item.price != null @@ -727,6 +759,7 @@ export class FlyerImportService { id: number; rawName: string; normalizedName: string; + brand: string | null; categoryHint: string | null; categoryId: number | null; categoryRef?: { @@ -742,6 +775,10 @@ export class FlyerImportService { priceUnit: string | null; comparisonPrice: Prisma.Decimal | null; comparisonUnit: string | null; + weight: string | null; + bundleWeight: string | null; + isBundle: boolean; + bundleItems: Prisma.JsonValue | null; offerText: string | null; parseConfidence: number; parseReasons: Prisma.JsonValue | null; @@ -793,4 +830,13 @@ export class FlyerImportService { private buildSourceStorageKey(userId: number, weekKey: string): string { return `flyer/${userId}/${weekKey}/${Date.now()}`; } + + private sanitizeBundleItems(items: string[] | null | undefined): string[] { + if (!Array.isArray(items)) return []; + return items + .map((entry) => String(entry).trim()) + .filter(Boolean) + .slice(0, this.MAX_BUNDLE_ITEMS) + .map((entry) => entry.slice(0, this.MAX_BUNDLE_ITEM_LENGTH)); + } } diff --git a/backend/src/flyer-import/services/ai-flyer-parser.service.ts b/backend/src/flyer-import/services/ai-flyer-parser.service.ts index e0a2b8e3..2a4d6b7b 100644 --- a/backend/src/flyer-import/services/ai-flyer-parser.service.ts +++ b/backend/src/flyer-import/services/ai-flyer-parser.service.ts @@ -10,11 +10,16 @@ import * as path from 'path'; export interface AiFlyerParseResult { rawName: string; normalizedName: string; + brand: string | null; category: string | null; price: number | null; priceUnit: string | null; comparisonPrice: number | null; comparisonUnit: string | null; + weight: string | null; + bundleWeight: string | null; + isBundle: boolean; + bundleItems: string[]; offerText: string | null; confidence: number; reasonCodes: string[]; @@ -162,41 +167,64 @@ export class AiFlyerParserService { private buildPrompt(text: string, maxTextLength: number): string { const truncatedText = text.length > maxTextLength ? text.substring(0, maxTextLength) : text; - return `Du är en expert på att tolka svenska matvaruflyers (t.ex. från Willys, Coop, ICA). + return `Du tolkar svenska matvaruflyers och ska returnera ENDAST en JSON-array. -Extrahera ALL produktinformation från följande text och returnera den som en JSON-array. +Returnera objekt med exakt dessa fält: +- name: string (produkttitel) +- brand: string | null +- category: string | null +- isBundle: boolean +- weight: string | null (vikt/storlek for en enskild produkt) +- bundleWeight: string | null (totalvikt for hela kombipaketet) +- bundleItems: string[] (ingående produkter i paketet, tom array om ej bundle) +- price: number | null +- comparisonPrice: number | null +- unit: string | null (enhet for jamforpris, t.ex. kg/l/st) +- offer: string[] -För varje produkt, inkludera: -- name: Produktnamn (fullständigt namn) -- weight: Vikt (om tillgänglig, t.ex. "150g", "Ca 1kg") eller null -- origin: Ursprung/land/märke (om tillgänglig, t.ex. "Grönland") eller null -- price: Pris som nummer (t.ex. 39.90) eller null -- comparisonPrice: Jämförpris som nummer (t.ex. 266.00) eller null -- unit: Enhet (kg, st, förp, l, etc.) eller null -- offer: Erbjudande som array (t.ex. ["Max 3 köp/hushåll"]) eller [] -- category: Kategori (t.ex. "Fisk", "Kött", "Mejeri", "Grönsaker", "Frukt", "Dryck") eller null -- validFrom: Giltig från (datum i formatet YYYY-MM-DD) eller null -- validTo: Giltig till (datum i formatet YYYY-MM-DD) eller null +Regler: +1) Vanlig produkt (ej bundle): isBundle=false, bundleWeight=null, bundleItems=[]. +2) Kombipaket/bundle: isBundle=true, name ska vara paketets huvudnamn, bundleWeight totalvikt. +3) For bundle ska bundleItems innehalla de ingaende produkterna, t.ex. ["Chumlax 3x100g", "Alaska pollock 3x100g"]. +4) price ar priset for hela forpackningen. comparisonPrice ar jamforpris som tal ("83:17" -> 83.17). +5) offer innehaller kampanjtext som "Max 10 kop/hushall". -Texten att tolka: -${truncatedText} - -Returnera ENDAST en JSON-array. Inga andra kommentarer, ingen markdown-markup. -Exempel på utdata: +Exempel bundle utdata: [ { - "name": "KALLRÖKT LAX, GRAVAD LAX", - "weight": "150g", - "origin": "Grönland", - "price": 39.90, - "comparisonPrice": 266.00, - "unit": "kg", - "offer": ["Max 3 köp/hushåll"], + "name": "Kaptenens Favoriter", + "brand": "Kapten Royal", "category": "Fisk", - "validFrom": "2026-05-18", - "validTo": "2026-05-24" + "isBundle": true, + "weight": null, + "bundleWeight": "600g", + "bundleItems": ["Chumlax 3x100g", "Alaska pollock 3x100g"], + "price": 49.90, + "comparisonPrice": 83.17, + "unit": "kg", + "offer": ["Max 10 kop/hushall"] } -]`; +] + +Exempel enkel produkt utdata: +[ + { + "name": "ICA Basic Mjolk 1,5%", + "brand": "ICA Basic", + "category": "Mejeri", + "isBundle": false, + "weight": "1l", + "bundleWeight": null, + "bundleItems": [], + "price": 12.90, + "comparisonPrice": 12.90, + "unit": "l", + "offer": [] + } +] + +Text att tolka: +${truncatedText}`; } /** @@ -245,11 +273,16 @@ Exempel på utdata: return { rawName, normalizedName, + brand: toString(item.brand), category: toString(item.category), price: toNumber(item.price), priceUnit: toString(item.unit), comparisonPrice: toNumber(item.comparisonPrice), comparisonUnit: toString(item.comparisonUnit), + weight: toString(item.weight), + bundleWeight: toString(item.bundleWeight), + isBundle: Boolean(item.isBundle), + bundleItems: toArray(item.bundleItems), offerText: toString(item.offer) || (toArray(item.offer).join(' ') || null), confidence: 0.85, reasonCodes: ['ai_parsed'], @@ -345,7 +378,7 @@ Exempel på utdata: throw new BadRequestException('AI returnerade inte en JSON-array.'); } - return items.map((item, idx) => this.normalizeAiItem(item, idx)); + return items.map((aiItem, idx) => this.normalizeAiItem(aiItem, idx)); } catch (attemptErr) { lastError = attemptErr; if (debugSession) { @@ -379,6 +412,9 @@ Exempel på utdata: item.price ?? '', item.priceUnit ?? '', item.offerText ?? '', + item.isBundle ? '1' : '0', + item.bundleWeight ?? '', + JSON.stringify(item.bundleItems ?? []), ].join('|'); if (seen.has(key)) continue; seen.add(key); diff --git a/backend/src/flyer-import/services/flyer-normalizer.service.ts b/backend/src/flyer-import/services/flyer-normalizer.service.ts index f66e22dd..8c03b92a 100644 --- a/backend/src/flyer-import/services/flyer-normalizer.service.ts +++ b/backend/src/flyer-import/services/flyer-normalizer.service.ts @@ -3,11 +3,16 @@ import { Injectable, Logger } from '@nestjs/common'; export interface NormalizedFlyerItem { rawName: string; normalizedName: string; + brand: string | null; categoryHint: string | null; price: number | null; priceUnit: string | null; comparisonPrice: number | null; comparisonUnit: string | null; + weight: string | null; + bundleWeight: string | null; + isBundle: boolean; + bundleItems: string[]; offerText: string | null; parseConfidence: number; parseReasons: string[]; @@ -16,6 +21,8 @@ export interface NormalizedFlyerItem { @Injectable() export class FlyerNormalizerService { private readonly logger = new Logger(FlyerNormalizerService.name); + private readonly MAX_BUNDLE_ITEMS = 20; + private readonly MAX_BUNDLE_ITEM_LENGTH = 120; private readonly UNIT_MAPPING: Record = { // Längd @@ -75,11 +82,16 @@ export class FlyerNormalizerService { return { rawName, normalizedName, + brand: this.extractString(item.brand), categoryHint: this.normalizeCategory(this.extractString(item.category)), price: this.extractPrice(item.price), priceUnit: this.normalizeUnit(this.extractString(item.unit)), comparisonPrice: this.extractPrice(item.comparisonPrice), comparisonUnit: this.normalizeUnit(this.extractString(item.comparisonUnit)), + weight: this.extractString(item.weight), + bundleWeight: this.extractString(item.bundleWeight), + isBundle: Boolean(item.isBundle), + bundleItems: this.extractStringArray(item.bundleItems), offerText: this.normalizeOfferText(item.offer), parseConfidence: item.confidence ?? 0.85, parseReasons: Array.isArray(item.reasonCodes) @@ -102,6 +114,15 @@ export class FlyerNormalizerService { return null; } + private extractStringArray(val: any): string[] { + if (!Array.isArray(val)) return []; + return val + .map((entry) => String(entry).trim()) + .filter(Boolean) + .slice(0, this.MAX_BUNDLE_ITEMS) + .map((entry) => entry.slice(0, this.MAX_BUNDLE_ITEM_LENGTH)); + } + private normalizeName(name: string): string { return name .toLowerCase()