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
|
sessionId Int
|
||||||
rawName String
|
rawName String
|
||||||
normalizedName String
|
normalizedName String
|
||||||
|
brand String?
|
||||||
categoryHint String?
|
categoryHint String?
|
||||||
categoryId Int?
|
categoryId Int?
|
||||||
price Decimal? @db.Decimal(10, 2)
|
price Decimal? @db.Decimal(10, 2)
|
||||||
priceUnit String?
|
priceUnit String?
|
||||||
comparisonPrice Decimal? @db.Decimal(10, 2)
|
comparisonPrice Decimal? @db.Decimal(10, 2)
|
||||||
comparisonUnit String?
|
comparisonUnit String?
|
||||||
|
weight String?
|
||||||
|
bundleWeight String?
|
||||||
|
isBundle Boolean @default(false)
|
||||||
|
bundleItems Json?
|
||||||
offerText String?
|
offerText String?
|
||||||
parseConfidence Float
|
parseConfidence Float
|
||||||
parseReasons Json?
|
parseReasons Json?
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ export type FlyerImportItem = {
|
|||||||
flyerItemId: number | null;
|
flyerItemId: number | null;
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
|
brand: string | null;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
comparisonPrice: number | null;
|
comparisonPrice: number | null;
|
||||||
comparisonUnit: string | null;
|
comparisonUnit: string | null;
|
||||||
offerText: string | null;
|
weight: string | null;
|
||||||
|
bundleWeight: string | null;
|
||||||
|
isBundle: boolean;
|
||||||
|
bundleItems: string[];
|
||||||
|
offerText: string | null;
|
||||||
isOffer: boolean;
|
isOffer: boolean;
|
||||||
offerLimitText: string | null;
|
offerLimitText: string | null;
|
||||||
parseConfidence: number;
|
parseConfidence: number;
|
||||||
|
|||||||
@@ -68,11 +68,16 @@ describe('FlyerImportService', () => {
|
|||||||
id: 99,
|
id: 99,
|
||||||
rawName: 'Tomat',
|
rawName: 'Tomat',
|
||||||
normalizedName: 'tomat',
|
normalizedName: 'tomat',
|
||||||
|
brand: null,
|
||||||
categoryHint: 'Gronsaker',
|
categoryHint: 'Gronsaker',
|
||||||
price: { toNumber: () => 19.9 },
|
price: { toNumber: () => 19.9 },
|
||||||
priceUnit: 'kg',
|
priceUnit: 'kg',
|
||||||
comparisonPrice: null,
|
comparisonPrice: null,
|
||||||
comparisonUnit: null,
|
comparisonUnit: null,
|
||||||
|
weight: null,
|
||||||
|
bundleWeight: null,
|
||||||
|
isBundle: false,
|
||||||
|
bundleItems: [],
|
||||||
offerText: 'Max 2 kop/hushall',
|
offerText: 'Max 2 kop/hushall',
|
||||||
parseConfidence: 0.9,
|
parseConfidence: 0.9,
|
||||||
parseReasons: ['ai_parsed'],
|
parseReasons: ['ai_parsed'],
|
||||||
@@ -94,6 +99,43 @@ describe('FlyerImportService', () => {
|
|||||||
expect(result.items[0].matchedVia).toBe('exact');
|
expect(result.items[0].matchedVia).toBe('exact');
|
||||||
expect(result.sourceAvailable).toBe(false);
|
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', () => {
|
describe('getLatestSession', () => {
|
||||||
@@ -150,12 +192,17 @@ describe('FlyerImportService', () => {
|
|||||||
id: 12,
|
id: 12,
|
||||||
rawName: 'Cocktailtomater',
|
rawName: 'Cocktailtomater',
|
||||||
normalizedName: 'cocktailtomater',
|
normalizedName: 'cocktailtomater',
|
||||||
|
brand: null,
|
||||||
categoryHint: 'Mat > Grönsaker > Tomater',
|
categoryHint: 'Mat > Grönsaker > Tomater',
|
||||||
categoryId: 3,
|
categoryId: 3,
|
||||||
price: null,
|
price: null,
|
||||||
priceUnit: null,
|
priceUnit: null,
|
||||||
comparisonPrice: null,
|
comparisonPrice: null,
|
||||||
comparisonUnit: null,
|
comparisonUnit: null,
|
||||||
|
weight: null,
|
||||||
|
bundleWeight: null,
|
||||||
|
isBundle: false,
|
||||||
|
bundleItems: [],
|
||||||
offerText: null,
|
offerText: null,
|
||||||
parseConfidence: 1,
|
parseConfidence: 1,
|
||||||
parseReasons: [],
|
parseReasons: [],
|
||||||
|
|||||||
@@ -18,18 +18,23 @@ import { TextExtractorService } from './services/text-extractor.service';
|
|||||||
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
|
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
|
||||||
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
||||||
|
|
||||||
type FlyerParseItem = {
|
type FlyerParseItem = {
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
category: string | null;
|
brand: string | null;
|
||||||
price: number | null;
|
category: string | null;
|
||||||
priceUnit: string | null;
|
price: number | null;
|
||||||
comparisonPrice: number | null;
|
priceUnit: string | null;
|
||||||
comparisonUnit: string | null;
|
comparisonPrice: number | null;
|
||||||
offerText: string | null;
|
comparisonUnit: string | null;
|
||||||
confidence: number;
|
weight: string | null;
|
||||||
reasonCodes: string[];
|
bundleWeight: string | null;
|
||||||
};
|
isBundle: boolean;
|
||||||
|
bundleItems: string[];
|
||||||
|
offerText: string | null;
|
||||||
|
confidence: number;
|
||||||
|
reasonCodes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type FlyerParseResponse = {
|
type FlyerParseResponse = {
|
||||||
retailer: 'willys';
|
retailer: 'willys';
|
||||||
@@ -54,7 +59,9 @@ type ProductLite = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FlyerImportService {
|
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(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -105,12 +112,17 @@ export class FlyerImportService {
|
|||||||
flyerItemId: null,
|
flyerItemId: null,
|
||||||
rawName: item.rawName,
|
rawName: item.rawName,
|
||||||
normalizedName: item.normalizedName,
|
normalizedName: item.normalizedName,
|
||||||
|
brand: item.brand,
|
||||||
category: item.category,
|
category: item.category,
|
||||||
categoryId: null,
|
categoryId: null,
|
||||||
price,
|
price,
|
||||||
priceUnit,
|
priceUnit,
|
||||||
comparisonPrice,
|
comparisonPrice,
|
||||||
comparisonUnit,
|
comparisonUnit,
|
||||||
|
weight: item.weight,
|
||||||
|
bundleWeight: item.bundleWeight,
|
||||||
|
isBundle: item.isBundle,
|
||||||
|
bundleItems: this.sanitizeBundleItems(item.bundleItems),
|
||||||
offerText: item.offerText,
|
offerText: item.offerText,
|
||||||
isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
|
isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
|
||||||
offerLimitText,
|
offerLimitText,
|
||||||
@@ -348,19 +360,24 @@ export class FlyerImportService {
|
|||||||
const savedItems: FlyerImportItem[] = [];
|
const savedItems: FlyerImportItem[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const created = await this.prisma.flyerItem.create({
|
const created = await this.prisma.flyerItem.create({
|
||||||
data: {
|
data: {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
rawName: item.rawName,
|
rawName: item.rawName,
|
||||||
normalizedName: item.normalizedName,
|
normalizedName: item.normalizedName,
|
||||||
categoryHint: item.category,
|
brand: item.brand,
|
||||||
categoryId: item.categoryId,
|
categoryHint: item.category,
|
||||||
price: item.price != null ? new Prisma.Decimal(item.price) : null,
|
categoryId: item.categoryId,
|
||||||
priceUnit: item.priceUnit,
|
price: item.price != null ? new Prisma.Decimal(item.price) : null,
|
||||||
comparisonPrice:
|
priceUnit: item.priceUnit,
|
||||||
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
|
comparisonPrice:
|
||||||
comparisonUnit: item.comparisonUnit,
|
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
|
||||||
offerText: item.offerText,
|
comparisonUnit: item.comparisonUnit,
|
||||||
parseConfidence: item.parseConfidence,
|
weight: item.weight,
|
||||||
|
bundleWeight: item.bundleWeight,
|
||||||
|
isBundle: item.isBundle,
|
||||||
|
bundleItems: item.bundleItems,
|
||||||
|
offerText: item.offerText,
|
||||||
|
parseConfidence: item.parseConfidence,
|
||||||
parseReasons: item.parseReasons,
|
parseReasons: item.parseReasons,
|
||||||
matchedProductId: item.matchedProductId,
|
matchedProductId: item.matchedProductId,
|
||||||
matchedProductName: item.matchedProductName,
|
matchedProductName: item.matchedProductName,
|
||||||
@@ -592,18 +609,23 @@ export class FlyerImportService {
|
|||||||
const normalizedItems = this.normalizer.normalize(aiItems);
|
const normalizedItems = this.normalizer.normalize(aiItems);
|
||||||
|
|
||||||
// 4. Konvertera till intern FlyerParseItem-format
|
// 4. Konvertera till intern FlyerParseItem-format
|
||||||
const items: FlyerParseItem[] = normalizedItems.map((item) => ({
|
const items: FlyerParseItem[] = normalizedItems.map((item) => ({
|
||||||
rawName: item.rawName,
|
rawName: item.rawName,
|
||||||
normalizedName: item.normalizedName,
|
normalizedName: item.normalizedName,
|
||||||
category: item.categoryHint,
|
brand: item.brand,
|
||||||
price: item.price,
|
category: item.categoryHint,
|
||||||
priceUnit: item.priceUnit,
|
price: item.price,
|
||||||
comparisonPrice: item.comparisonPrice,
|
priceUnit: item.priceUnit,
|
||||||
comparisonUnit: item.comparisonUnit,
|
comparisonPrice: item.comparisonPrice,
|
||||||
offerText: item.offerText,
|
comparisonUnit: item.comparisonUnit,
|
||||||
confidence: item.parseConfidence,
|
weight: item.weight,
|
||||||
reasonCodes: item.parseReasons,
|
bundleWeight: item.bundleWeight,
|
||||||
}));
|
isBundle: item.isBundle,
|
||||||
|
bundleItems: item.bundleItems,
|
||||||
|
offerText: item.offerText,
|
||||||
|
confidence: item.parseConfidence,
|
||||||
|
reasonCodes: item.parseReasons,
|
||||||
|
}));
|
||||||
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
@@ -634,6 +656,7 @@ export class FlyerImportService {
|
|||||||
id: number;
|
id: number;
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
|
brand: string | null;
|
||||||
categoryHint: string | null;
|
categoryHint: string | null;
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
categoryRef?: {
|
categoryRef?: {
|
||||||
@@ -649,6 +672,10 @@ export class FlyerImportService {
|
|||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
comparisonPrice: Prisma.Decimal | null;
|
comparisonPrice: Prisma.Decimal | null;
|
||||||
comparisonUnit: string | null;
|
comparisonUnit: string | null;
|
||||||
|
weight: string | null;
|
||||||
|
bundleWeight: string | null;
|
||||||
|
isBundle: boolean;
|
||||||
|
bundleItems: Prisma.JsonValue | null;
|
||||||
offerText: string | null;
|
offerText: string | null;
|
||||||
parseConfidence: number;
|
parseConfidence: number;
|
||||||
parseReasons: Prisma.JsonValue | null;
|
parseReasons: Prisma.JsonValue | null;
|
||||||
@@ -677,12 +704,17 @@ export class FlyerImportService {
|
|||||||
flyerItemId: item.id,
|
flyerItemId: item.id,
|
||||||
rawName: item.rawName,
|
rawName: item.rawName,
|
||||||
normalizedName: item.normalizedName,
|
normalizedName: item.normalizedName,
|
||||||
|
brand: item.brand,
|
||||||
category: categoryPath,
|
category: categoryPath,
|
||||||
categoryId: item.categoryId,
|
categoryId: item.categoryId,
|
||||||
price: item.price != null ? item.price.toNumber() : offerSignals.price,
|
price: item.price != null ? item.price.toNumber() : offerSignals.price,
|
||||||
priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit,
|
priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit,
|
||||||
comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice,
|
comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice,
|
||||||
comparisonUnit: this.normalizeUnit(item.comparisonUnit) ?? offerSignals.comparisonUnit,
|
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,
|
offerText: item.offerText,
|
||||||
isOffer:
|
isOffer:
|
||||||
item.price != null
|
item.price != null
|
||||||
@@ -727,6 +759,7 @@ export class FlyerImportService {
|
|||||||
id: number;
|
id: number;
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
|
brand: string | null;
|
||||||
categoryHint: string | null;
|
categoryHint: string | null;
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
categoryRef?: {
|
categoryRef?: {
|
||||||
@@ -742,6 +775,10 @@ export class FlyerImportService {
|
|||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
comparisonPrice: Prisma.Decimal | null;
|
comparisonPrice: Prisma.Decimal | null;
|
||||||
comparisonUnit: string | null;
|
comparisonUnit: string | null;
|
||||||
|
weight: string | null;
|
||||||
|
bundleWeight: string | null;
|
||||||
|
isBundle: boolean;
|
||||||
|
bundleItems: Prisma.JsonValue | null;
|
||||||
offerText: string | null;
|
offerText: string | null;
|
||||||
parseConfidence: number;
|
parseConfidence: number;
|
||||||
parseReasons: Prisma.JsonValue | null;
|
parseReasons: Prisma.JsonValue | null;
|
||||||
@@ -793,4 +830,13 @@ export class FlyerImportService {
|
|||||||
private buildSourceStorageKey(userId: number, weekKey: string): string {
|
private buildSourceStorageKey(userId: number, weekKey: string): string {
|
||||||
return `flyer/${userId}/${weekKey}/${Date.now()}`;
|
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 {
|
export interface AiFlyerParseResult {
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
|
brand: string | null;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
comparisonPrice: number | null;
|
comparisonPrice: number | null;
|
||||||
comparisonUnit: string | null;
|
comparisonUnit: string | null;
|
||||||
|
weight: string | null;
|
||||||
|
bundleWeight: string | null;
|
||||||
|
isBundle: boolean;
|
||||||
|
bundleItems: string[];
|
||||||
offerText: string | null;
|
offerText: string | null;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
reasonCodes: string[];
|
reasonCodes: string[];
|
||||||
@@ -162,41 +167,64 @@ export class AiFlyerParserService {
|
|||||||
private buildPrompt(text: string, maxTextLength: number): string {
|
private buildPrompt(text: string, maxTextLength: number): string {
|
||||||
const truncatedText = text.length > maxTextLength ? text.substring(0, maxTextLength) : text;
|
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:
|
Regler:
|
||||||
- name: Produktnamn (fullständigt namn)
|
1) Vanlig produkt (ej bundle): isBundle=false, bundleWeight=null, bundleItems=[].
|
||||||
- weight: Vikt (om tillgänglig, t.ex. "150g", "Ca 1kg") eller null
|
2) Kombipaket/bundle: isBundle=true, name ska vara paketets huvudnamn, bundleWeight totalvikt.
|
||||||
- origin: Ursprung/land/märke (om tillgänglig, t.ex. "Grönland") eller null
|
3) For bundle ska bundleItems innehalla de ingaende produkterna, t.ex. ["Chumlax 3x100g", "Alaska pollock 3x100g"].
|
||||||
- price: Pris som nummer (t.ex. 39.90) eller null
|
4) price ar priset for hela forpackningen. comparisonPrice ar jamforpris som tal ("83:17" -> 83.17).
|
||||||
- comparisonPrice: Jämförpris som nummer (t.ex. 266.00) eller null
|
5) offer innehaller kampanjtext som "Max 10 kop/hushall".
|
||||||
- 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
|
|
||||||
|
|
||||||
Texten att tolka:
|
Exempel bundle utdata:
|
||||||
${truncatedText}
|
|
||||||
|
|
||||||
Returnera ENDAST en JSON-array. Inga andra kommentarer, ingen markdown-markup.
|
|
||||||
Exempel på utdata:
|
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "KALLRÖKT LAX, GRAVAD LAX",
|
"name": "Kaptenens Favoriter",
|
||||||
"weight": "150g",
|
"brand": "Kapten Royal",
|
||||||
"origin": "Grönland",
|
|
||||||
"price": 39.90,
|
|
||||||
"comparisonPrice": 266.00,
|
|
||||||
"unit": "kg",
|
|
||||||
"offer": ["Max 3 köp/hushåll"],
|
|
||||||
"category": "Fisk",
|
"category": "Fisk",
|
||||||
"validFrom": "2026-05-18",
|
"isBundle": true,
|
||||||
"validTo": "2026-05-24"
|
"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 {
|
return {
|
||||||
rawName,
|
rawName,
|
||||||
normalizedName,
|
normalizedName,
|
||||||
|
brand: toString(item.brand),
|
||||||
category: toString(item.category),
|
category: toString(item.category),
|
||||||
price: toNumber(item.price),
|
price: toNumber(item.price),
|
||||||
priceUnit: toString(item.unit),
|
priceUnit: toString(item.unit),
|
||||||
comparisonPrice: toNumber(item.comparisonPrice),
|
comparisonPrice: toNumber(item.comparisonPrice),
|
||||||
comparisonUnit: toString(item.comparisonUnit),
|
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),
|
offerText: toString(item.offer) || (toArray(item.offer).join(' ') || null),
|
||||||
confidence: 0.85,
|
confidence: 0.85,
|
||||||
reasonCodes: ['ai_parsed'],
|
reasonCodes: ['ai_parsed'],
|
||||||
@@ -345,7 +378,7 @@ Exempel på utdata:
|
|||||||
throw new BadRequestException('AI returnerade inte en JSON-array.');
|
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) {
|
} catch (attemptErr) {
|
||||||
lastError = attemptErr;
|
lastError = attemptErr;
|
||||||
if (debugSession) {
|
if (debugSession) {
|
||||||
@@ -379,6 +412,9 @@ Exempel på utdata:
|
|||||||
item.price ?? '',
|
item.price ?? '',
|
||||||
item.priceUnit ?? '',
|
item.priceUnit ?? '',
|
||||||
item.offerText ?? '',
|
item.offerText ?? '',
|
||||||
|
item.isBundle ? '1' : '0',
|
||||||
|
item.bundleWeight ?? '',
|
||||||
|
JSON.stringify(item.bundleItems ?? []),
|
||||||
].join('|');
|
].join('|');
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
export interface NormalizedFlyerItem {
|
export interface NormalizedFlyerItem {
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
|
brand: string | null;
|
||||||
categoryHint: string | null;
|
categoryHint: string | null;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
comparisonPrice: number | null;
|
comparisonPrice: number | null;
|
||||||
comparisonUnit: string | null;
|
comparisonUnit: string | null;
|
||||||
|
weight: string | null;
|
||||||
|
bundleWeight: string | null;
|
||||||
|
isBundle: boolean;
|
||||||
|
bundleItems: string[];
|
||||||
offerText: string | null;
|
offerText: string | null;
|
||||||
parseConfidence: number;
|
parseConfidence: number;
|
||||||
parseReasons: string[];
|
parseReasons: string[];
|
||||||
@@ -16,6 +21,8 @@ export interface NormalizedFlyerItem {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class FlyerNormalizerService {
|
export class FlyerNormalizerService {
|
||||||
private readonly logger = new Logger(FlyerNormalizerService.name);
|
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> = {
|
private readonly UNIT_MAPPING: Record<string, string> = {
|
||||||
// Längd
|
// Längd
|
||||||
@@ -75,11 +82,16 @@ export class FlyerNormalizerService {
|
|||||||
return {
|
return {
|
||||||
rawName,
|
rawName,
|
||||||
normalizedName,
|
normalizedName,
|
||||||
|
brand: this.extractString(item.brand),
|
||||||
categoryHint: this.normalizeCategory(this.extractString(item.category)),
|
categoryHint: this.normalizeCategory(this.extractString(item.category)),
|
||||||
price: this.extractPrice(item.price),
|
price: this.extractPrice(item.price),
|
||||||
priceUnit: this.normalizeUnit(this.extractString(item.unit)),
|
priceUnit: this.normalizeUnit(this.extractString(item.unit)),
|
||||||
comparisonPrice: this.extractPrice(item.comparisonPrice),
|
comparisonPrice: this.extractPrice(item.comparisonPrice),
|
||||||
comparisonUnit: this.normalizeUnit(this.extractString(item.comparisonUnit)),
|
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),
|
offerText: this.normalizeOfferText(item.offer),
|
||||||
parseConfidence: item.confidence ?? 0.85,
|
parseConfidence: item.confidence ?? 0.85,
|
||||||
parseReasons: Array.isArray(item.reasonCodes)
|
parseReasons: Array.isArray(item.reasonCodes)
|
||||||
@@ -102,6 +114,15 @@ export class FlyerNormalizerService {
|
|||||||
return null;
|
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 {
|
private normalizeName(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|||||||
Reference in New Issue
Block a user