parser logic added
This commit is contained in:
@@ -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']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user