chore: add flyer import module and configuration
- Added FlyerImportModule to AppModule imports - Created new flyer-import module directory - Added .kilo/ configuration directory
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
# Analys av `plan importer willys.md.txt` i projektkontext
|
||||||
|
|
||||||
|
## Kontext och utgångsläge
|
||||||
|
|
||||||
|
Den analyserade planen finns i `microservice-importer/plan importer willys.md.txt` och beskriver ett förslag för:
|
||||||
|
- premium-gating av AI
|
||||||
|
- PDF-import av Willys-underlag
|
||||||
|
- produktmatchning mot inventory
|
||||||
|
- receptgenerering med AI (Mistral) eller mallar
|
||||||
|
|
||||||
|
Jämfört med faktisk arkitektur i era tre repos:
|
||||||
|
- `recipe-app`: äger användare, premium/AI-flaggor, inventory, recept, auth, adminflöden och databas (Prisma + MariaDB).
|
||||||
|
- `microservice-importer`: stateless import/parsing utan databas; ansvarar för quick-import, markdown-parse och receipt-parse.
|
||||||
|
- `recipe-gitea-runner`: CI-exekvering med labels `backend-node24` och `flutter-3-41`.
|
||||||
|
|
||||||
|
## Sammanfattande bedömning
|
||||||
|
|
||||||
|
Planen är ambitiös men **inte direkt kompatibel** med nuvarande systemdesign. Den blandar ansvar mellan tjänster, använder kodmönster som avviker från era etablerade stackval och återinför funktioner ni redan implementerat i annan form.
|
||||||
|
|
||||||
|
Hög nivå:
|
||||||
|
- Bra: tydlig stegstruktur, fallback-idé (AI -> templates), fokus på robust importflöde.
|
||||||
|
- Problem: arkitekturdrift (DB i importer), felaktig domänmodell för era nuvarande User/Product-fält, Express-exempel i NestJS-miljö, svag validering/säkerhet i flera snippets.
|
||||||
|
|
||||||
|
## Styrkor att återanvända
|
||||||
|
|
||||||
|
- Tydlig domänuppdelning i delsteg: extrahering -> parsing -> matchning -> generering.
|
||||||
|
- Fallback-first-princip vid AI-fel (ligger i linje med era befintliga principer).
|
||||||
|
- Intention att normalisera och strukturera råtext innan beslut i senare led.
|
||||||
|
- Fokus på svenska enheter/uttryck som passar era kvittoflöden.
|
||||||
|
|
||||||
|
## Kritiska gap mot befintlig arkitektur
|
||||||
|
|
||||||
|
1. **Fel placerat ansvar (största gapet)**
|
||||||
|
- Planen föreslår Prisma/DB i importer-flödet (`prisma.user`, `encryptedData.create`, premiumfält m.m.).
|
||||||
|
- Er importer är dokumenterat stateless och DB-lös.
|
||||||
|
- Rekommendation: all user/premium/inventory/recipe persistence ska ligga i `recipe-app` backend, inte i `microservice-importer`.
|
||||||
|
|
||||||
|
2. **Premium-modell avviker från faktisk datamodell**
|
||||||
|
- Planen använder `is_premium` och `premium_expiry_date`.
|
||||||
|
- I `recipe-app` används `isPremium` + `aiEngineEnabled` redan, med admin-styrning och JWT-scope.
|
||||||
|
- Rekommendation: återanvänd befintliga fält/guards; undvik parallel premiummodell.
|
||||||
|
|
||||||
|
3. **Framework mismatch (Express vs NestJS)**
|
||||||
|
- Planen innehåller Express-routerexempel medan repos är NestJS-moduler/controllers/services.
|
||||||
|
- Rekommendation: all ny implementation bör följa NestJS module/service/controller + DTO + class-validator.
|
||||||
|
|
||||||
|
4. **Datamodell-krockar**
|
||||||
|
- Planen antar tabeller/fält som inte finns i nuvarande schema (`encryptedData`, snake_case-kolumner osv).
|
||||||
|
- Rekommendation: mappa mot faktiska modeller (`User`, `Product`, `InventoryItem`, `Recipe*`) eller skapa tydlig migrationplan med namngiven adapter.
|
||||||
|
|
||||||
|
5. **Överlapp med befintlig funktionalitet**
|
||||||
|
- Ni har redan importerad kvittoparsning med regelmotor + AI-fallback och premium/ai-scope.
|
||||||
|
- Rekommendation: undvik ”nytt parallellt flöde”; bygg som utökning av befintliga receipt/import pipelines.
|
||||||
|
|
||||||
|
## Tekniska förbättringar av själva planen
|
||||||
|
|
||||||
|
### A. Arkitekturförbättringar (måste prioriteras)
|
||||||
|
|
||||||
|
- **Inför tydligt kontrakt mellan tjänsterna**
|
||||||
|
- `microservice-importer`: parse/normalize only (ingen userstate).
|
||||||
|
- `recipe-app`: auth, premium-gating, produktmatchning, persistence.
|
||||||
|
- **Skapa explicit API-kontrakt för kampanjblad/Willys**
|
||||||
|
- ny endpoint i importer, exempel: `POST /api/flyer/parse`.
|
||||||
|
- svar med strikt schema: produkter, erbjudandeflaggor, normaliserade mått/enheter, confidence.
|
||||||
|
- **Beslut i recipe-app**
|
||||||
|
- besluta AI/template på basis av `isPremium && aiEngineEnabled`.
|
||||||
|
- lagra endast i recipe-app DB.
|
||||||
|
|
||||||
|
### B. Datakontrakt och validering
|
||||||
|
|
||||||
|
- Ersätt `any` med typed DTO/interfaces i båda repos.
|
||||||
|
- Lägg till strikt validering (zod eller class-validator) för:
|
||||||
|
- parsed flyer rows
|
||||||
|
- matched product payload
|
||||||
|
- generated recipe payload
|
||||||
|
- Definiera versionerat kontrakt (`v1`) så importer och app kan deployas oberoende.
|
||||||
|
|
||||||
|
### C. Parser-kvalitet och robusthet (Willys-specifikt)
|
||||||
|
|
||||||
|
- Nuvarande regex-idé är för skör för verkliga PDF-varianter.
|
||||||
|
- Förbättra med pipeline:
|
||||||
|
1) textblock-normalisering
|
||||||
|
2) radklassificering (kategori, produkt, prisrad, metadata)
|
||||||
|
3) enhetsnormalisering (`förp`, `st`, `kg`)
|
||||||
|
4) probabilistisk matchscore per fält
|
||||||
|
- Lägg in rule priority + fallback AI bara för osäkra rader (som ni redan gör i receipt flow).
|
||||||
|
|
||||||
|
### D. Matchning mot inventory
|
||||||
|
|
||||||
|
- Planens fuzzy-match på 0.6 riskerar falska positiva.
|
||||||
|
- Förslag:
|
||||||
|
- kombinera alias > exact-normalized > token-similarity > levenshtein.
|
||||||
|
- category-guardrails (finns redan i receipt-flöde, återanvänd).
|
||||||
|
- trösklar per kategori (mejeri/kött behöver striktare gränser än exotiska varor).
|
||||||
|
- returnera `matchedVia`, `confidence`, `reasonCodes` för UI-debugg och lärande.
|
||||||
|
|
||||||
|
### E. AI-generering
|
||||||
|
|
||||||
|
- Planen gör fri JSON-parsning från modelltext; hög risk för parse-fel.
|
||||||
|
- Förslag:
|
||||||
|
- använd strikt output schema + reparationssteg vid JSON-avvikelse.
|
||||||
|
- lägg budget/timeouts/retries per request.
|
||||||
|
- prompta på begränsad produktmängd (top-N relevanta) för lägre kostnad/latens.
|
||||||
|
- logga token-användning och feltyper för cost observability.
|
||||||
|
|
||||||
|
### F. Säkerhet
|
||||||
|
|
||||||
|
- Behåll premium-kontroll i backend (ej klient).
|
||||||
|
- Lägg rate limiting även på nya flyer-endpoints.
|
||||||
|
- Undvik filsystemberoende (`multer dest + fs.unlinkSync`) om möjligt; använd bufferbaserad pipeline.
|
||||||
|
- Lägg explicit content-type + storleksgräns + filsignature-validering.
|
||||||
|
|
||||||
|
### G. Drift och CI (recipe-gitea-runner)
|
||||||
|
|
||||||
|
- Lägg nya testjobb med labels ni redan har:
|
||||||
|
- `backend-node24`: contract tests mellan app/importer.
|
||||||
|
- `backend-node24`: parser regression suite med fixtures från riktiga Willys-underlag.
|
||||||
|
- Lägg minimikrav i CI:
|
||||||
|
- typecheck
|
||||||
|
- unit tests parser + matcher
|
||||||
|
- contract tests importer <-> recipe-app
|
||||||
|
- build
|
||||||
|
- Publicera artifact med parser-rapport (precision/recall på fixture-set) för varje PR.
|
||||||
|
|
||||||
|
## Konkreta optimeringar per repo
|
||||||
|
|
||||||
|
### 1) `microservice-importer`
|
||||||
|
|
||||||
|
- Lägg till separat modul `flyer-parsing` istället för att återanvända receipt rakt av.
|
||||||
|
- Returnera endast normaliserad och validerad struktur, aldrig användarspecifika beslut.
|
||||||
|
- Återanvänd befintlig robusthet:
|
||||||
|
- fallback parsing
|
||||||
|
- timeout/retry-mönster
|
||||||
|
- global exception shape.
|
||||||
|
- Bygg fixture-driven tester för Willys-format (varianter med OCR-brus, multipack, kampanjtext).
|
||||||
|
|
||||||
|
### 2) `recipe-app`
|
||||||
|
|
||||||
|
- Implementera orkestreringstjänst för flyerimport:
|
||||||
|
- anropa importer
|
||||||
|
- matcha mot user-scopade produkter
|
||||||
|
- premium-gata AI-recept (isPremium + aiEngineEnabled)
|
||||||
|
- spara recept via befintliga modeller
|
||||||
|
- Exponera adminfeature toggle för ”flyer-recipe-generation” (separation från övrig AI om ni vill kontrollera rollout).
|
||||||
|
- Lägg telemetri per steg: parse time, match confidence distribution, AI fallback rate.
|
||||||
|
|
||||||
|
### 3) `recipe-gitea-runner`
|
||||||
|
|
||||||
|
- Säkra att workflow i `recipe-app` inkluderar integrationstest mot importer (mockad eller ephemeral service).
|
||||||
|
- Lägg nattlig regressionkörning för parser-fixtures för att fånga drift i regex/regler.
|
||||||
|
- Behåll labels som idag; komplettera med tydligare jobbseparation i CI (quick PR vs full push).
|
||||||
|
|
||||||
|
## Prioriterad implementeringsplan (reviderad)
|
||||||
|
|
||||||
|
1. **Målbild/ansvar (P0)**
|
||||||
|
- Fastställ och dokumentera kontrakt: importer parser-only, app stateful orchestration.
|
||||||
|
|
||||||
|
2. **Kontrakt + DTO (P0)**
|
||||||
|
- Definiera `FlyerParseResponse v1` och valideringsregler.
|
||||||
|
|
||||||
|
3. **Importer-modul (P1)**
|
||||||
|
- Implementera Willys/flyer parser i `microservice-importer` med tester + fixtures.
|
||||||
|
|
||||||
|
4. **Recipe-app orkestrering (P1)**
|
||||||
|
- Ny service i backend som mappar parse-resultat till matchning + recipe generation.
|
||||||
|
|
||||||
|
5. **Premium-gating harmonisering (P1)**
|
||||||
|
- Använd enbart `isPremium` + `aiEngineEnabled`; ta bort/undvik expiry-logik om den inte behövs produktmässigt.
|
||||||
|
|
||||||
|
6. **Observability + säkerhet (P1)**
|
||||||
|
- Metrics, structured logs, rate limits, upload guards.
|
||||||
|
|
||||||
|
7. **CI-utbyggnad (P2)**
|
||||||
|
- Contract tests + parser regression suite i Gitea workflows.
|
||||||
|
|
||||||
|
## Risker om planen implementeras oförändrad
|
||||||
|
|
||||||
|
- Arkitekturspret: dubbla sanningskällor för premium och recipes.
|
||||||
|
- Ökad driftkomplexitet: importer blir stateful och svårare att skala/deploya.
|
||||||
|
- Regressionsrisk: ny kod duplicerar befintlig receipt/importlogik.
|
||||||
|
- Säkerhets- och datakvalitetsrisk: svag typing/validering + osäker JSON-parsning från AI-svar.
|
||||||
|
|
||||||
|
## Rekommenderad riktning (kort)
|
||||||
|
|
||||||
|
Använd planen som **idékatalog**, inte som direkt implementation. Behåll er nuvarande ansvarsfördelning mellan repos, bygg Willys-stödet som en ny parserdomän i `microservice-importer`, och låt `recipe-app` fortsätta vara enda platsen för användarlogik, premiumbeslut och datalagring. Detta ger lägst risk och bäst kompatibilitet med er nuvarande kodbas, driftmodell och CI-upplägg.
|
||||||
@@ -18,6 +18,7 @@ import { CategoriesModule } from './categories/categories.module';
|
|||||||
import { AiModule } from './ai/ai.module';
|
import { AiModule } from './ai/ai.module';
|
||||||
import { RealtimeModule } from './realtime/realtime.module';
|
import { RealtimeModule } from './realtime/realtime.module';
|
||||||
import { HelpTextsModule } from './help-texts/help-texts.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 { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
import { RolesGuard } from './auth/roles.guard';
|
import { RolesGuard } from './auth/roles.guard';
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ import { RolesGuard } from './auth/roles.guard';
|
|||||||
AiModule,
|
AiModule,
|
||||||
RealtimeModule,
|
RealtimeModule,
|
||||||
HelpTextsModule,
|
HelpTextsModule,
|
||||||
|
FlyerImportModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user