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,4 @@
-- AlterTable
ALTER TABLE `FlyerItem`
ADD COLUMN `signals` JSON NULL,
ADD COLUMN `displayNameDetailed` VARCHAR(191) NULL;
+2
View File
@@ -325,6 +325,8 @@ model FlyerItem {
bundleWeight String?
isBundle Boolean @default(false)
bundleItems Json?
signals Json?
displayNameDetailed String?
offerText String?
parseConfidence Float
parseReasons Json?
@@ -1,5 +1,13 @@
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
export type FlyerImportSignals = {
originCountries: string[];
labels: string[];
qualityFlags: string[];
variant: string | null;
packaging: string | null;
};
export type FlyerReasonDescriptor = {
code: string;
kind: 'parse' | 'match';
@@ -24,6 +32,8 @@ export type FlyerImportItem = {
bundleWeight: string | null;
isBundle: boolean;
bundleItems: string[];
displayNameDetailed: string | null;
signals: FlyerImportSignals | null;
offerText: string | null;
isOffer: boolean;
offerLimitText: string | null;
@@ -5,15 +5,18 @@ import { FlyerImportService } from './flyer-import.service';
import { TextExtractorService } from './services/text-extractor.service';
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
import { CategoriesModule } from '../categories/categories.module';
import { CategoryResolverService } from '../import-common/category-resolver.service';
@Module({
imports: [PrismaModule],
imports: [PrismaModule, CategoriesModule],
controllers: [FlyerImportController],
providers: [
FlyerImportService,
TextExtractorService,
AiFlyerParserService,
FlyerNormalizerService,
CategoryResolverService,
],
})
export class FlyerImportModule {}
@@ -3,25 +3,44 @@ import { FlyerImportService } from './flyer-import.service';
describe('FlyerImportService', () => {
const prismaMock = {
product: {
findMany: jest.fn(),
},
receiptAlias: {
findMany: jest.fn(),
},
flyerSession: {
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
},
flyerItem: {
findUnique: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
aiTrace: {
create: jest.fn(),
},
category: {
findUnique: jest.fn(),
},
};
const createService = () =>
const createService = (overrides?: {
categoriesService?: any;
categoryResolver?: any;
textExtractor?: any;
aiParser?: any;
normalizer?: any;
}) =>
new FlyerImportService(
prismaMock as any,
{} as any,
{} as any,
{} as any,
overrides?.categoriesService ?? { findFlattened: jest.fn().mockResolvedValue([]) },
overrides?.categoryResolver ?? { resolveForFlyer: jest.fn().mockReturnValue(null) },
overrides?.textExtractor ?? {},
overrides?.aiParser ?? {},
overrides?.normalizer ?? {},
);
beforeEach(() => {
@@ -78,6 +97,8 @@ describe('FlyerImportService', () => {
bundleWeight: null,
isBundle: false,
bundleItems: [],
displayNameDetailed: 'Tomat',
signals: { originCountries: ['Sverige'], labels: [], qualityFlags: [], variant: null, packaging: null },
offerText: 'Max 2 kop/hushall',
parseConfidence: 0.9,
parseReasons: ['ai_parsed'],
@@ -97,6 +118,8 @@ describe('FlyerImportService', () => {
expect(result.items).toHaveLength(1);
expect(result.items[0].flyerItemId).toBe(99);
expect(result.items[0].matchedVia).toBe('exact');
expect(result.items[0].displayNameDetailed).toBe('Tomat');
expect(result.items[0].signals?.originCountries).toEqual(['Sverige']);
expect(result.items[0].parseReasonsDetailed[0].title).toBe('AI-tolkad rad');
expect(result.items[0].matchReasonsDetailed[0].title).toBe('Exakt normaliserad matchning');
expect(result.sourceAvailable).toBe(false);
@@ -140,6 +163,162 @@ describe('FlyerImportService', () => {
});
});
describe('parseAndMatch', () => {
it('persists and returns signals/displayNameDetailed/categoryId in parse pipeline', async () => {
prismaMock.product.findMany.mockResolvedValue([
{ id: 11, name: 'Fläskytterfilé', canonicalName: 'Fläskytterfilé', categoryId: 7 },
]);
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.flyerSession.create.mockResolvedValue({ id: 200 });
prismaMock.flyerItem.create
.mockResolvedValueOnce({ id: 1001 });
prismaMock.aiTrace.create.mockResolvedValue({ id: 1 });
const categoriesService = { findFlattened: jest.fn().mockResolvedValue([]) };
const categoryResolver = { resolveForFlyer: jest.fn().mockReturnValue(7) };
const textExtractor = { extractText: jest.fn().mockResolvedValue('raw flyer text') };
const aiParser = {
parseWithAI: jest.fn().mockResolvedValue({
items: [
{
rawName: 'Fläskytterfilé (Sverige) EKO',
normalizedName: 'flaskytterfile sverige eko',
brand: 'Garant',
category: 'Kött',
price: 99.9,
unit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: '900g',
bundleWeight: null,
isBundle: true,
bundleItems: ['Del 1', 'Del 2'],
offer: 'ekologiskt från Sverige',
confidence: 0.93,
reasonCodes: ['ai_parsed'],
},
],
trace: { prompt: null, rawOutput: null, chunkCount: 1, retryCount: 0 },
}),
};
const normalizer = {
normalize: jest.fn().mockReturnValue([
{
rawName: 'Fläskytterfilé (Sverige) EKO',
normalizedName: 'flaskytterfile sverige eko',
brand: 'Garant',
categoryHint: 'Kött',
price: 99.9,
priceUnit: 'kg',
comparisonPrice: null,
comparisonUnit: null,
weight: '900g',
bundleWeight: null,
isBundle: true,
bundleItems: ['Del 1', 'Del 2'],
offerText: 'ekologiskt från Sverige',
parseConfidence: 0.93,
parseReasons: ['ai_parsed'],
},
]),
};
const service = createService({
categoriesService,
categoryResolver,
textExtractor,
aiParser,
normalizer,
});
const result = await service.parseAndMatch(
{
originalname: 'flyer.pdf',
mimetype: 'application/pdf',
size: 10,
buffer: Buffer.from('pdf'),
} as any,
1,
);
expect(result.items).toHaveLength(1);
expect(result.items[0].displayNameDetailed).toBe('Fläskytterfilé (Sverige) EKO (Del 1 + Del 2)');
expect(result.items[0].signals?.originCountries).toEqual(['Sverige']);
expect(result.items[0].signals?.qualityFlags).toContain('eco');
expect(result.items[0].categoryId).toBe(7);
expect(result.items[0].normalizedName).toBe('flaskytterfile');
expect(prismaMock.flyerItem.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
displayNameDetailed: 'Fläskytterfilé (Sverige) EKO (Del 1 + Del 2)',
categoryId: 7,
signals: expect.objectContaining({ originCountries: ['Sverige'] }),
}),
}),
);
});
it('logs warning when categories fallback is used', async () => {
prismaMock.product.findMany.mockResolvedValue([]);
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.flyerSession.create.mockResolvedValue({ id: 201 });
prismaMock.flyerItem.create.mockResolvedValue({ id: 1002 });
prismaMock.aiTrace.create.mockResolvedValue({ id: 2 });
const categoriesService = { findFlattened: jest.fn().mockRejectedValue(new Error('db down')) };
const textExtractor = { extractText: jest.fn().mockResolvedValue('raw') };
const aiParser = {
parseWithAI: jest.fn().mockResolvedValue({
items: [{ rawName: 'Tomat' }],
trace: { prompt: null, rawOutput: null, chunkCount: 1, retryCount: 0 },
}),
};
const normalizer = {
normalize: jest.fn().mockReturnValue([
{
rawName: 'Tomat',
normalizedName: 'tomat',
brand: null,
categoryHint: null,
price: null,
priceUnit: null,
comparisonPrice: null,
comparisonUnit: null,
weight: null,
bundleWeight: null,
isBundle: false,
bundleItems: [],
offerText: null,
parseConfidence: 0.9,
parseReasons: ['ai_parsed'],
},
]),
};
const service = createService({
categoriesService,
textExtractor,
aiParser,
normalizer,
});
const warnSpy = jest.spyOn((service as any).logger, 'warn');
await service.parseAndMatch(
{
originalname: 'flyer.pdf',
mimetype: 'application/pdf',
size: 10,
buffer: Buffer.from('pdf'),
} as any,
1,
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Could not load categories for flyer import'),
);
});
});
describe('getLatestSession', () => {
it('returns empty response when no sessions exist', async () => {
prismaMock.flyerSession.findFirst.mockResolvedValue(null);
+206 -83
View File
@@ -6,9 +6,10 @@ import {
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name';
import { CategoriesService } from '../categories/categories.service';
import {
FlyerImportItem,
FlyerImportMatchVia,
@@ -18,6 +19,10 @@ import { TextExtractorService } from './services/text-extractor.service';
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
import { describeMatchReason, describeParseReason } from './services/reason-codes';
import { CategoryResolverService } from '../import-common/category-resolver.service';
import { buildDisplayNameDetailed } from '../import-common/import-display-name.util';
import { extractImportSignals } from '../import-common/import-signals.util';
import { ImportedItemSignals } from '../import-common/import-item.types';
type FlyerParseItem = {
rawName: string;
@@ -58,11 +63,12 @@ type ExtractedOfferSignals = {
hasCampaignPattern: boolean;
};
type ProductLite = {
id: number;
name: string;
canonicalName: string | null;
};
type ProductLite = {
id: number;
name: string;
canonicalName: string | null;
categoryId: number | null;
};
@Injectable()
export class FlyerImportService {
@@ -70,29 +76,37 @@ export class FlyerImportService {
private readonly MAX_BUNDLE_ITEMS = 20;
private readonly MAX_BUNDLE_ITEM_LENGTH = 120;
constructor(
private readonly prisma: PrismaService,
private readonly textExtractor: TextExtractorService,
private readonly aiParser: AiFlyerParserService,
private readonly normalizer: FlyerNormalizerService,
) {}
constructor(
private readonly prisma: PrismaService,
private readonly categoriesService: CategoriesService,
private readonly categoryResolver: CategoryResolverService,
private readonly textExtractor: TextExtractorService,
private readonly aiParser: AiFlyerParserService,
private readonly normalizer: FlyerNormalizerService,
) {}
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
const startedAt = Date.now();
const parsed = await this.parseViaInternal(file);
const [products, aliases] = await Promise.all([
this.prisma.product.findMany({
where: { ownerId: userId, isActive: true },
select: { id: true, name: true, canonicalName: true },
}),
this.prisma.receiptAlias.findMany({
where: {
OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }],
},
select: { receiptName: true, productId: true },
}),
]);
const [products, aliases, categories] = await Promise.all([
this.prisma.product.findMany({
where: { ownerId: userId, isActive: true },
select: { id: true, name: true, canonicalName: true, categoryId: true },
}),
this.prisma.receiptAlias.findMany({
where: {
OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }],
},
select: { receiptName: true, productId: true },
}),
this.categoriesService.findFlattened().catch((error) => {
this.logger.warn(
`Could not load categories for flyer import, proceeding without rule categories: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
}),
]);
const aliasToProduct = new Map<string, number>();
for (const alias of aliases) {
@@ -109,20 +123,39 @@ export class FlyerImportService {
}
const items: FlyerImportItem[] = parsed.items.map((item) => {
const match = this.matchItem(item, products, aliasToProduct, productById);
const signalData = extractImportSignals({
rawName: item.rawName,
brand: item.brand,
offerText: item.offerText,
});
const match = this.matchItem(item, signalData.normalizedMatchName, signalData.signals, products, aliasToProduct, productById);
const signals = this.extractOfferSignals(item.offerText);
const price = item.price ?? signals.price;
const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit;
const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice;
const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit;
const offerLimitText = this.extractOfferLimitText(item.offerText);
const displayNameDetailed = buildDisplayNameDetailed({
rawName: item.rawName,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(item.bundleItems),
});
const categoryId = this.categoryResolver.resolveForFlyer({
categories,
signalText: [item.rawName, item.brand ?? '', item.offerText ?? ''].join(' ').trim(),
categoryHint: item.category,
matchedProductCategoryId: match.product?.categoryId ?? null,
matchConfidence: match.confidence,
});
return {
flyerItemId: null,
rawName: item.rawName,
normalizedName: item.normalizedName,
normalizedName: signalData.normalizedMatchName || item.normalizedName,
brand: item.brand,
category: item.category,
categoryId: null,
categoryId,
price,
priceUnit,
comparisonPrice,
@@ -131,6 +164,8 @@ export class FlyerImportService {
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(item.bundleItems),
displayNameDetailed,
signals: signalData.signals,
offerText: item.offerText,
isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
offerLimitText,
@@ -145,6 +180,8 @@ export class FlyerImportService {
matchReasonsDetailed: this.describeMatchReasons(match.reasons),
};
});
this.logImportMetrics(items);
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file);
@@ -385,36 +422,40 @@ export class FlyerImportService {
select: { id: true },
});
const savedItems: FlyerImportItem[] = [];
for (const item of items) {
const created = await this.prisma.flyerItem.create({
data: {
sessionId: session.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
categoryHint: item.category,
categoryId: item.categoryId,
price: item.price != null ? new Prisma.Decimal(item.price) : null,
priceUnit: item.priceUnit,
comparisonPrice:
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
comparisonUnit: item.comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: item.bundleItems,
offerText: item.offerText,
parseConfidence: item.parseConfidence,
parseReasons: item.parseReasons,
matchedProductId: item.matchedProductId,
matchedProductName: item.matchedProductName,
matchedVia: item.matchedVia,
matchConfidence: item.matchConfidence,
matchReasons: item.matchReasons,
},
select: { id: true },
});
const savedItems: FlyerImportItem[] = [];
for (const item of items) {
const createData: Prisma.FlyerItemUncheckedCreateInput = {
sessionId: session.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
categoryHint: item.category,
categoryId: item.categoryId,
price: item.price != null ? new Prisma.Decimal(item.price) : null,
priceUnit: item.priceUnit,
comparisonPrice:
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
comparisonUnit: item.comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: item.bundleItems,
displayNameDetailed: item.displayNameDetailed,
signals: item.signals as Prisma.InputJsonValue,
offerText: item.offerText,
parseConfidence: item.parseConfidence,
parseReasons: item.parseReasons,
matchedProductId: item.matchedProductId,
matchedProductName: item.matchedProductName,
matchedVia: item.matchedVia,
matchConfidence: item.matchConfidence,
matchReasons: item.matchReasons,
};
const created = await this.prisma.flyerItem.create({
data: createData,
select: { id: true },
});
savedItems.push({ ...item, flyerItemId: created.id });
}
@@ -431,21 +472,23 @@ export class FlyerImportService {
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
private matchItem(
item: FlyerParseItem,
products: ProductLite[],
aliasToProduct: Map<string, number>,
productById: Map<number, ProductLite>,
): {
private matchItem(
item: FlyerParseItem,
normalizedMatchName: string,
itemSignals: ImportedItemSignals,
products: ProductLite[],
aliasToProduct: Map<string, number>,
productById: Map<number, ProductLite>,
): {
product: ProductLite | null;
via: FlyerImportMatchVia;
confidence: number;
reasons: string[];
} {
const normalized = normalizeName(item.rawName || item.normalizedName);
if (!normalized) {
return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] };
}
const normalized = normalizedMatchName || normalizeName(item.normalizedName || item.rawName);
if (!normalized) {
return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] };
}
const aliasedProductId = aliasToProduct.get(normalized);
if (aliasedProductId) {
@@ -471,17 +514,30 @@ export class FlyerImportService {
}
}
let best: { product: ProductLite; confidence: number; overlap: number } | null = null;
const itemTokens = this.tokenize(item.rawName);
for (const product of products) {
const productTokens = this.tokenize(product.canonicalName ?? product.name);
const overlap = this.tokenOverlap(itemTokens, productTokens);
if (overlap <= 0) continue;
const confidence = Math.min(0.92, 0.5 + overlap * 0.4);
if (!best || confidence > best.confidence) {
best = { product, confidence, overlap };
}
}
let best: { product: ProductLite; confidence: number; overlap: number } | null = null;
const itemTokens = this.tokenize(normalized);
for (const product of products) {
const productTokens = this.tokenize(product.canonicalName ?? product.name);
const overlap = this.tokenOverlap(itemTokens, productTokens);
if (overlap <= 0) continue;
let confidence = Math.min(0.93, 0.48 + overlap * 0.42);
if (this.hasBrandSignal(item.brand, product)) {
confidence += 0.04;
}
if (this.hasWeightSignal(item.weight, product)) {
confidence += 0.03;
}
if (this.hasQualitySignal(itemSignals, product)) {
confidence += 0.03;
}
confidence = Math.min(0.95, confidence);
if (!best || confidence > best.confidence) {
best = { product, confidence, overlap };
}
}
if (best && best.confidence >= 0.66) {
return {
@@ -508,7 +564,7 @@ export class FlyerImportService {
.filter((part) => part.length >= 3);
}
private tokenOverlap(a: string[], b: string[]): number {
private tokenOverlap(a: string[], b: string[]): number {
if (a.length === 0 || b.length === 0) return 0;
const as = new Set(a);
const bs = new Set(b);
@@ -518,8 +574,32 @@ export class FlyerImportService {
}
const union = new Set([...as, ...bs]).size;
if (union === 0) return 0;
return intersection / union;
}
return intersection / union;
}
private hasBrandSignal(brand: string | null, product: ProductLite): boolean {
if (!brand) return false;
const normalizedBrand = normalizeName(brand);
if (!normalizedBrand) return false;
const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`);
return normalizedProduct.includes(normalizedBrand);
}
private hasWeightSignal(weight: string | null, product: ProductLite): boolean {
if (!weight) return false;
const normalizedWeight = normalizeName(weight);
if (!normalizedWeight) return false;
const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`);
return normalizedProduct.includes(normalizedWeight);
}
private hasQualitySignal(signals: ImportedItemSignals, product: ProductLite): boolean {
if (!signals.qualityFlags.includes('eco')) return false;
const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`);
return /\beko\b|\bekolog/i.test(normalizedProduct);
}
private isOfferItem(item: FlyerParseItem, hasCampaignPattern: boolean): boolean {
return (
@@ -745,6 +825,8 @@ export class FlyerImportService {
bundleWeight: string | null;
isBundle: boolean;
bundleItems: Prisma.JsonValue | null;
displayNameDetailed?: string | null;
signals?: Prisma.JsonValue | null;
offerText: string | null;
parseConfidence: number;
parseReasons: Prisma.JsonValue | null;
@@ -759,6 +841,24 @@ export class FlyerImportService {
return value.map((entry) => String(entry));
};
const toSignals = (value: Prisma.JsonValue | null | undefined): ImportedItemSignals | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const toArray = (key: string): string[] => {
const maybeArray = record[key];
if (!Array.isArray(maybeArray)) return [];
return maybeArray.map((entry) => String(entry));
};
return {
originCountries: toArray('originCountries'),
labels: toArray('labels'),
qualityFlags: toArray('qualityFlags'),
variant: typeof record.variant === 'string' ? record.variant : null,
packaging: typeof record.packaging === 'string' ? record.packaging : null,
};
};
const normalizedMatchVia =
item.matchedVia === 'alias' || item.matchedVia === 'exact' || item.matchedVia === 'token'
? item.matchedVia
@@ -784,6 +884,14 @@ export class FlyerImportService {
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)),
displayNameDetailed:
item.displayNameDetailed ??
buildDisplayNameDetailed({
rawName: item.rawName,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)),
}),
signals: toSignals(item.signals),
offerText: item.offerText,
isOffer:
item.price != null
@@ -858,6 +966,8 @@ export class FlyerImportService {
bundleWeight: string | null;
isBundle: boolean;
bundleItems: Prisma.JsonValue | null;
displayNameDetailed?: string | null;
signals?: Prisma.JsonValue | null;
offerText: string | null;
parseConfidence: number;
parseReasons: Prisma.JsonValue | null;
@@ -918,4 +1028,17 @@ export class FlyerImportService {
.slice(0, this.MAX_BUNDLE_ITEMS)
.map((entry) => entry.slice(0, this.MAX_BUNDLE_ITEM_LENGTH));
}
private logImportMetrics(items: FlyerImportItem[]): void {
if (items.length === 0) return;
const noMatchCount = items.filter((item) => item.matchReasons.includes('no_match')).length;
const categoryAssignedCount = items.filter((item) => item.categoryId != null).length;
const noMatchRatio = (noMatchCount / items.length) * 100;
const categoryAssignedRatio = (categoryAssignedCount / items.length) * 100;
this.logger.log(
`Flyer import metrics: no_match=${noMatchCount}/${items.length} (${noMatchRatio.toFixed(1)}%), category_id=${categoryAssignedCount}/${items.length} (${categoryAssignedRatio.toFixed(1)}%)`,
);
}
}
@@ -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;
}