chore: update flyer import features and resources
- 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:
@@ -28,12 +28,20 @@ type FlyerParseItem = {
|
|||||||
reasonCodes: string[];
|
reasonCodes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type FlyerParseResponse = {
|
type FlyerParseResponse = {
|
||||||
retailer: 'willys';
|
retailer: 'willys';
|
||||||
parserVersion: 'v1';
|
parserVersion: 'v1';
|
||||||
items: FlyerParseItem[];
|
items: FlyerParseItem[];
|
||||||
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;
|
||||||
@@ -77,23 +85,28 @@ export class FlyerImportService {
|
|||||||
productById.set(product.id, product);
|
productById.set(product.id, product);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 offerLimitText = this.extractOfferLimitText(item.offerText);
|
const signals = this.extractOfferSignals(item.offerText);
|
||||||
return {
|
const price = item.price ?? signals.price;
|
||||||
flyerItemId: null,
|
const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit;
|
||||||
rawName: item.rawName,
|
const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice;
|
||||||
normalizedName: item.normalizedName,
|
const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit;
|
||||||
category: item.category,
|
const offerLimitText = this.extractOfferLimitText(item.offerText);
|
||||||
price: item.price,
|
return {
|
||||||
priceUnit: item.priceUnit,
|
flyerItemId: null,
|
||||||
comparisonPrice: item.comparisonPrice,
|
rawName: item.rawName,
|
||||||
comparisonUnit: item.comparisonUnit,
|
normalizedName: item.normalizedName,
|
||||||
offerText: item.offerText,
|
category: item.category,
|
||||||
isOffer: this.isOfferItem(item),
|
price,
|
||||||
offerLimitText,
|
priceUnit,
|
||||||
parseConfidence: item.confidence,
|
comparisonPrice,
|
||||||
parseReasons: item.reasonCodes,
|
comparisonUnit,
|
||||||
|
offerText: item.offerText,
|
||||||
|
isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
|
||||||
|
offerLimitText,
|
||||||
|
parseConfidence: item.confidence,
|
||||||
|
parseReasons: item.reasonCodes,
|
||||||
matchedProductId: match.product?.id ?? null,
|
matchedProductId: match.product?.id ?? null,
|
||||||
matchedProductName: match.product?.name ?? null,
|
matchedProductName: match.product?.name ?? null,
|
||||||
matchedVia: match.via,
|
matchedVia: match.via,
|
||||||
@@ -260,28 +273,103 @@ 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 {
|
||||||
if (!offerText) return null;
|
if (!offerText) return null;
|
||||||
|
|
||||||
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(
|
||||||
if (limitMatch?.[0]) {
|
/(?:max|högst|begränsat\s+antal)\s+[^,.;]*(?:hushåll|kund|köp)?(?:\s*\/\s*(?:hushåll|kund))?/i,
|
||||||
return limitMatch[0].trim();
|
);
|
||||||
}
|
if (limitMatch?.[0]) {
|
||||||
|
return limitMatch[0].trim();
|
||||||
const householdMatch = normalized.match(/[^,.;]*(?:hushåll|kund)[^,.;]*/i);
|
}
|
||||||
if (householdMatch?.[0]) {
|
|
||||||
return householdMatch[0].trim();
|
const perCustomerMatch = normalized.match(
|
||||||
}
|
/[^,.;]*(?:per\s+(?:hushåll|kund)|\/\s*(?:hushåll|kund))[^,.;]*/i,
|
||||||
|
);
|
||||||
return null;
|
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<FlyerParseResponse> {
|
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|||||||
@@ -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}'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -296,5 +317,4 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user