chore(ci): update project documentation and flyer import features
Update project documentation with recent CI improvements and flyer import enhancements: - Add ESLint configuration for backend and Dart lints for Flutter - Document Prisma query logging via PRISMA_LOG_QUERIES environment variable - Update NEXT_STEPS.md, README.md, and TEKNISK_BESKRIVNING.md with new features - Add isOffer, offerLimitText, comparisonPrice, comparisonUnit, parseConfidence, and parseReasons fields to FlyerImportItem - Update FlyerImportResponse type to include new fields - Extend file picker to support image formats (png, jpg, jpeg, webp) - Add offer badge display and price formatting in Flutter UI - Implement PDF preview functionality for flyer import
This commit is contained in:
@@ -1,28 +1,30 @@
|
||||
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
||||
|
||||
export type FlyerImportItem = {
|
||||
flyerItemId: number | null;
|
||||
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 = {
|
||||
sessionId: number | null;
|
||||
retailer: 'willys';
|
||||
parserVersion: 'v1';
|
||||
items: FlyerImportItem[];
|
||||
warnings: string[];
|
||||
};
|
||||
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
||||
|
||||
export type FlyerImportItem = {
|
||||
flyerItemId: number | null;
|
||||
rawName: string;
|
||||
normalizedName: string;
|
||||
category: string | null;
|
||||
price: number | null;
|
||||
priceUnit: string | null;
|
||||
comparisonPrice: number | null;
|
||||
comparisonUnit: string | null;
|
||||
offerText: string | null;
|
||||
isOffer: boolean;
|
||||
offerLimitText: string | null;
|
||||
parseConfidence: number;
|
||||
parseReasons: string[];
|
||||
matchedProductId: number | null;
|
||||
matchedProductName: string | null;
|
||||
matchedVia: FlyerImportMatchVia;
|
||||
matchConfidence: number;
|
||||
matchReasons: string[];
|
||||
};
|
||||
|
||||
export type FlyerImportResponse = {
|
||||
sessionId: number | null;
|
||||
retailer: 'willys';
|
||||
parserVersion: 'v1';
|
||||
items: FlyerImportItem[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
@@ -1,299 +1,325 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
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 {
|
||||
flyerItemId: null,
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
|
||||
|
||||
return {
|
||||
sessionId: persistedItems.sessionId,
|
||||
retailer: parsed.retailer,
|
||||
parserVersion: parsed.parserVersion,
|
||||
items: persistedItems.items,
|
||||
warnings: parsed.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async persistSessionWithItems(
|
||||
userId: number,
|
||||
retailer: 'willys',
|
||||
items: FlyerImportItem[],
|
||||
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
|
||||
const weekKey = this.toWeekKey(new Date());
|
||||
|
||||
const session = await this.prisma.flyerSession.create({
|
||||
data: {
|
||||
userId,
|
||||
retailer,
|
||||
weekKey,
|
||||
status: 'draft',
|
||||
},
|
||||
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,
|
||||
categoryHint: item.category,
|
||||
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,
|
||||
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 },
|
||||
});
|
||||
|
||||
savedItems.push({ ...item, flyerItemId: created.id });
|
||||
}
|
||||
|
||||
return { sessionId: session.id, items: savedItems };
|
||||
}
|
||||
|
||||
private toWeekKey(date: Date): string {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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>;
|
||||
}
|
||||
}
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
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);
|
||||
const offerLimitText = this.extractOfferLimitText(item.offerText);
|
||||
return {
|
||||
flyerItemId: null,
|
||||
rawName: item.rawName,
|
||||
normalizedName: item.normalizedName,
|
||||
category: item.category,
|
||||
price: item.price,
|
||||
priceUnit: item.priceUnit,
|
||||
comparisonPrice: item.comparisonPrice,
|
||||
comparisonUnit: item.comparisonUnit,
|
||||
offerText: item.offerText,
|
||||
isOffer: this.isOfferItem(item),
|
||||
offerLimitText,
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
|
||||
|
||||
return {
|
||||
sessionId: persistedItems.sessionId,
|
||||
retailer: parsed.retailer,
|
||||
parserVersion: parsed.parserVersion,
|
||||
items: persistedItems.items,
|
||||
warnings: parsed.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
private async persistSessionWithItems(
|
||||
userId: number,
|
||||
retailer: 'willys',
|
||||
items: FlyerImportItem[],
|
||||
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
|
||||
const weekKey = this.toWeekKey(new Date());
|
||||
|
||||
const session = await this.prisma.flyerSession.create({
|
||||
data: {
|
||||
userId,
|
||||
retailer,
|
||||
weekKey,
|
||||
status: 'draft',
|
||||
},
|
||||
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,
|
||||
categoryHint: item.category,
|
||||
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,
|
||||
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 },
|
||||
});
|
||||
|
||||
savedItems.push({ ...item, flyerItemId: created.id });
|
||||
}
|
||||
|
||||
return { sessionId: session.id, items: savedItems };
|
||||
}
|
||||
|
||||
private toWeekKey(date: Date): string {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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 isOfferItem(item: FlyerParseItem): boolean {
|
||||
return item.price != null || item.comparisonPrice != null || !!item.offerText?.trim();
|
||||
}
|
||||
|
||||
private extractOfferLimitText(offerText: string | null): string | null {
|
||||
if (!offerText) return null;
|
||||
|
||||
const normalized = offerText.replace(/\s+/g, ' ' ).trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
const limitMatch = normalized.match(/(?:max|högst)\s+[^,.;]+(?:hushåll|kund)?/i);
|
||||
if (limitMatch?.[0]) {
|
||||
return limitMatch[0].trim();
|
||||
}
|
||||
|
||||
const householdMatch = normalized.match(/[^,.;]*(?:hushåll|kund)[^,.;]*/i);
|
||||
if (householdMatch?.[0]) {
|
||||
return householdMatch[0].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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