import { Injectable, Logger } from '@nestjs/common'; export interface NormalizedFlyerItem { rawName: string; normalizedName: string; categoryHint: string | null; price: number | null; priceUnit: string | null; comparisonPrice: number | null; comparisonUnit: string | null; offerText: string | null; parseConfidence: number; parseReasons: string[]; } @Injectable() export class FlyerNormalizerService { private readonly logger = new Logger(FlyerNormalizerService.name); private readonly UNIT_MAPPING: Record = { // Längd mm: 'mm', cm: 'cm', m: 'm', // Vikt mg: 'mg', g: 'g', hg: 'hg', kg: 'kg', ton: 'ton', // Volym ml: 'ml', cl: 'cl', dl: 'dl', l: 'l', // Övrigt st: 'st', styck: 'st', stycke: 'st', pkt: 'pkt', paket: 'pkt', fp: 'pkt', förp: 'pkt', förpackning: 'pkt', }; /** * Normaliserar en AI-parsad produktlista. */ normalize(items: any[]): NormalizedFlyerItem[] { if (!Array.isArray(items)) { this.logger.warn('normalize() received non-array, returning empty list'); return []; } return items .map((item, idx) => this.normalizeItem(item, idx)) .filter((item): item is NormalizedFlyerItem => item !== null); } private normalizeItem(item: any, index: number): NormalizedFlyerItem | null { if (!item || typeof item !== 'object') { this.logger.warn(`Item ${index} is not an object, skipping`); return null; } const rawName = this.extractString(item.rawName) || this.extractString(item.name); if (!rawName) { this.logger.warn(`Item ${index} has no name, skipping`); return null; } const normalizedName = this.extractString(item.normalizedName) || this.normalizeName(rawName); return { rawName, normalizedName, 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)), offerText: this.normalizeOfferText(item.offer), parseConfidence: item.confidence ?? 0.85, parseReasons: Array.isArray(item.reasonCodes) ? item.reasonCodes.map(String) : ['normalized'], }; } private extractString(val: any): string | null { if (typeof val === 'string') return val.trim() || null; return null; } private extractPrice(val: any): number | null { if (typeof val === 'number') return val; if (typeof val === 'string') { const num = parseFloat(val.replace(/,/g, '.')); return isFinite(num) ? num : null; } return null; } private normalizeName(name: string): string { return name .toLowerCase() .replace(/[^a-zåäö0-9\s]/g, '') .replace(/\s+/g, ' ') .trim(); } private normalizeUnit(unit: string | null): string | null { if (!unit) return null; const cleaned = unit.trim().toLowerCase().replace(/\./g, ''); return this.UNIT_MAPPING[cleaned] ?? null; } private normalizeCategory(category: string | null): string | null { if (!category) return null; const normalized = category.trim().toLowerCase(); // Mappning av tänkta kategorivärdena från AI const categoryMap: Record = { fisk: 'Fisk', kött: 'Kött', mejeri: 'Mejeri', grönsaker: 'Grönsaker', frukt: 'Frukt', dryck: 'Dryck', frukt_grönsaker: 'Frukt & Grönsaker', fastfood: 'Fastfood', bröd: 'Bröd', fryst: 'Fryst', godis: 'Godis', pasta: 'Pasta', }; return categoryMap[normalized] ?? null; } private normalizeOfferText(offer: any): string | null { if (!offer) return null; if (typeof offer === 'string') { return offer.trim() || null; } if (Array.isArray(offer)) { const joined = offer.map(String).filter(s => s.trim()).join(' '); return joined || null; } return null; } }