chore: add flyer import module and configuration
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 3m57s
Test Suite / flutter-quality (push) Failing after 1m19s

- Added FlyerImportModule to AppModule imports
- Created new flyer-import module directory
- Added .kilo/ configuration directory
This commit is contained in:
Nils-Johan Gynther
2026-05-18 18:40:25 +02:00
parent e6961fc593
commit f42132ed5b
6 changed files with 521 additions and 1 deletions
+3 -1
View File
@@ -18,6 +18,7 @@ import { CategoriesModule } from './categories/categories.module';
import { AiModule } from './ai/ai.module';
import { RealtimeModule } from './realtime/realtime.module';
import { HelpTextsModule } from './help-texts/help-texts.module';
import { FlyerImportModule } from './flyer-import/flyer-import.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
@@ -48,6 +49,7 @@ import { RolesGuard } from './auth/roles.guard';
AiModule,
RealtimeModule,
HelpTextsModule,
FlyerImportModule,
],
providers: [
{
@@ -64,4 +66,4 @@ import { RolesGuard } from './auth/roles.guard';
},
],
})
export class AppModule {}
export class AppModule {}
@@ -0,0 +1,26 @@
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
export type FlyerImportItem = {
rawName: string;
normalizedName: string;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
offerText: string | null;
parseConfidence: number;
parseReasons: string[];
matchedProductId: number | null;
matchedProductName: string | null;
matchedVia: FlyerImportMatchVia;
matchConfidence: number;
matchReasons: string[];
};
export type FlyerImportResponse = {
retailer: 'willys';
parserVersion: 'v1';
items: FlyerImportItem[];
warnings: string[];
};
@@ -0,0 +1,59 @@
import {
BadRequestException,
Controller,
HttpCode,
Post,
Request,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { FlyerImportResponse } from './dto/flyer-import.response';
import { FlyerImportService } from './flyer-import.service';
const ALLOWED_MIMES = [
'application/pdf',
'application/octet-stream',
'text/plain',
];
@Controller('flyer-import')
export class FlyerImportController {
constructor(private readonly flyerImportService: FlyerImportService) {}
@Post('parse')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15 * 1024 * 1024 },
}),
)
async parseFlyer(
@UploadedFile() file?: Express.Multer.File,
@Request() req?: any,
): Promise<FlyerImportResponse> {
if (!file?.buffer) {
throw new BadRequestException('Ingen fil skickades med.');
}
if (!ALLOWED_MIMES.includes(file.mimetype)) {
throw new BadRequestException('Otillåten filtyp. Använd PDF eller textfil.');
}
const userId =
typeof req?.user?.id === 'number'
? req.user.id
: typeof req?.user?.userId === 'number'
? req.user.userId
: undefined;
if (!userId) {
throw new BadRequestException('Kunde inte identifiera användaren.');
}
return this.flyerImportService.parseAndMatch(file, userId);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { FlyerImportController } from './flyer-import.controller';
import { FlyerImportService } from './flyer-import.service';
@Module({
imports: [PrismaModule],
controllers: [FlyerImportController],
providers: [FlyerImportService],
})
export class FlyerImportModule {}
@@ -0,0 +1,237 @@
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name';
import {
FlyerImportItem,
FlyerImportMatchVia,
FlyerImportResponse,
} from './dto/flyer-import.response';
const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
type FlyerParseItem = {
rawName: string;
normalizedName: string;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
offerText: string | null;
confidence: number;
reasonCodes: string[];
};
type FlyerParseResponse = {
retailer: 'willys';
parserVersion: 'v1';
items: FlyerParseItem[];
warnings: string[];
};
type ProductLite = {
id: number;
name: string;
canonicalName: string | null;
};
@Injectable()
export class FlyerImportService {
private readonly logger = new Logger(FlyerImportService.name);
constructor(private readonly prisma: PrismaService) {}
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
const parsed = await this.parseViaImporter(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 aliasToProduct = new Map<string, number>();
for (const alias of aliases) {
const normalized = normalizeName(alias.receiptName);
if (!normalized) continue;
if (!aliasToProduct.has(normalized)) {
aliasToProduct.set(normalized, alias.productId);
}
}
const productById = new Map<number, ProductLite>();
for (const product of products) {
productById.set(product.id, product);
}
const items: FlyerImportItem[] = parsed.items.map((item) => {
const match = this.matchItem(item, products, aliasToProduct, productById);
return {
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.category,
price: item.price,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit,
offerText: item.offerText,
parseConfidence: item.confidence,
parseReasons: item.reasonCodes,
matchedProductId: match.product?.id ?? null,
matchedProductName: match.product?.name ?? null,
matchedVia: match.via,
matchConfidence: match.confidence,
matchReasons: match.reasons,
};
});
return {
retailer: parsed.retailer,
parserVersion: parsed.parserVersion,
items,
warnings: parsed.warnings,
};
}
private matchItem(
item: FlyerParseItem,
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 aliasedProductId = aliasToProduct.get(normalized);
if (aliasedProductId) {
const product = productById.get(aliasedProductId) ?? null;
return {
product,
via: product ? 'alias' : 'none',
confidence: product ? 1 : 0,
reasons: product ? ['alias_exact'] : ['alias_points_to_missing_product'],
};
}
for (const product of products) {
const pn = normalizeName(product.name);
const cn = product.canonicalName ? normalizeName(product.canonicalName) : null;
if (normalized === pn || (cn && normalized === cn)) {
return {
product,
via: 'exact',
confidence: 0.96,
reasons: ['normalized_exact'],
};
}
}
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 };
}
}
if (best && best.confidence >= 0.66) {
return {
product: best.product,
via: 'token',
confidence: best.confidence,
reasons: [`token_overlap:${best.overlap.toFixed(2)}`],
};
}
return {
product: null,
via: 'none',
confidence: 0,
reasons: ['no_match'],
};
}
private tokenize(value: string): string[] {
return value
.toLowerCase()
.split(/[^a-z0-9åäö]+/)
.map((part) => part.trim())
.filter((part) => part.length >= 3);
}
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);
let intersection = 0;
for (const token of as) {
if (bs.has(token)) intersection++;
}
const union = new Set([...as, ...bs]).size;
if (union === 0) return 0;
return intersection / union;
}
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
const form = new FormData();
form.append(
'file',
new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }),
file.originalname,
);
form.append('retailer', 'willys');
let response: Response;
try {
response = await fetch(`${IMPORTER_SERVICE_URL}/api/flyer/parse`, {
method: 'POST',
body: form,
});
} catch (err) {
this.logger.error(`Kunde inte nå importer-api för flyer-parse: ${String(err)}`);
throw new ServiceUnavailableException('Importer-tjänsten är inte tillgänglig just nu.');
}
if (!response.ok) {
let message = `Importer-tjänsten svarade ${response.status}`;
try {
const body = (await response.json()) as { message?: string };
if (typeof body.message === 'string' && body.message.trim()) {
message = body.message;
}
} catch {
// ignore parse issues
}
if (response.status >= 400 && response.status < 500) {
throw new BadRequestException(message);
}
throw new ServiceUnavailableException(message);
}
return response.json() as Promise<FlyerParseResponse>;
}
}