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
This commit is contained in:
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
@@ -4,12 +4,17 @@ 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;
|
||||
weight: string | null;
|
||||
bundleWeight: string | null;
|
||||
isBundle: boolean;
|
||||
bundleItems: string[];
|
||||
offerText: string | null;
|
||||
isOffer: boolean;
|
||||
offerLimitText: string | null;
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -21,11 +21,16 @@ import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
||||
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[];
|
||||
@@ -55,6 +60,8 @@ type ProductLite = {
|
||||
@Injectable()
|
||||
export class FlyerImportService {
|
||||
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,
|
||||
@@ -350,15 +362,20 @@ export class FlyerImportService {
|
||||
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,
|
||||
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,
|
||||
@@ -595,11 +612,16 @@ export class FlyerImportService {
|
||||
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,
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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()
|
||||
|
||||
Reference in New Issue
Block a user