parser logic added

This commit is contained in:
davmar
2026-08-30 12:24:29 +02:00
parent 35d5c90d81
commit 3b698a3667
28 changed files with 3018 additions and 346 deletions
+9 -1
View File
@@ -3,7 +3,7 @@ library;
import 'dart:convert';
const int kBackupVersion = 1;
const int kBackupVersion = 2;
class BackupDocument {
const BackupDocument({
@@ -14,6 +14,8 @@ class BackupDocument {
required this.products,
required this.shops,
required this.shopItems,
required this.shoppingLists,
required this.shoppingListItems,
required this.images,
});
@@ -24,6 +26,8 @@ class BackupDocument {
final List<Map<String, Object?>> products;
final List<Map<String, Object?>> shops;
final List<Map<String, Object?>> shopItems;
final List<Map<String, Object?>> shoppingLists;
final List<Map<String, Object?>> shoppingListItems;
final Map<String, String> images;
Map<String, Object?> toJson() => {
@@ -35,6 +39,8 @@ class BackupDocument {
'products': products,
'shops': shops,
'shopItems': shopItems,
'shoppingLists': shoppingLists,
'shoppingListItems': shoppingListItems,
'images': images,
};
@@ -60,6 +66,8 @@ class BackupDocument {
products: _maps(decoded['products']),
shops: _maps(decoded['shops']),
shopItems: _maps(decoded['shopItems']),
shoppingLists: _maps(decoded['shoppingLists']),
shoppingListItems: _maps(decoded['shoppingListItems']),
images: _strings(decoded['images']),
);
}
+9 -5
View File
@@ -57,6 +57,8 @@ class BackupService {
products: rewrittenProducts,
shops: rewrittenShops,
shopItems: await repository.dumpTable('shop_items'),
shoppingLists: await repository.dumpTable('shopping_lists'),
shoppingListItems: await repository.dumpTable('shopping_list_items'),
images: images,
);
}
@@ -114,7 +116,10 @@ class BackupService {
final now = DateTime.now().toIso8601String();
for (final row in document.products) {
final copy = Map<String, Object?>.from(row);
copy['image_path'] = await _restoreImage(copy['image_path'], document.images);
copy['image_path'] = await _restoreImage(
copy['image_path'],
document.images,
);
copy['created_at'] ??= now;
copy['updated_at'] ??= copy['created_at'];
if (copy['normalized_name'] == null && copy['name'] is String) {
@@ -141,14 +146,13 @@ class BackupService {
products: products,
shops: shops,
shopItems: document.shopItems,
shoppingLists: document.shoppingLists,
shoppingListItems: document.shoppingListItems,
darkMode: document.darkMode,
);
}
Future<String?> _restoreImage(
Object? key,
Map<String, String> images,
) async {
Future<String?> _restoreImage(Object? key, Map<String, String> images) async {
if (key is! String || key.isEmpty) return null;
final encoded = images[key];
if (encoded == null) return null;
+306 -18
View File
@@ -11,6 +11,7 @@ import '../models/line_item.dart';
import '../models/product.dart';
import '../models/purchase.dart';
import '../models/shop.dart';
import '../models/shopping_list.dart';
import '../models/supermarket.dart';
import '../utils/money.dart';
import '../utils/supermarkets.dart';
@@ -27,15 +28,20 @@ class ProductRepository {
}
Future<Database> _open() async {
final path = databasePath ??
final path =
databasePath ??
p.join((await getApplicationDocumentsDirectory()).path, 'receipity.db');
return openDatabase(
path,
version: 4,
version: 5,
onConfigure: (db) async {
await db.execute('PRAGMA foreign_keys = ON');
},
onCreate: (db, version) async {
await _createProducts(db);
await _createShopTables(db);
await _createSettingsTables(db);
await _createShoppingListTables(db);
await _seedSupermarkets(db);
},
onUpgrade: (db, oldVersion, newVersion) async {
@@ -55,6 +61,9 @@ class ProductRepository {
if (oldVersion < 4) {
await db.execute('ALTER TABLE products ADD COLUMN category TEXT');
}
if (oldVersion < 5) {
await _createShoppingListTables(db);
}
},
);
}
@@ -133,6 +142,29 @@ class ProductRepository {
''');
}
Future<void> _createShoppingListTables(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE IF NOT EXISTS shopping_list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL,
product_id INTEGER,
name TEXT NOT NULL,
checked INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (list_id) REFERENCES shopping_lists(id) ON DELETE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(id)
)
''');
}
static String normalizeName(String name) =>
name.trim().toUpperCase().replaceAll(RegExp(r'\s+'), ' ');
@@ -315,13 +347,11 @@ class ProductRepository {
Future<List<String>> getUsedCategories() async {
final db = await _database;
final rows = await db.rawQuery(
'''
final rows = await db.rawQuery('''
SELECT DISTINCT category FROM products
WHERE category IS NOT NULL AND TRIM(category) != ''
ORDER BY category COLLATE NOCASE ASC
''',
);
''');
return [
for (final row in rows)
if (row['category'] is String) row['category']! as String,
@@ -455,6 +485,238 @@ class ProductRepository {
return rows.map(ShopItem.fromMap).toList();
}
/// Removes a trip and its line items. Catalog products are left unchanged.
Future<void> deleteShop(int id) async {
final shop = await getShop(id);
final db = await _database;
await db.transaction((txn) async {
await txn.delete('shop_items', where: 'shop_id = ?', whereArgs: [id]);
await txn.delete('shops', where: 'id = ?', whereArgs: [id]);
});
final path = shop?.receiptImagePath;
if (path == null || path.isEmpty) return;
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
/// Removes one trip line. Catalog products are left unchanged.
Future<void> deleteShopItem(int id) async {
final db = await _database;
final rows = await db.query(
'shop_items',
columns: ['shop_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final shopId = rows.first['shop_id']! as int;
await db.delete('shop_items', where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId);
}
Future<void> updateShopItem({
required int id,
String? name,
double? price,
int? quantity,
double? unitPrice,
}) async {
final db = await _database;
final rows = await db.query(
'shop_items',
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final shopId = rows.first['shop_id']! as int;
final values = <String, Object?>{};
if (name != null) values['name'] = name.trim();
if (price != null) values['price'] = price;
if (quantity != null) values['quantity'] = quantity;
if (unitPrice != null) values['unit_price'] = unitPrice;
if (values.isEmpty) return;
await db.update('shop_items', values, where: 'id = ?', whereArgs: [id]);
await _recalculateShopTotal(shopId);
}
Future<void> _recalculateShopTotal(int shopId) async {
final db = await _database;
final rows = await db.rawQuery(
'SELECT COALESCE(SUM(price), 0) AS total FROM shop_items WHERE shop_id = ?',
[shopId],
);
final total = roundMoney((rows.first['total'] as num).toDouble());
await db.update(
'shops',
{'total': total},
where: 'id = ?',
whereArgs: [shopId],
);
}
Future<List<ShoppingList>> getShoppingLists() async {
final db = await _database;
final rows = await db.rawQuery('''
SELECT
shopping_lists.id,
shopping_lists.name,
shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
FROM shopping_lists
LEFT JOIN shopping_list_items
ON shopping_list_items.list_id = shopping_lists.id
GROUP BY shopping_lists.id
ORDER BY shopping_lists.updated_at DESC, shopping_lists.id DESC
''');
return rows.map(ShoppingList.fromMap).toList();
}
Future<ShoppingList?> getShoppingList(int id) async {
final db = await _database;
final rows = await db.rawQuery(
'''
SELECT
shopping_lists.id,
shopping_lists.name,
shopping_lists.updated_at,
COUNT(shopping_list_items.id) AS item_count,
COALESCE(SUM(shopping_list_items.checked), 0) AS checked_count
FROM shopping_lists
LEFT JOIN shopping_list_items
ON shopping_list_items.list_id = shopping_lists.id
WHERE shopping_lists.id = ?
GROUP BY shopping_lists.id
''',
[id],
);
if (rows.isEmpty) return null;
return ShoppingList.fromMap(rows.first);
}
Future<int> createShoppingList(String name) async {
final db = await _database;
final now = DateTime.now().toIso8601String();
return db.insert('shopping_lists', {
'name': name.trim(),
'created_at': now,
'updated_at': now,
});
}
Future<void> renameShoppingList(int id, String name) async {
final db = await _database;
await db.update(
'shopping_lists',
{'name': name.trim(), 'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> deleteShoppingList(int id) async {
final db = await _database;
await db.transaction((txn) async {
await txn.delete(
'shopping_list_items',
where: 'list_id = ?',
whereArgs: [id],
);
await txn.delete('shopping_lists', where: 'id = ?', whereArgs: [id]);
});
}
Future<List<ShoppingListItem>> getShoppingListItems(int listId) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
where: 'list_id = ?',
whereArgs: [listId],
orderBy: 'checked ASC, sort_order ASC, id ASC',
);
return rows.map(ShoppingListItem.fromMap).toList();
}
Future<int> addShoppingListItem({
required int listId,
required String name,
int? productId,
}) async {
final db = await _database;
final max = Sqflite.firstIntValue(
await db.rawQuery(
'SELECT MAX(sort_order) FROM shopping_list_items WHERE list_id = ?',
[listId],
),
);
final id = await db.insert('shopping_list_items', {
'list_id': listId,
'product_id': productId,
'name': name.trim(),
'checked': 0,
'sort_order': (max ?? -1) + 1,
});
await _touchShoppingList(listId);
return id;
}
Future<void> updateShoppingListItem({
required int id,
String? name,
bool? checked,
int? productId,
}) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
columns: ['list_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
final values = <String, Object?>{};
if (name != null) values['name'] = name.trim();
if (checked != null) values['checked'] = checked ? 1 : 0;
if (productId != null) values['product_id'] = productId;
if (values.isEmpty) return;
await db.update(
'shopping_list_items',
values,
where: 'id = ?',
whereArgs: [id],
);
await _touchShoppingList(rows.first['list_id']! as int);
}
Future<void> deleteShoppingListItem(int id) async {
final db = await _database;
final rows = await db.query(
'shopping_list_items',
columns: ['list_id'],
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
if (rows.isEmpty) return;
await db.delete('shopping_list_items', where: 'id = ?', whereArgs: [id]);
await _touchShoppingList(rows.first['list_id']! as int);
}
Future<void> _touchShoppingList(int id) async {
final db = await _database;
await db.update(
'shopping_lists',
{'updated_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
}
Future<bool> getDarkMode() async {
final db = await _database;
final rows = await db.query(
@@ -469,16 +731,18 @@ class ProductRepository {
Future<void> setDarkMode(bool enabled) async {
final db = await _database;
await db.insert(
'app_settings',
{'key': 'dark_mode', 'value': enabled ? '1' : '0'},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await db.insert('app_settings', {
'key': 'dark_mode',
'value': enabled ? '1' : '0',
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<List<Supermarket>> getSupermarkets() async {
final db = await _database;
final rows = await db.query('supermarkets', orderBy: 'sort_order ASC, id ASC');
final rows = await db.query(
'supermarkets',
orderBy: 'sort_order ASC, id ASC',
);
if (rows.isEmpty) {
await _seedSupermarkets(db);
return getSupermarkets();
@@ -579,9 +843,13 @@ class ProductRepository {
required List<Map<String, Object?>> shops,
required List<Map<String, Object?>> shopItems,
required bool darkMode,
List<Map<String, Object?>> shoppingLists = const [],
List<Map<String, Object?>> shoppingListItems = const [],
}) async {
final db = await _database;
await db.transaction((txn) async {
await txn.delete('shopping_list_items');
await txn.delete('shopping_lists');
await txn.delete('shop_items');
await txn.delete('shops');
await txn.delete('products');
@@ -600,6 +868,15 @@ class ProductRepository {
for (final row in shopItems) {
await txn.insert('shop_items', _sqlRow(row, _shopItemColumns));
}
for (final row in shoppingLists) {
await txn.insert('shopping_lists', _sqlRow(row, _shoppingListColumns));
}
for (final row in shoppingListItems) {
await txn.insert(
'shopping_list_items',
_sqlRow(row, _shoppingListItemColumns),
);
}
await txn.insert('app_settings', {
'key': 'dark_mode',
'value': darkMode ? '1' : '0',
@@ -609,15 +886,12 @@ class ProductRepository {
await _resetSequence(txn, 'products');
await _resetSequence(txn, 'shops');
await _resetSequence(txn, 'shop_items');
await _resetSequence(txn, 'shopping_lists');
await _resetSequence(txn, 'shopping_list_items');
});
}
static const _supermarketColumns = {
'id',
'name',
'color',
'sort_order',
};
static const _supermarketColumns = {'id', 'name', 'color', 'sort_order'};
static const _productColumns = {
'id',
'name',
@@ -649,6 +923,20 @@ class ProductRepository {
'quantity',
'unit_price',
};
static const _shoppingListColumns = {
'id',
'name',
'created_at',
'updated_at',
};
static const _shoppingListItemColumns = {
'id',
'list_id',
'product_id',
'name',
'checked',
'sort_order',
};
Map<String, Object?> _sqlRow(
Map<String, Object?> source,
+179
View File
@@ -0,0 +1,179 @@
/// Keyword and regex config loaded from receipt-parsing-keywords.json.
library;
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
class ReceiptParseConfig {
ReceiptParseConfig({
required this.knownStores,
required this.columnHeaderKeywords,
required this.separatorPattern,
required this.quantityStandalone,
required this.quantityInline,
required this.discountKeywords,
required this.negativeAmount,
required this.totalDiscountKeywords,
required this.subtotalKeywords,
required this.grandTotalKeywords,
required this.taxInclusiveTotalKeywords,
required this.paymentKeywords,
required this.taxKeywords,
required this.categoryRow,
required this.trailingCategoryLetter,
required this.noiseKeywords,
required this.itemCountKeywords,
});
final List<String> knownStores;
final List<String> columnHeaderKeywords;
final RegExp separatorPattern;
final RegExp quantityStandalone;
final RegExp quantityInline;
final List<String> discountKeywords;
final RegExp negativeAmount;
final List<String> totalDiscountKeywords;
final List<String> subtotalKeywords;
final List<String> grandTotalKeywords;
final List<String> taxInclusiveTotalKeywords;
final List<String> paymentKeywords;
final List<String> taxKeywords;
final RegExp categoryRow;
final RegExp trailingCategoryLetter;
final List<String> noiseKeywords;
final List<String> itemCountKeywords;
factory ReceiptParseConfig.fromJson(Map<String, dynamic> json) {
List<String> list(String section, [String key = 'list']) {
final block = json[section];
if (block is! Map) return const [];
final value = block[key];
if (value is! List) return const [];
return [
for (final item in value)
if (item is String) item,
];
}
RegExp rx(String section, String key, {bool caseInsensitive = true}) {
final block = json[section];
final value = block is Map ? block[key] : null;
final source = value is String ? value : '^\$';
return RegExp(source, caseSensitive: !caseInsensitive);
}
return ReceiptParseConfig(
knownStores: list('known_stores'),
columnHeaderKeywords: list('column_header_keywords'),
separatorPattern: rx('column_header_keywords', 'separator_pattern'),
quantityStandalone: rx(
'quantity_modifier_pattern',
'regex',
caseInsensitive: true,
),
quantityInline: rx(
'quantity_modifier_pattern',
'inline_regex',
caseInsensitive: true,
),
discountKeywords: list('discount_keywords'),
negativeAmount: rx('discount_keywords', 'negative_amount_pattern'),
totalDiscountKeywords: list(
'discount_keywords',
'receipt_level_total_discount_keywords',
),
subtotalKeywords: list('total_keywords', 'subtotal_keywords'),
grandTotalKeywords: list('total_keywords', 'grand_total_keywords'),
taxInclusiveTotalKeywords: list(
'total_keywords',
'tax_inclusive_total_keywords',
),
paymentKeywords: list('payment_keywords'),
taxKeywords: list('tax_breakdown_keywords'),
categoryRow: rx('tax_breakdown_keywords', 'category_row_pattern'),
trailingCategoryLetter: rx(
'tax_breakdown_keywords',
'trailing_category_letter_pattern',
),
noiseKeywords: list('noise_footer_keywords'),
itemCountKeywords: list('line_item_count_keywords'),
);
}
static const assetPath = 'receipt-parsing-keywords.json';
/// True if [line] matches a config keyword.
///
/// Short tokens (3 characters or fewer) use a word boundary so `PIN` does
/// not match `SPINAZIE`. Longer tokens use a case-insensitive substring,
/// and a letter-only compact form so OCR splits like `Betaal d:` still
/// match `BETAALD`.
bool matchesKeyword(String line, String keyword) {
final upper = line.toUpperCase();
final key = keyword.toUpperCase();
if (key.length <= 3) {
return RegExp('\\b${RegExp.escape(key)}\\b').hasMatch(upper);
}
if (upper.contains(key)) return true;
final compactLine = upper.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final compactKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
return compactKey.length >= 4 && compactLine.contains(compactKey);
}
bool isStandaloneKeywordLine(String line, List<String> keywords) {
final compact = line.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
for (final keyword in keywords) {
final key = keyword.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
if (key.isEmpty) continue;
if (compact == key) return true;
if (compact.startsWith(key) && compact.length <= key.length + 3) {
return true;
}
}
return false;
}
bool matchesAny(String line, List<String> keywords) {
for (final keyword in keywords) {
if (matchesKeyword(line, keyword)) return true;
}
return false;
}
}
ReceiptParseConfig? _loadedConfig;
ReceiptParseConfig get receiptParseConfig {
if (_loadedConfig != null) return _loadedConfig!;
final file = File(ReceiptParseConfig.assetPath);
if (file.existsSync()) {
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
);
return _loadedConfig!;
}
throw StateError(
'Receipt keyword config was not loaded. Call ReceiptParser.loadConfig().',
);
}
void setReceiptParseConfig(ReceiptParseConfig config) {
_loadedConfig = config;
}
Future<void> loadReceiptParseConfig() async {
try {
final raw = await rootBundle.loadString(ReceiptParseConfig.assetPath);
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} catch (_) {
final file = File(ReceiptParseConfig.assetPath);
if (!file.existsSync()) rethrow;
_loadedConfig = ReceiptParseConfig.fromJson(
jsonDecode(file.readAsStringSync()) as Map<String, dynamic>,
);
}
}
+499 -113
View File
@@ -1,57 +1,31 @@
/// Turns raw OCR text into [LineItem]s using line-end prices and a keyword filter.
/// Turns raw OCR text into [LineItem]s using receipt-parsing-keywords.json.
library;
import '../models/line_item.dart';
import '../utils/money.dart';
import 'receipt_parse_config.dart';
/// Headers, payments, and totals — never treated as products.
const Set<String> kHeaderKeywords = {
'TOTAL',
'SUBTOTAL',
'TOTAAL',
'TAX',
'VAT',
'BTW',
'CASH',
'CARD',
'CHANGE',
'VISA',
'MASTERCARD',
'MAESTRO',
'PIN',
'DEBIT',
'CREDIT',
'OMSCHRIJVING',
'BEDRAG',
'AMOUNT',
'DESCRIPTION',
'THANK',
'BEDANKT',
'WISSELGELD',
'BETAALD',
'BETAAL',
'CONTANT',
'KASSA',
'RECEIPT',
'WWW',
'HTTP',
};
export 'receipt_parse_config.dart' show loadReceiptParseConfig;
/// Discount / promo rows. Paired with a price, then dropped.
const Set<String> kDropKeywords = {
'DISCOUNT',
'KORTING',
'ACTIE',
};
/// Result of a full receipt parse, including totals used for review.
class ReceiptParseResult {
const ReceiptParseResult({
required this.items,
this.store,
this.receiptTotal,
this.totalDiscount = 0,
this.validationPassed = true,
});
final _separatorPattern = RegExp(r'^[=\-_.:\s]+$');
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
final _phonePattern = RegExp(r'^\d{8,}$');
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
final List<LineItem> items;
final String? store;
final double? receiptTotal;
final double totalDiscount;
final bool validationPassed;
}
class _QtyLine {
const _QtyLine({required this.count, required this.unitPrice, this.total});
class _Qty {
const _Qty({required this.count, required this.unitPrice, this.total});
final int count;
final double unitPrice;
@@ -60,6 +34,59 @@ class _QtyLine {
double get lineTotal => total ?? roundMoney(count * unitPrice);
}
class _Pending {
_Pending({
required this.name,
this.unitPrice,
this.quantity = 1,
this.lineTotal,
});
String name;
double? unitPrice;
int quantity;
double? lineTotal;
double discount = 0;
}
enum _Kind {
skip,
item,
nameOnly,
quantity,
discount,
grandTotal,
totalDiscount,
priceOnly,
}
class _Line {
const _Line({
required this.kind,
required this.raw,
this.name,
this.amount,
this.qty,
});
final _Kind kind;
final String raw;
final String? name;
final double? amount;
final _Qty? qty;
}
final _postalPattern = RegExp(r'\d{4}\s*[A-Za-z]{2}\b');
final _phonePattern = RegExp(r'^\d{8,}$');
final _streetPattern = RegExp(r"^[A-Za-z. ''-]+\s+\d{1,4}[A-Za-z]?$");
final _urlPattern = RegExp(r'www\.|\.com|\.nl', caseSensitive: false);
/// Jumbo-style OCR often adds a line total after `N X unit`.
final _qtyWithTotal = RegExp(
r'^\s*(\d+)\s*[xX×]\s*(-?\d+[,.]\d{2})(?:\s+(?:PER\s+STUK|PER\s+KG|ST\.?|STUKS?))?(?:\s+(-?\d+[,.]\d{2}))?\s*$',
caseSensitive: false,
);
class ReceiptParser {
ReceiptParser._();
@@ -67,74 +94,198 @@ class ReceiptParser {
static String _id() => 'item-${++_nextId}';
/// Parses [rawText] into product rows.
///
/// Prefers same-line `name … price` (with a following `2 x 1.50` line when
/// the name has no price). If OCR split the receipt into a name column and a
/// price column, falls back to pairing those columns.
static List<LineItem> parse(String rawText) {
static Future<void> loadConfig() => loadReceiptParseConfig();
static List<LineItem> parse(String rawText) => parseReceipt(rawText).items;
static ReceiptParseResult parseReceipt(String rawText) {
final config = receiptParseConfig;
final lines = rawText
.split('\n')
.map((line) => line.trim())
.map(_preprocess)
.where((line) => line.isNotEmpty)
.toList();
final inline = _parseInline(lines);
final columns = _parseColumns(lines);
final chosen = columns.length > inline.length ? columns : inline;
return chosen.where(_keepItem).toList();
if (_looksLikeColumnOcr(lines, config)) {
return _parseColumns(lines, config, rawText);
}
return _parseSequential(lines, config, rawText);
}
static List<LineItem> _parseInline(List<String> lines) {
final items = <LineItem>[];
static String _preprocess(String line) =>
line.trim().replaceAll(RegExp(r'\s+'), ' ');
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (_shouldSkipLine(line) || isPriceOnly(line)) continue;
static String _stripTax(String line, ReceiptParseConfig config) {
return line.replaceFirst(config.trailingCategoryLetter, '').trim();
}
final qty = _parseQty(line);
if (qty != null) continue;
final trailing = trailingPricePattern.firstMatch(line);
if (trailing != null) {
final name = trailing.group(1)!.trim();
final price = tryParsePrice(trailing.group(2)!);
if (name.isEmpty || price == null || _shouldSkipLine(name)) continue;
items.add(LineItem(id: _id(), name: name, price: price));
continue;
static bool _looksLikeColumnOcr(
List<String> lines,
ReceiptParseConfig config,
) {
var run = 0;
var maxRun = 0;
var prices = 0;
var names = 0;
for (final line in lines) {
final stripped = _stripTax(line, config);
if (isPriceOnly(stripped)) {
prices++;
run++;
if (run > maxRun) maxRun = run;
} else {
run = 0;
if (_parseQty(stripped, config) == null &&
!_isIgnorable(stripped, config)) {
names++;
}
}
}
return maxRun >= 4 && prices >= 4 && names >= 4;
}
if (i + 1 >= lines.length) continue;
final nextQty = _parseQty(lines[i + 1]);
if (nextQty == null) continue;
items.add(
LineItem(
id: _id(),
name: line,
price: nextQty.lineTotal,
quantity: nextQty.count,
unitPrice: nextQty.unitPrice,
),
);
i++;
static ReceiptParseResult _parseSequential(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final items = <LineItem>[];
_Pending? pending;
var receiptTotal = _readAmountAfterKeyword(
lines,
config,
config.grandTotalKeywords,
skipIf: (line) => config.matchesAny(line, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
]),
);
var totalDiscount = 0.0;
var stop = false;
void flush() {
final current = pending;
pending = null;
if (current == null) return;
final total = current.lineTotal ?? current.unitPrice;
if (total == null) return;
items.add(_toItem(current, total));
}
return items;
for (var i = 0; i < lines.length && !stop; i++) {
final classified = _classify(lines[i], config);
switch (classified.kind) {
case _Kind.skip:
continue;
case _Kind.item:
flush();
pending = _Pending(
name: classified.name!,
unitPrice: classified.amount,
quantity: classified.qty?.count ?? 1,
lineTotal: classified.qty == null
? classified.amount
: classified.qty!.lineTotal,
);
if (classified.qty != null) {
pending!.unitPrice = classified.qty!.unitPrice;
}
case _Kind.nameOnly:
flush();
pending = _Pending(name: classified.name ?? classified.raw);
case _Kind.quantity:
final qty = classified.qty!;
if (pending != null) {
pending!
..quantity = qty.count
..unitPrice = qty.unitPrice
..lineTotal = qty.lineTotal;
}
case _Kind.discount:
var amount = classified.amount;
if (amount == null && i + 1 < lines.length) {
amount = tryParsePrice(_stripTax(lines[i + 1], config));
if (amount != null) i++;
}
if (amount == null) continue;
if (amount > 0) amount = -amount;
if (pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
} else {
totalDiscount += amount;
}
case _Kind.totalDiscount:
var amount = classified.amount;
if (amount == null && i + 1 < lines.length) {
amount = tryParsePrice(_stripTax(lines[i + 1], config));
if (amount != null) i++;
}
if (amount != null) {
totalDiscount += amount > 0 ? -amount : amount;
}
case _Kind.grandTotal:
flush();
if (classified.amount != null) {
receiptTotal = classified.amount;
stop = true;
}
case _Kind.priceOnly:
final amount = classified.amount!;
if (amount < 0 && pending != null) {
pending!.discount += amount;
pending!.lineTotal = roundMoney(
(pending!.lineTotal ?? pending!.unitPrice ?? 0) + amount,
);
}
}
}
flush();
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
}
static List<LineItem> _parseColumns(List<String> lines) {
static ReceiptParseResult _parseColumns(
List<String> lines,
ReceiptParseConfig config,
String rawText,
) {
final names = <({String name, int? quantity, double? unitPrice})>[];
final prices = <double>[];
final discountSlots = <int>[];
final discounts = <double>[];
for (final line in lines) {
for (final raw in lines) {
final line = _stripTax(raw, config);
if (isPriceOnly(line)) {
final price = tryParsePrice(line);
if (price != null) prices.add(price);
if (price == null) continue;
if (price < 0) {
discounts.add(price);
} else {
prices.add(price);
}
continue;
}
if (_isIgnorable(line, config)) continue;
if (config.matchesAny(line, [
...config.grandTotalKeywords,
...config.subtotalKeywords,
...config.taxInclusiveTotalKeywords,
...config.totalDiscountKeywords,
])) {
continue;
}
if (_shouldSkipLine(line)) continue;
final qty = _parseQty(line);
final qty = _parseQty(line, config);
if (qty != null) {
if (names.isNotEmpty) {
final last = names.removeLast();
@@ -147,11 +298,16 @@ class ReceiptParser {
continue;
}
if (config.matchesAny(line, config.discountKeywords)) {
if (names.isNotEmpty) discountSlots.add(names.length - 1);
continue;
}
names.add((name: line, quantity: null, unitPrice: null));
}
final count = names.length < prices.length ? names.length : prices.length;
return [
final items = <LineItem>[
for (var i = 0; i < count; i++)
LineItem(
id: _id(),
@@ -161,44 +317,274 @@ class ReceiptParser {
unitPrice: names[i].unitPrice,
),
];
for (var i = 0; i < discountSlots.length && i < discounts.length; i++) {
final index = discountSlots[i];
if (index < 0 || index >= items.length) continue;
final item = items[index];
final discount = discounts[i];
items[index] = item.copyWith(
price: roundMoney(item.price + discount),
discount: roundMoney(item.discount + discount),
);
}
final receiptTotal = _readAmountAfterKeyword(
lines,
config,
config.grandTotalKeywords,
skipIf: (line) => config.matchesAny(line, [
...config.totalDiscountKeywords,
...config.taxInclusiveTotalKeywords,
...config.subtotalKeywords,
]),
);
var totalDiscount = discounts.fold<double>(0, (sum, value) => sum + value);
totalDiscount +=
_readAmountAfterKeyword(lines, config, config.totalDiscountKeywords) ??
0;
return _result(
items: items,
rawText: rawText,
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
);
}
static bool _keepItem(LineItem item) {
if (item.price < 0) return false;
if (item.name.trim().isEmpty) return false;
return !_containsKeyword(item.name, kDropKeywords);
static LineItem _toItem(_Pending pending, double total) {
return LineItem(
id: _id(),
name: pending.name,
price: roundMoney(total),
quantity: pending.quantity == 1 ? null : pending.quantity,
unitPrice: pending.quantity == 1 ? null : pending.unitPrice,
discount: pending.discount == 0 ? 0 : roundMoney(pending.discount),
);
}
static bool _shouldSkipLine(String line) {
if (_separatorPattern.hasMatch(line)) return true;
static ReceiptParseResult _result({
required List<LineItem> items,
required String rawText,
double? receiptTotal,
double totalDiscount = 0,
}) {
final kept = items
.where((item) => item.name.trim().isNotEmpty && item.price > 0)
.toList();
final sum = roundMoney(
kept.fold<double>(0, (total, item) => total + item.price),
);
final passed =
receiptTotal == null ||
(sum - receiptTotal).abs() <= 0.01 ||
(sum + totalDiscount - receiptTotal).abs() <= 0.01;
return ReceiptParseResult(
items: kept,
store: _detectStore(rawText),
receiptTotal: receiptTotal,
totalDiscount: totalDiscount,
validationPassed: passed,
);
}
static String? _detectStore(String rawText) {
final config = receiptParseConfig;
final header = rawText
.split('\n')
.map(_preprocess)
.where((line) => line.isNotEmpty)
.take(8)
.join('\n')
.toUpperCase();
final stores = [...config.knownStores]
..sort((a, b) => b.length.compareTo(a.length));
for (final store in stores) {
if (RegExp('\\b${RegExp.escape(store)}\\b').hasMatch(header)) {
return _canonicalStore(store);
}
}
return null;
}
static String _canonicalStore(String raw) {
const aliases = {
'AH': 'Albert Heijn',
'ALBERT HEIJN': 'Albert Heijn',
'JUMBO': 'Jumbo',
'LIDL': 'Lidl',
'KRUIDVAT': 'Kruidvat',
'ALDI': 'Aldi',
'PLUS': 'Plus',
'DIRK': 'Dirk',
'COOP': 'Coop',
'SPAR': 'SPAR',
'VOMAR': 'Vomar',
'EKOPLAZA': 'Ekoplaza',
'HOOGVLIET': 'Hoogvliet',
'ACTION': 'Action',
'ETOS': 'Etos',
'WORLD TOKO': 'World Toko',
};
return aliases[raw.toUpperCase()] ?? raw;
}
static double? _readAmountAfterKeyword(
List<String> lines,
ReceiptParseConfig config,
List<String> keywords, {
bool Function(String line)? skipIf,
}) {
for (var i = 0; i < lines.length; i++) {
final line = _stripTax(lines[i], config);
if (!config.matchesAny(line, keywords)) continue;
if (skipIf != null && skipIf(line)) continue;
final trailing = _trailingPrice(line, config);
if (trailing != null) return trailing;
if (i + 1 < lines.length) {
final next = tryParsePrice(_stripTax(lines[i + 1], config));
if (next != null) return next;
}
}
return null;
}
static _Line _classify(String raw, ReceiptParseConfig config) {
final line = _stripTax(raw, config);
if (_isIgnorable(line, config)) {
return _Line(kind: _Kind.skip, raw: line);
}
if (config.matchesAny(line, config.totalDiscountKeywords)) {
return _Line(
kind: _Kind.totalDiscount,
raw: line,
amount: _trailingPrice(line, config),
);
}
if (config.matchesAny(line, config.taxInclusiveTotalKeywords) ||
config.matchesAny(line, config.subtotalKeywords)) {
return _Line(kind: _Kind.skip, raw: line);
}
if (config.matchesAny(line, config.grandTotalKeywords)) {
return _Line(
kind: _Kind.grandTotal,
raw: line,
amount: _trailingPrice(line, config),
);
}
final qty = _parseQty(line, config);
if (qty != null) {
return _Line(kind: _Kind.quantity, raw: line, qty: qty);
}
final isDiscount =
config.matchesAny(line, config.discountKeywords) ||
config.negativeAmount.hasMatch(line);
if (isDiscount &&
(config.matchesAny(line, config.discountKeywords) ||
isPriceOnly(line))) {
return _Line(
kind: _Kind.discount,
raw: line,
name: line,
amount: _trailingPrice(line, config) ?? tryParsePrice(line),
);
}
if (isPriceOnly(line)) {
return _Line(
kind: _Kind.priceOnly,
raw: line,
amount: tryParsePrice(line),
);
}
final inline = config.quantityInline.firstMatch(line);
final trailing = _trailingPrice(line, config);
if (trailing != null) {
var name = line.replaceFirst(RegExp(r'\s+-?\d+[.,]\d{2}\s*$'), '').trim();
_Qty? inlineQty;
if (inline != null) {
name = line.substring(0, inline.start).trim();
final count = int.tryParse(inline.group(1)!);
final unit = tryParsePrice(inline.group(2)!);
if (count != null && unit != null) {
inlineQty = _Qty(count: count, unitPrice: unit, total: trailing);
}
}
if (name.isEmpty || _isIgnorable(name, config)) {
return _Line(kind: _Kind.skip, raw: line);
}
return _Line(
kind: _Kind.item,
raw: line,
name: name,
amount: trailing,
qty: inlineQty,
);
}
if (inline != null) {
final name = line.substring(0, inline.start).trim();
final count = int.tryParse(inline.group(1)!);
final unit = tryParsePrice(inline.group(2)!);
if (name.isNotEmpty && count != null && unit != null) {
return _Line(
kind: _Kind.item,
raw: line,
name: name,
amount: roundMoney(count * unit),
qty: _Qty(count: count, unitPrice: unit),
);
}
}
return _Line(kind: _Kind.nameOnly, raw: line, name: line);
}
static bool _isIgnorable(String line, ReceiptParseConfig config) {
if (config.separatorPattern.hasMatch(line)) return true;
if (_urlPattern.hasMatch(line)) return true;
if (_postalPattern.hasMatch(line)) return true;
if (_phonePattern.hasMatch(line.replaceAll(RegExp(r'\s'), ''))) {
return true;
}
if (_streetPattern.hasMatch(line)) return true;
if (_containsKeyword(line, kHeaderKeywords)) return true;
return false;
}
static bool _containsKeyword(String line, Set<String> keywords) {
final upper = line.toUpperCase();
for (final keyword in keywords) {
if (RegExp('\\b${RegExp.escape(keyword)}\\b').hasMatch(upper)) {
return true;
}
if (config.categoryRow.hasMatch(line)) return true;
if (config.isStandaloneKeywordLine(line, config.columnHeaderKeywords)) {
return true;
}
if (config.isStandaloneKeywordLine(line, config.itemCountKeywords)) {
return true;
}
if (config.matchesAny(line, config.taxKeywords)) return true;
if (config.matchesAny(line, config.paymentKeywords)) return true;
if (config.matchesAny(line, config.noiseKeywords)) return true;
return false;
}
static _QtyLine? _parseQty(String line) {
final match = quantityPattern.firstMatch(line.trim());
static double? _trailingPrice(String line, ReceiptParseConfig config) {
final match = RegExp(r'(-?\d+[.,]\d{2})\s*$').firstMatch(line);
if (match == null) return null;
return tryParsePrice(match.group(1)!);
}
static _Qty? _parseQty(String line, ReceiptParseConfig config) {
final match =
_qtyWithTotal.firstMatch(line) ??
config.quantityStandalone.firstMatch(line);
if (match == null) return null;
final count = int.tryParse(match.group(1)!);
final unit = tryParsePrice(match.group(2)!);
if (count == null || count < 1 || unit == null) return null;
final totalRaw = match.group(3);
final total = totalRaw == null ? null : tryParsePrice(totalRaw);
return _QtyLine(count: count, unitPrice: unit, total: total);
double? total;
if (match.groupCount >= 3) {
final raw = match.group(3);
if (raw != null && RegExp(r'^-?\d+[.,]\d{2}$').hasMatch(raw)) {
total = tryParsePrice(raw);
}
}
return _Qty(count: count, unitPrice: unit, total: total);
}
}