feat(flyer-import): add detailed product signals and display names
- Added `signals` and `displayNameDetailed` fields to FlyerItem model in Prisma schema - Introduced `FlyerImportSignals` type with origin countries, labels, quality flags, variant, and packaging - Added `displayNameDetailed` field to FlyerImportItem DTO and Flutter model - Implemented utility functions for signal extraction and display name building - Updated flyer import service to persist and return signals/category data - Enhanced Flutter UI to display detailed product information including badges for signals - Added new test coverage for signals persistence and display name generation - Added new import-common module for shared import utilities - Created database migration for new fields - Added Kilo plan for feature development
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { CategoryResolverService } from './category-resolver.service';
|
||||
|
||||
describe('CategoryResolverService', () => {
|
||||
const service = new CategoryResolverService();
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: 'Kött, chark & fågel', path: 'Kött, chark & fågel' },
|
||||
{ id: 2, name: 'Kött', path: 'Kött, chark & fågel > Kött' },
|
||||
{ id: 3, name: 'Fläsk', path: 'Kött, chark & fågel > Kött > Fläsk' },
|
||||
{ id: 4, name: 'Bröd', path: 'Bröd & kakor > Bröd' },
|
||||
];
|
||||
|
||||
it('resolves Fläskytterfilé to pork category', () => {
|
||||
const categoryId = service.resolveForFlyer({
|
||||
categories,
|
||||
signalText: 'Fläskytterfilé Sverige',
|
||||
categoryHint: null,
|
||||
matchedProductCategoryId: null,
|
||||
matchConfidence: 0,
|
||||
});
|
||||
|
||||
expect(categoryId).toBe(3);
|
||||
});
|
||||
|
||||
it('prefers matched product category when confidence is high', () => {
|
||||
const categoryId = service.resolveForFlyer({
|
||||
categories,
|
||||
signalText: 'Något annat',
|
||||
categoryHint: 'Bröd',
|
||||
matchedProductCategoryId: 99,
|
||||
matchConfidence: 0.95,
|
||||
});
|
||||
|
||||
expect(categoryId).toBe(99);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { FlatCategory } from '../categories/categories.service';
|
||||
|
||||
type ResolveFlyerCategoryParams = {
|
||||
categories: FlatCategory[];
|
||||
signalText: string;
|
||||
categoryHint: string | null;
|
||||
matchedProductCategoryId: number | null;
|
||||
matchConfidence: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CategoryResolverService {
|
||||
resolveForFlyer(params: ResolveFlyerCategoryParams): number | null {
|
||||
if (params.matchedProductCategoryId != null && params.matchConfidence >= 0.9) {
|
||||
return params.matchedProductCategoryId;
|
||||
}
|
||||
|
||||
const normalizedSignal = normalizeForRules(params.signalText);
|
||||
|
||||
if (hasPorkLikeSignal(normalizedSignal)) {
|
||||
const pork = this.resolvePorkCategory(params.categories);
|
||||
if (pork) return pork.id;
|
||||
}
|
||||
|
||||
if (hasBreadLikeSignal(normalizedSignal)) {
|
||||
const bread = this.resolveBreadCategory(params.categories);
|
||||
if (bread) return bread.id;
|
||||
}
|
||||
|
||||
if (!params.categoryHint) return null;
|
||||
return this.resolveByHint(params.categories, params.categoryHint)?.id ?? null;
|
||||
}
|
||||
|
||||
private resolveByHint(categories: FlatCategory[], categoryHint: string): FlatCategory | undefined {
|
||||
const normalizedHint = normalizeForRules(categoryHint);
|
||||
|
||||
return categories.find((category) => {
|
||||
const normalizedName = normalizeForRules(category.name);
|
||||
const normalizedPath = normalizeForRules(category.path);
|
||||
return normalizedName === normalizedHint || normalizedPath === normalizedHint;
|
||||
});
|
||||
}
|
||||
|
||||
private resolvePorkCategory(categories: FlatCategory[]): FlatCategory | undefined {
|
||||
return (
|
||||
categories.find(
|
||||
(category) =>
|
||||
category.name.toLowerCase() === 'fläsk' &&
|
||||
category.path.toLowerCase().startsWith('kött, chark & fågel > kött > '),
|
||||
) ||
|
||||
categories.find(
|
||||
(category) =>
|
||||
category.name.toLowerCase() === 'kött' &&
|
||||
category.path.toLowerCase() === 'kött, chark & fågel > kött',
|
||||
) ||
|
||||
categories.find((category) => category.path.toLowerCase() === 'kött, chark & fågel')
|
||||
);
|
||||
}
|
||||
|
||||
private resolveBreadCategory(categories: FlatCategory[]): FlatCategory | undefined {
|
||||
return (
|
||||
categories.find(
|
||||
(category) =>
|
||||
category.name.toLowerCase() === 'rostbröd' &&
|
||||
category.path.toLowerCase().startsWith('bröd & kakor > bröd > '),
|
||||
) ||
|
||||
categories.find(
|
||||
(category) =>
|
||||
category.name.toLowerCase() === 'bröd' &&
|
||||
category.path.toLowerCase() === 'bröd & kakor > bröd',
|
||||
) ||
|
||||
categories.find((category) => category.path.toLowerCase() === 'bröd & kakor')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeForRules(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasPorkLikeSignal(normalized: string): boolean {
|
||||
return (
|
||||
normalized.includes('bacon') ||
|
||||
normalized.includes('sidflask') ||
|
||||
normalized.includes('pancetta') ||
|
||||
normalized.includes('flask') ||
|
||||
normalized.includes('flaskytterfile') ||
|
||||
normalized.includes('ytterfile') ||
|
||||
normalized.includes('karre') ||
|
||||
normalized.includes('kotlett')
|
||||
);
|
||||
}
|
||||
|
||||
function hasBreadLikeSignal(normalized: string): boolean {
|
||||
return (
|
||||
/\brostbrod\b/.test(normalized) ||
|
||||
/\brost\s*n\s*toast\b/.test(normalized) ||
|
||||
/\broast\s*n\s*toast\b/.test(normalized) ||
|
||||
/\btoastbrod\b/.test(normalized) ||
|
||||
/\bformbrod\b/.test(normalized) ||
|
||||
/\blantbrod\b/.test(normalized) ||
|
||||
/\bfullkornsbrod\b/.test(normalized) ||
|
||||
/\bfranska\b/.test(normalized) ||
|
||||
/\blimpa\b/.test(normalized) ||
|
||||
/\bbrod\b/.test(normalized) ||
|
||||
/\btoast\b/.test(normalized)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function buildDisplayNameDetailed(params: {
|
||||
rawName: string;
|
||||
isBundle: boolean;
|
||||
bundleItems: string[];
|
||||
}): string {
|
||||
const rawName = params.rawName.trim();
|
||||
if (!params.isBundle) return rawName;
|
||||
|
||||
const items = params.bundleItems
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
if (items.length === 0) return rawName;
|
||||
return `${rawName} (${items.join(' + ')})`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ImportedItemSignals = {
|
||||
originCountries: string[];
|
||||
labels: string[];
|
||||
qualityFlags: string[];
|
||||
variant: string | null;
|
||||
packaging: string | null;
|
||||
};
|
||||
|
||||
export type ImportedItemCandidate = {
|
||||
rawName: string;
|
||||
normalizedName: string;
|
||||
brand: string | null;
|
||||
weight: string | null;
|
||||
bundleWeight: string | null;
|
||||
isBundle: boolean;
|
||||
bundleItems: string[];
|
||||
price: number | null;
|
||||
priceUnit: string | null;
|
||||
comparisonPrice: number | null;
|
||||
comparisonUnit: string | null;
|
||||
categoryHint: string | null;
|
||||
categoryId: number | null;
|
||||
matchedProductId: number | null;
|
||||
matchedProductName: string | null;
|
||||
matchedVia: string;
|
||||
matchConfidence: number;
|
||||
matchReasons: string[];
|
||||
signals: ImportedItemSignals | null;
|
||||
displayNameDetailed: string | null;
|
||||
};
|
||||
|
||||
export const EMPTY_IMPORTED_SIGNALS: ImportedItemSignals = {
|
||||
originCountries: [],
|
||||
labels: [],
|
||||
qualityFlags: [],
|
||||
variant: null,
|
||||
packaging: null,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { buildDisplayNameDetailed } from './import-display-name.util';
|
||||
import { extractImportSignals } from './import-signals.util';
|
||||
|
||||
describe('import signals utilities', () => {
|
||||
it('extracts deterministic origin and eco labels', () => {
|
||||
const result = extractImportSignals({
|
||||
rawName: 'Fläskytterfilé (Sverige) EKO',
|
||||
brand: 'Garant',
|
||||
offerText: 'Ekologiskt kött från Sverige',
|
||||
});
|
||||
|
||||
expect(result.signals.originCountries).toEqual(['Sverige']);
|
||||
expect(result.signals.labels).toContain('Ekologisk');
|
||||
expect(result.signals.qualityFlags).toContain('eco');
|
||||
expect(result.normalizedMatchName).toBe('flaskytterfile');
|
||||
});
|
||||
|
||||
it('extracts Germany and keeps labels deterministic', () => {
|
||||
const result = extractImportSignals({
|
||||
rawName: 'Korv från Tyskland',
|
||||
offerText: 'Tysk kvalitet',
|
||||
});
|
||||
|
||||
expect(result.signals.originCountries).toEqual(['Tyskland']);
|
||||
expect(result.signals.labels).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds detailed display name for bundle rows', () => {
|
||||
expect(
|
||||
buildDisplayNameDetailed({
|
||||
rawName: 'Kaptenens Favoriter',
|
||||
isBundle: true,
|
||||
bundleItems: ['Chumlax 3x100g', 'Alaska pollock 3x100g'],
|
||||
}),
|
||||
).toBe('Kaptenens Favoriter (Chumlax 3x100g + Alaska pollock 3x100g)');
|
||||
});
|
||||
|
||||
it('extracts storpack packaging signal', () => {
|
||||
const result = extractImportSignals({
|
||||
rawName: 'Kycklingfilé storpack',
|
||||
});
|
||||
|
||||
expect(result.signals.packaging).toBe('storpack');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { normalizeName } from '../common/utils/normalize-name';
|
||||
import {
|
||||
EMPTY_IMPORTED_SIGNALS,
|
||||
ImportedItemSignals,
|
||||
} from './import-item.types';
|
||||
|
||||
type SignalExtractionInput = {
|
||||
rawName: string;
|
||||
brand?: string | null;
|
||||
offerText?: string | null;
|
||||
};
|
||||
|
||||
const ORIGIN_COUNTRY_PATTERNS: Array<{ label: string; regex: RegExp }> = [
|
||||
{ label: 'Sverige', regex: /\b(sverige|svensk(t|a)?|sweden)\b/i },
|
||||
{ label: 'Tyskland', regex: /\b(tyskland|tysk(t|a)?|germany|deutschland)\b/i },
|
||||
{ label: 'Norge', regex: /\b(norge|norsk(t|a)?)\b/i },
|
||||
{ label: 'Danmark', regex: /\b(danmark|dansk(t|a)?)\b/i },
|
||||
{ label: 'Finland', regex: /\b(finland|finsk(t|a)?)\b/i },
|
||||
];
|
||||
|
||||
const LABEL_PATTERNS: Array<{ label: string; qualityFlag: string | null; regex: RegExp }> = [
|
||||
{ label: 'Ekologisk', qualityFlag: 'eco', regex: /\b(eko|ekologisk(t|a)?|organic)\b/i },
|
||||
{ label: 'Laktosfri', qualityFlag: 'lactose_free', regex: /\b(laktosfri(tt|a)?|lactose\s*free)\b/i },
|
||||
{ label: 'Glutenfri', qualityFlag: 'gluten_free', regex: /\b(glutenfri(tt|a)?|gluten\s*free)\b/i },
|
||||
{ label: 'Vegansk', qualityFlag: 'vegan', regex: /\b(vegansk(t|a)?|vegan)\b/i },
|
||||
{ label: 'Vegetarisk', qualityFlag: 'vegetarian', regex: /\b(vegetarisk(t|a)?|vegetarian)\b/i },
|
||||
];
|
||||
|
||||
export type SignalExtractionResult = {
|
||||
signals: ImportedItemSignals;
|
||||
normalizedMatchName: string;
|
||||
};
|
||||
|
||||
export function extractImportSignals(input: SignalExtractionInput): SignalExtractionResult {
|
||||
const text = [input.rawName, input.brand ?? '', input.offerText ?? '']
|
||||
.filter((part) => part.trim().length > 0)
|
||||
.join(' ');
|
||||
|
||||
const origins = ORIGIN_COUNTRY_PATTERNS
|
||||
.filter((pattern) => pattern.regex.test(text))
|
||||
.map((pattern) => pattern.label);
|
||||
|
||||
const labels = LABEL_PATTERNS
|
||||
.filter((pattern) => pattern.regex.test(text))
|
||||
.map((pattern) => pattern.label);
|
||||
|
||||
const qualityFlags = LABEL_PATTERNS
|
||||
.filter((pattern) => pattern.qualityFlag && pattern.regex.test(text))
|
||||
.map((pattern) => pattern.qualityFlag as string);
|
||||
|
||||
const packaging = resolvePackaging(text);
|
||||
const variant = extractVariant(input.rawName);
|
||||
|
||||
const signals: ImportedItemSignals = {
|
||||
...EMPTY_IMPORTED_SIGNALS,
|
||||
originCountries: Array.from(new Set(origins)),
|
||||
labels: Array.from(new Set(labels)),
|
||||
qualityFlags: Array.from(new Set(qualityFlags)),
|
||||
variant,
|
||||
packaging,
|
||||
};
|
||||
|
||||
const normalizedMatchName = normalizeForMatching(input.rawName);
|
||||
|
||||
return { signals, normalizedMatchName };
|
||||
}
|
||||
|
||||
export function normalizeForMatching(rawName: string): string {
|
||||
let cleaned = rawName;
|
||||
|
||||
for (const pattern of ORIGIN_COUNTRY_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern.regex, ' ');
|
||||
}
|
||||
|
||||
for (const pattern of LABEL_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern.regex, ' ');
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/[()\[\]]/g, ' ');
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
||||
return normalizeName(cleaned) || normalizeName(rawName);
|
||||
}
|
||||
|
||||
function resolvePackaging(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
if (/\b\d+\s*[x×]\s*\d+\s*(g|kg|ml|cl|dl|l)\b/.test(normalized)) {
|
||||
return 'multipack';
|
||||
}
|
||||
if (/\bstorpack\b/.test(normalized)) {
|
||||
return 'storpack';
|
||||
}
|
||||
if (/\b(2-pack|3-pack|4-pack|5-pack|6-pack|pack)\b/.test(normalized)) {
|
||||
return 'pack';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractVariant(rawName: string): string | null {
|
||||
const variantMatch = rawName.match(/\(([^)]+)\)/);
|
||||
if (!variantMatch) return null;
|
||||
const value = variantMatch[1].trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
Reference in New Issue
Block a user