270 lines
8.1 KiB
Dart
270 lines
8.1 KiB
Dart
/// 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<Product> 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<List<Product>> nameConflictGroups(Iterable<Product> products) {
|
|
final groups = <String, List<Product>>{};
|
|
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<int> nameConflictIds(Iterable<Product> 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<Product> 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<String> variants;
|
|
final String key;
|
|
}
|
|
|
|
/// Builds merge groups from catalog names and trip lines at the same store.
|
|
List<ProductMergeGroup> buildNameConflictGroups({
|
|
required Iterable<Product> products,
|
|
required Iterable<({int productId, String name, String supermarket})>
|
|
shopLines,
|
|
}) {
|
|
final productById = {for (final product in products) product.id: product};
|
|
final idsByKey = <String, Set<int>>{};
|
|
final supermarketByKey = <String, String>{};
|
|
|
|
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 = <ProductMergeGroup>[];
|
|
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<ProductSpellingVariant> findSpellingVariants({
|
|
required Iterable<Product> products,
|
|
required Iterable<({int productId, String name, String supermarket})>
|
|
shopLines,
|
|
Iterable<int> mergeProductIds = const [],
|
|
Iterable<String> mergeGroupKeys = const [],
|
|
}) {
|
|
final productById = {for (final product in products) product.id: product};
|
|
final excludedProducts = mergeProductIds.toSet();
|
|
final excludedGroups = mergeGroupKeys.toSet();
|
|
final namesByProductStore = <String, Set<String>>{};
|
|
final supermarketByKey = <String, String>{};
|
|
|
|
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 = <ProductSpellingVariant>[];
|
|
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 = <String, Set<String>>{};
|
|
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<ProductMergeGroup> mergeGroups;
|
|
final List<ProductSpellingVariant> spellingVariants;
|
|
|
|
bool get isEmpty => mergeGroups.isEmpty && spellingVariants.isEmpty;
|
|
|
|
bool get isNotEmpty => !isEmpty;
|
|
|
|
int get issueCount => mergeGroups.length + spellingVariants.length;
|
|
|
|
Set<int> 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<Product> products,
|
|
required Iterable<({int productId, String name, String supermarket})>
|
|
shopLines,
|
|
Set<String> 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,
|
|
);
|
|
}
|