feat(flyer-import): add detailed product signals and display names
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 5m12s
Test Suite / flutter-quality (push) Failing after 2m8s

- 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:
Nils-Johan Gynther
2026-05-24 19:32:13 +02:00
parent d9f992ca9a
commit b04d157915
16 changed files with 1124 additions and 107 deletions
@@ -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;
}