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
@@ -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;
+5
View File
@@ -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()