chore: update flyer import features and resources
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 2m43s
Test Suite / flutter-quality (push) Failing after 1m33s

- Remove outdated Willys flyer PDF (0001-0008_WIL_V21_ED1pdf.pdf)
- Add new Willys flyer PDF (willys_reklamblad.pdf)
- Improve offer detection logic in backend flyer-import service
- Add offer limit text extraction and sanitization in Flutter UI
- Fix Swedish character encoding issues in UI text
This commit is contained in:
Nils-Johan Gynther
2026-05-18 23:40:05 +02:00
parent c720f611ea
commit 0ce1db5471
3 changed files with 159 additions and 51 deletions
@@ -35,6 +35,14 @@ type FlyerParseResponse = {
warnings: string[]; warnings: string[];
}; };
type ExtractedOfferSignals = {
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
hasCampaignPattern: boolean;
};
type ProductLite = { type ProductLite = {
id: number; id: number;
name: string; name: string;
@@ -79,18 +87,23 @@ export class FlyerImportService {
const items: FlyerImportItem[] = parsed.items.map((item) => { const items: FlyerImportItem[] = parsed.items.map((item) => {
const match = this.matchItem(item, products, aliasToProduct, productById); 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); const offerLimitText = this.extractOfferLimitText(item.offerText);
return { return {
flyerItemId: null, flyerItemId: null,
rawName: item.rawName, rawName: item.rawName,
normalizedName: item.normalizedName, normalizedName: item.normalizedName,
category: item.category, category: item.category,
price: item.price, price,
priceUnit: item.priceUnit, priceUnit,
comparisonPrice: item.comparisonPrice, comparisonPrice,
comparisonUnit: item.comparisonUnit, comparisonUnit,
offerText: item.offerText, offerText: item.offerText,
isOffer: this.isOfferItem(item), isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
offerLimitText, offerLimitText,
parseConfidence: item.confidence, parseConfidence: item.confidence,
parseReasons: item.reasonCodes, parseReasons: item.reasonCodes,
@@ -260,8 +273,13 @@ export class FlyerImportService {
return intersection / union; return intersection / union;
} }
private isOfferItem(item: FlyerParseItem): boolean { private isOfferItem(item: FlyerParseItem, hasCampaignPattern: boolean): boolean {
return item.price != null || item.comparisonPrice != null || !!item.offerText?.trim(); return (
item.price != null
|| item.comparisonPrice != null
|| !!item.offerText?.trim()
|| hasCampaignPattern
);
} }
private extractOfferLimitText(offerText: string | null): string | null { private extractOfferLimitText(offerText: string | null): string | null {
@@ -270,11 +288,20 @@ export class FlyerImportService {
const normalized = offerText.replace(/\s+/g, ' ').trim(); const normalized = offerText.replace(/\s+/g, ' ').trim();
if (!normalized) return null; if (!normalized) return null;
const limitMatch = normalized.match(/(?:max|högst)\s+[^,.;]+(?:hushåll|kund)?/i); 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]) { if (limitMatch?.[0]) {
return limitMatch[0].trim(); 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); const householdMatch = normalized.match(/[^,.;]*(?:hushåll|kund)[^,.;]*/i);
if (householdMatch?.[0]) { if (householdMatch?.[0]) {
return householdMatch[0].trim(); return householdMatch[0].trim();
@@ -283,6 +310,67 @@ export class FlyerImportService {
return null; 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<FlyerParseResponse> { private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
const form = new FormData(); const form = new FormData();
form.append( form.append(
@@ -116,6 +116,17 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
return '$raw kr$unitPart'; 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) { Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) {
final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null; final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null;
if (!hasOffer) return const SizedBox.shrink(); if (!hasOffer) return const SizedBox.shrink();
@@ -157,7 +168,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined, isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
title: const Text('Flyerförhandsvisning'), title: const Text('Flyerförhandsvisning'),
subtitle: Text(file?.name ?? ''), subtitle: Text(file?.name ?? ''),
trailing: isImage trailing: isImage
? null ? null
@@ -170,7 +181,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
if (!context.mounted || opened) return; if (!context.mounted || opened) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( 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<FlyerImportTab> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( 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, style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _isLoading ? null : _pickFile, onPressed: _isLoading ? null : _pickFile,
icon: const Icon(Icons.attach_file), icon: const Icon(Icons.attach_file),
label: Text(_pickedFile?.name ?? 'Välj flyerfil'), label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FilledButton.icon( FilledButton.icon(
@@ -242,7 +253,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
} }
}); });
}, },
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<FlyerImportTab> {
final priceText = _formatPrice(item.price, item.priceUnit); final priceText = _formatPrice(item.price, item.priceUnit);
final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit); final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit);
final limitText = item.offerLimitText?.trim(); final limitText = item.offerLimitText?.trim();
final sanitizedOfferText = item.offerText == null
? ''
: _removeLimitTextFromOfferText(item.offerText!, limitText);
return CheckboxListTile( return CheckboxListTile(
value: _selected[index] ?? false, value: _selected[index] ?? false,
@@ -267,9 +281,16 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (priceText.isNotEmpty) Text('Pris: $priceText'), if (priceText.isNotEmpty) Text('Pris: $priceText'),
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'), if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
if (limitText != null && limitText.isNotEmpty) Text('Begränsning: $limitText'), if (limitText != null && limitText.isNotEmpty)
if ((item.offerText?.trim().isNotEmpty ?? false)) Text(item.offerText!.trim()), 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}'), if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
], ],
), ),
@@ -297,4 +318,3 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
); );
} }
} }