feat(flyer-import): add bundle support and new product fields
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 3m43s
Test Suite / flutter-quality (push) Failing after 1m51s

- Add bundle support with isBundle, bundleWeight, and bundleItems fields
- Add brand, weight, and comparisonUnit fields to FlyerItem model
- Update AI flyer parser to extract bundle information
- Add sanitization for bundle items in FlyerNormalizerService
- Update DTOs and interfaces to include new fields
- Add migration for new database fields
- Update tests to cover bundle item handling
This commit is contained in:
Nils-Johan Gynther
2026-05-21 13:26:50 +02:00
parent 7bbb5a63b5
commit 67c3170067
7 changed files with 239 additions and 72 deletions
@@ -18,18 +18,23 @@ import { TextExtractorService } from './services/text-extractor.service';
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
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 FlyerParseItem = {
rawName: string;
normalizedName: string;
brand: string | null;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
weight: string | null;
bundleWeight: string | null;
isBundle: boolean;
bundleItems: string[];
offerText: string | null;
confidence: number;
reasonCodes: string[];
};
type FlyerParseResponse = {
retailer: 'willys';
@@ -54,7 +59,9 @@ type ProductLite = {
@Injectable()
export class FlyerImportService {
private readonly logger = new Logger(FlyerImportService.name);
private readonly logger = new Logger(FlyerImportService.name);
private readonly MAX_BUNDLE_ITEMS = 20;
private readonly MAX_BUNDLE_ITEM_LENGTH = 120;
constructor(
private readonly prisma: PrismaService,
@@ -105,12 +112,17 @@ export class FlyerImportService {
flyerItemId: null,
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
category: item.category,
categoryId: null,
price,
priceUnit,
comparisonPrice,
comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(item.bundleItems),
offerText: item.offerText,
isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
offerLimitText,
@@ -348,19 +360,24 @@ export class FlyerImportService {
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,
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,
offerText: item.offerText,
parseConfidence: item.parseConfidence,
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,
@@ -592,18 +609,23 @@ export class FlyerImportService {
const normalizedItems = this.normalizer.normalize(aiItems);
// 4. Konvertera till intern FlyerParseItem-format
const items: FlyerParseItem[] = normalizedItems.map((item) => ({
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.categoryHint,
price: item.price,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit,
offerText: item.offerText,
confidence: item.parseConfidence,
reasonCodes: item.parseReasons,
}));
const items: FlyerParseItem[] = normalizedItems.map((item) => ({
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
category: item.categoryHint,
price: item.price,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: item.bundleItems,
offerText: item.offerText,
confidence: item.parseConfidence,
reasonCodes: item.parseReasons,
}));
const warnings: string[] = [];
if (items.length === 0) {
@@ -634,6 +656,7 @@ export class FlyerImportService {
id: number;
rawName: string;
normalizedName: string;
brand: string | null;
categoryHint: string | null;
categoryId: number | null;
categoryRef?: {
@@ -649,6 +672,10 @@ export class FlyerImportService {
priceUnit: string | null;
comparisonPrice: Prisma.Decimal | null;
comparisonUnit: string | null;
weight: string | null;
bundleWeight: string | null;
isBundle: boolean;
bundleItems: Prisma.JsonValue | null;
offerText: string | null;
parseConfidence: number;
parseReasons: Prisma.JsonValue | null;
@@ -677,12 +704,17 @@ export class FlyerImportService {
flyerItemId: item.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
brand: item.brand,
category: categoryPath,
categoryId: item.categoryId,
price: item.price != null ? item.price.toNumber() : offerSignals.price,
priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit,
comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice,
comparisonUnit: this.normalizeUnit(item.comparisonUnit) ?? offerSignals.comparisonUnit,
weight: item.weight,
bundleWeight: item.bundleWeight,
isBundle: item.isBundle,
bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)),
offerText: item.offerText,
isOffer:
item.price != null
@@ -727,6 +759,7 @@ export class FlyerImportService {
id: number;
rawName: string;
normalizedName: string;
brand: string | null;
categoryHint: string | null;
categoryId: number | null;
categoryRef?: {
@@ -742,6 +775,10 @@ export class FlyerImportService {
priceUnit: string | null;
comparisonPrice: Prisma.Decimal | null;
comparisonUnit: string | null;
weight: string | null;
bundleWeight: string | null;
isBundle: boolean;
bundleItems: Prisma.JsonValue | null;
offerText: string | null;
parseConfidence: number;
parseReasons: Prisma.JsonValue | null;
@@ -793,4 +830,13 @@ export class FlyerImportService {
private buildSourceStorageKey(userId: number, weekKey: string): string {
return `flyer/${userId}/${weekKey}/${Date.now()}`;
}
private sanitizeBundleItems(items: string[] | null | undefined): string[] {
if (!Array.isArray(items)) return [];
return items
.map((entry) => String(entry).trim())
.filter(Boolean)
.slice(0, this.MAX_BUNDLE_ITEMS)
.map((entry) => entry.slice(0, this.MAX_BUNDLE_ITEM_LENGTH));
}
}