feat(flyer-import): add bundle support and new product fields
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Successful in 3m43s
Test Suite / flutter-quality (push) Failing after 1m51s

- 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:
Nils-Johan Gynther
2026-05-21 13:26:50 +02:00
parent 7bbb5a63b5
commit 67c3170067
7 changed files with 239 additions and 72 deletions
@@ -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);