diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index 009888c8..0e99e1bf 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -28,12 +28,20 @@ type FlyerParseItem = { reasonCodes: string[]; }; -type FlyerParseResponse = { - retailer: 'willys'; - parserVersion: 'v1'; - items: FlyerParseItem[]; - warnings: string[]; -}; +type FlyerParseResponse = { + retailer: 'willys'; + parserVersion: 'v1'; + items: FlyerParseItem[]; + warnings: string[]; +}; + +type ExtractedOfferSignals = { + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + hasCampaignPattern: boolean; +}; type ProductLite = { id: number; @@ -77,23 +85,28 @@ export class FlyerImportService { 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, + const items: FlyerImportItem[] = parsed.items.map((item) => { + const match = this.matchItem(item, products, aliasToProduct, productById); + const signals = this.extractOfferSignals(item.offerText); + const price = item.price ?? signals.price; + const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit; + const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice; + const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit; + const offerLimitText = this.extractOfferLimitText(item.offerText); + return { + flyerItemId: null, + rawName: item.rawName, + normalizedName: item.normalizedName, + category: item.category, + price, + priceUnit, + comparisonPrice, + comparisonUnit, + offerText: item.offerText, + isOffer: this.isOfferItem(item, signals.hasCampaignPattern), + offerLimitText, + parseConfidence: item.confidence, + parseReasons: item.reasonCodes, matchedProductId: match.product?.id ?? null, matchedProductName: match.product?.name ?? null, matchedVia: match.via, @@ -260,28 +273,103 @@ export class FlyerImportService { return intersection / union; } - private isOfferItem(item: FlyerParseItem): boolean { - return item.price != null || item.comparisonPrice != null || !!item.offerText?.trim(); - } + private isOfferItem(item: FlyerParseItem, hasCampaignPattern: boolean): boolean { + return ( + item.price != null + || item.comparisonPrice != null + || !!item.offerText?.trim() + || hasCampaignPattern + ); + } 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; - } + const normalized = offerText.replace(/\s+/g, ' ').trim(); + if (!normalized) return null; + + const limitMatch = normalized.match( + /(?:max|högst|begränsat\s+antal)\s+[^,.;]*(?:hushÃ¥ll|kund|köp)?(?:\s*\/\s*(?:hushÃ¥ll|kund))?/i, + ); + if (limitMatch?.[0]) { + return limitMatch[0].trim(); + } + + const perCustomerMatch = normalized.match( + /[^,.;]*(?:per\s+(?:hushÃ¥ll|kund)|\/\s*(?:hushÃ¥ll|kund))[^,.;]*/i, + ); + if (perCustomerMatch?.[0]) { + return perCustomerMatch[0].trim(); + } + + const householdMatch = normalized.match(/[^,.;]*(?:hushÃ¥ll|kund)[^,.;]*/i); + if (householdMatch?.[0]) { + return householdMatch[0].trim(); + } + + return null; + } + + private extractOfferSignals(offerText: string | null): ExtractedOfferSignals { + const empty: ExtractedOfferSignals = { + price: null, + priceUnit: null, + comparisonPrice: null, + comparisonUnit: null, + hasCampaignPattern: false, + }; + + if (!offerText?.trim()) return empty; + + const normalized = offerText.replace(/\s+/g, ' ').trim().toLowerCase(); + const campaignPattern = /(\b\d+\s*för\s*\d+[,.:]?\d*\b)|(ta\s*\d+\s*betala\s*för\s*\d+)/i; + const priceWithUnit = normalized.match(/(\d{1,3}[:.,]\d{2}|\d{1,3})\s*(?:kr)?\s*\/?\s*(kg|hg|g|l|dl|cl|ml|st|styck|pkt|förp|fp)/i); + const priceOnly = normalized.match(/(\d{1,3}[:.,]\d{2}|\d{1,3})\s*kr\b/i); + const comparison = normalized.match( + /(?:jfr\s*pris|jamforpris|jämförpris|jfr)\s*[:]?\s*(\d{1,3}[:.,]\d{2}|\d{1,3})\s*(?:kr)?\s*\/?\s*(kg|hg|g|l|dl|cl|ml|st|styck|pkt|förp|fp)/i, + ); + + const signals: ExtractedOfferSignals = { + ...empty, + hasCampaignPattern: campaignPattern.test(normalized), + }; + + if (priceWithUnit) { + signals.price = this.parseSwedishPrice(priceWithUnit[1]); + signals.priceUnit = this.normalizeUnit(priceWithUnit[2]); + } else if (priceOnly) { + signals.price = this.parseSwedishPrice(priceOnly[1]); + } + + if (comparison) { + signals.comparisonPrice = this.parseSwedishPrice(comparison[1]); + signals.comparisonUnit = this.normalizeUnit(comparison[2]); + } + + return signals; + } + + private parseSwedishPrice(value: string | null | undefined): number | null { + if (!value) return null; + const normalized = value.trim().replace(':', '.').replace(',', '.'); + const parsed = Number.parseFloat(normalized); + if (!Number.isFinite(parsed)) return null; + return parsed; + } + + private normalizeUnit(unit: string | null | undefined): string | null { + if (!unit) return null; + const cleaned = unit.trim().toLowerCase().replace(/\./g, ''); + if (!cleaned) return null; + + if (cleaned === 'styck') return 'st'; + if (cleaned === 'fp' || cleaned === 'forp' || cleaned === 'förp' || cleaned === 'pkt') { + return 'pkt'; + } + + const allowed = new Set(['kg', 'hg', 'g', 'l', 'dl', 'cl', 'ml', 'st', 'pkt']); + return allowed.has(cleaned) ? cleaned : cleaned; + } private async parseViaImporter(file: Express.Multer.File): Promise { const form = new FormData(); diff --git a/flutter/lib/features/import/presentation/flyer_import_tab.dart b/flutter/lib/features/import/presentation/flyer_import_tab.dart index 60110a88..e2cdb8b5 100644 --- a/flutter/lib/features/import/presentation/flyer_import_tab.dart +++ b/flutter/lib/features/import/presentation/flyer_import_tab.dart @@ -116,6 +116,17 @@ class _FlyerImportTabState extends ConsumerState { return '$raw kr$unitPart'; } + String _removeLimitTextFromOfferText(String offerText, String? limitText) { + final trimmedOffer = offerText.trim(); + final trimmedLimit = limitText?.trim() ?? ''; + if (trimmedOffer.isEmpty || trimmedLimit.isEmpty) return trimmedOffer; + + final escaped = RegExp.escape(trimmedLimit); + final withoutLimit = trimmedOffer.replaceAll(RegExp(escaped, caseSensitive: false), '').trim(); + final withoutLeadingPunctuation = withoutLimit.replaceAll(RegExp(r'^[,.;:\s-]+'), '').trim(); + return withoutLeadingPunctuation.replaceAll(RegExp(r'[,.;:\s-]+$'), '').trim(); + } + Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) { final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null; if (!hasOffer) return const SizedBox.shrink(); @@ -157,7 +168,7 @@ class _FlyerImportTabState extends ConsumerState { isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined, color: theme.colorScheme.primary, ), - title: const Text('Flyerförhandsvisning'), + title: const Text('Flyerförhandsvisning'), subtitle: Text(file?.name ?? ''), trailing: isImage ? null @@ -170,7 +181,7 @@ class _FlyerImportTabState extends ConsumerState { if (!context.mounted || opened) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'), + content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'), ), ); }, @@ -206,14 +217,14 @@ class _FlyerImportTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Ladda upp flyer, granska erbjudanden och planera inköp med ett klick.', + 'Ladda upp flyer, granska erbjudanden och planera inköp med ett klick.', style: theme.textTheme.bodyMedium, ), const SizedBox(height: 16), OutlinedButton.icon( onPressed: _isLoading ? null : _pickFile, icon: const Icon(Icons.attach_file), - label: Text(_pickedFile?.name ?? 'Välj flyerfil'), + label: Text(_pickedFile?.name ?? 'Välj flyerfil'), ), const SizedBox(height: 12), FilledButton.icon( @@ -242,7 +253,7 @@ class _FlyerImportTabState extends ConsumerState { } }); }, - child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), + child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'), ), ], ), @@ -253,6 +264,9 @@ class _FlyerImportTabState extends ConsumerState { final priceText = _formatPrice(item.price, item.priceUnit); final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit); final limitText = item.offerLimitText?.trim(); + final sanitizedOfferText = item.offerText == null + ? '' + : _removeLimitTextFromOfferText(item.offerText!, limitText); return CheckboxListTile( value: _selected[index] ?? false, @@ -267,9 +281,16 @@ class _FlyerImportTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (priceText.isNotEmpty) Text('Pris: $priceText'), - if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), - if (limitText != null && limitText.isNotEmpty) Text('Begränsning: $limitText'), - if ((item.offerText?.trim().isNotEmpty ?? false)) Text(item.offerText!.trim()), + if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), + if (limitText != null && limitText.isNotEmpty) + Text( + 'Begränsning: $limitText', + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.orange.shade900, + fontWeight: FontWeight.w600, + ), + ), + if (sanitizedOfferText.isNotEmpty) Text(sanitizedOfferText), if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'), ], ), @@ -296,5 +317,4 @@ class _FlyerImportTabState extends ConsumerState { ), ); } -} - +} diff --git a/0001-0008_WIL_V21_ED1pdf.pdf b/willys_reklamblad.pdf similarity index 100% rename from 0001-0008_WIL_V21_ED1pdf.pdf rename to willys_reklamblad.pdf