/// Case-insensitive product name matching and duplicate grouping. library; import '../models/product.dart'; String normalizeProductName(String name) => name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' '); String normalizeSupermarket(String? supermarket) => supermarket?.trim().toUpperCase() ?? ''; String productConflictKey({ required String name, required String? supermarket, }) => '${normalizeSupermarket(supermarket)}|${normalizeProductName(name)}'; String mergeGroupKey(List products, String? supermarket) { final ids = products.map((product) => product.id).toList()..sort(); return 'merge:${normalizeSupermarket(supermarket)}:' '${normalizeProductName(products.first.name)}:${ids.join(',')}'; } String spellingVariantKey(int productId, String? supermarket) => 'spelling:$productId:${normalizeSupermarket(supermarket)}'; List> nameConflictGroups(Iterable products) { final groups = >{}; for (final product in products) { final key = productConflictKey( name: product.name, supermarket: product.supermarket, ); if (normalizeProductName(product.name).isEmpty) continue; (groups[key] ??= []).add(product); } return [ for (final group in groups.values) if (group.length > 1) group, ]; } Set nameConflictIds(Iterable products) { return { for (final group in nameConflictGroups(products)) for (final product in group) product.id, }; } /// Separate catalog products with the same name at the same store. class ProductMergeGroup { const ProductMergeGroup({ required this.products, required this.supermarket, required this.key, }); final List products; final String? supermarket; final String key; } /// One catalog product bought under multiple spellings at one store. class ProductSpellingVariant { const ProductSpellingVariant({ required this.product, required this.supermarket, required this.variants, required this.key, }); final Product product; final String? supermarket; final List variants; final String key; } /// Builds merge groups from catalog names and trip lines at the same store. List buildNameConflictGroups({ required Iterable products, required Iterable<({int productId, String name, String supermarket})> shopLines, }) { final productById = {for (final product in products) product.id: product}; final idsByKey = >{}; final supermarketByKey = {}; void add(int productId, String name, String? supermarket) { final key = productConflictKey(name: name, supermarket: supermarket); if (normalizeProductName(name).isEmpty) return; final trimmed = supermarket?.trim(); if (trimmed != null && trimmed.isNotEmpty) { supermarketByKey.putIfAbsent(key, () => trimmed); } (idsByKey[key] ??= {}).add(productId); } for (final product in products) { add(product.id, product.name, product.supermarket); } for (final line in shopLines) { add(line.productId, line.name, line.supermarket); } final groups = []; for (final entry in idsByKey.entries) { if (entry.value.length < 2) continue; final productsInGroup = [ for (final id in entry.value) if (productById.containsKey(id)) productById[id]!, ]; if (productsInGroup.length < 2) continue; productsInGroup.sort( (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), ); final supermarketLabel = supermarketByKey[entry.key]; groups.add( ProductMergeGroup( products: productsInGroup, supermarket: supermarketLabel, key: mergeGroupKey(productsInGroup, supermarketLabel), ), ); } groups.sort( (a, b) => productConflictKey( name: a.products.first.name, supermarket: a.supermarket, ).compareTo( productConflictKey( name: b.products.first.name, supermarket: b.supermarket, ), ), ); return groups; } /// Finds receipt spellings that differ only by capitals at one store. List findSpellingVariants({ required Iterable products, required Iterable<({int productId, String name, String supermarket})> shopLines, Iterable mergeProductIds = const [], Iterable mergeGroupKeys = const [], }) { final productById = {for (final product in products) product.id: product}; final excludedProducts = mergeProductIds.toSet(); final excludedGroups = mergeGroupKeys.toSet(); final namesByProductStore = >{}; final supermarketByKey = {}; void addName(int productId, String name, String supermarket) { final storeKey = normalizeSupermarket(supermarket); final bucketKey = '$productId|$storeKey'; supermarketByKey[bucketKey] = supermarket; (namesByProductStore[bucketKey] ??= {}).add(name); } for (final line in shopLines) { addName(line.productId, line.name, line.supermarket); } for (final product in products) { final supermarket = product.supermarket; if (supermarket == null || supermarket.trim().isEmpty) continue; addName(product.id, product.name, supermarket); } final issues = []; for (final entry in namesByProductStore.entries) { final parts = entry.key.split('|'); final productId = int.parse(parts.first); if (excludedProducts.contains(productId)) continue; final product = productById[productId]; if (product == null) continue; final supermarket = supermarketByKey[entry.key]; final byNormalized = >{}; for (final name in entry.value) { final key = normalizeProductName(name); if (key.isEmpty) continue; (byNormalized[key] ??= {}).add(name); } for (final names in byNormalized.values) { if (names.length < 2) continue; final variants = names.toList() ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); final issue = ProductSpellingVariant( product: product, supermarket: supermarket, variants: variants, key: spellingVariantKey(productId, supermarket), ); if (excludedGroups.contains(issue.key)) continue; issues.add(issue); } } issues.sort( (a, b) => productConflictKey( name: a.product.name, supermarket: a.supermarket, ).compareTo( productConflictKey(name: b.product.name, supermarket: b.supermarket), ), ); return issues; } /// Catalog merges and receipt spelling variants for the Products screen. class ProductNameIssues { const ProductNameIssues({ required this.mergeGroups, required this.spellingVariants, }); final List mergeGroups; final List spellingVariants; bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty; bool get isNotEmpty => !isEmpty; int get issueCount => mergeGroups.length + spellingVariants.length; Set get productIds => { for (final group in mergeGroups) for (final product in group.products) product.id, for (final variant in spellingVariants) variant.product.id, }; } ProductNameIssues buildProductNameIssues({ required Iterable products, required Iterable<({int productId, String name, String supermarket})> shopLines, Set dismissedKeys = const {}, }) { final mergeGroups = [ for (final group in buildNameConflictGroups( products: products, shopLines: shopLines, )) if (!dismissedKeys.contains(group.key)) group, ]; final mergeIds = { for (final group in mergeGroups) for (final product in group.products) product.id, }; final mergeKeys = {for (final group in mergeGroups) group.key}; final spellingVariants = [ for (final variant in findSpellingVariants( products: products, shopLines: shopLines, mergeProductIds: mergeIds, mergeGroupKeys: mergeKeys, )) if (!dismissedKeys.contains(variant.key)) variant, ]; return ProductNameIssues( mergeGroups: mergeGroups, spellingVariants: spellingVariants, ); }