feat(flyer-import): add bundle support and new product fields
- 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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user