feat: implement save receipt functionality with transaction handling and DTOs
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -255,6 +255,64 @@ class ImportRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save receipt items in a single atomic transaction.
|
||||
///
|
||||
/// This endpoint handles:
|
||||
/// - Creating/validating products
|
||||
/// - Creating/merging inventory items
|
||||
/// - Adding to pantry
|
||||
/// - Learning aliases
|
||||
/// - Learning unit mappings
|
||||
Future<Map<String, dynamic>> saveReceipt({
|
||||
required List<Map<String, dynamic>> items,
|
||||
bool isAdminLearning = false,
|
||||
String? token,
|
||||
}) async {
|
||||
try {
|
||||
developer.log('Starting saveReceipt with ${items.length} items', name: 'ImportRepository');
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/receipt-import/save');
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
body: jsonEncode({
|
||||
'items': items,
|
||||
if (isAdminLearning) 'isAdminLearning': true,
|
||||
}),
|
||||
).timeout(
|
||||
const Duration(seconds: 60),
|
||||
onTimeout: () {
|
||||
developer.log('saveReceipt request timed out', name: 'ImportRepository', error: 'Timeout');
|
||||
throw ApiException(
|
||||
type: ApiErrorType.network,
|
||||
message: 'Sparandet tok för lång tid. Försök igen.',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
developer.log('saveReceipt response status: ${response.statusCode}', name: 'ImportRepository');
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
developer.log('saveReceipt error: ${response.body}', name: 'ImportRepository', error: 'HTTP Error');
|
||||
throw ApiException(
|
||||
type: _mapStatusCodeToErrorType(response.statusCode),
|
||||
message: 'Fel vid sparande: ${response.body}',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
final result = _parseResponse(response) as Map<String, dynamic>;
|
||||
developer.log('saveReceipt succeeded: ${result['created']} created, ${result['merged']} merged', name: 'ImportRepository');
|
||||
return result;
|
||||
} catch (e) {
|
||||
developer.log('Exception during saveReceipt: $e', name: 'ImportRepository', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper method to map HTTP status codes to [ApiErrorType].
|
||||
ApiErrorType _mapStatusCodeToErrorType(int statusCode) {
|
||||
if (statusCode == 401) return ApiErrorType.unauthorized;
|
||||
|
||||
@@ -473,107 +473,74 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
int created = 0;
|
||||
int merged = 0;
|
||||
int pantryAdded = 0;
|
||||
int pantrySkipped = 0;
|
||||
int aliasesLearned = 0;
|
||||
int unitMappingsLearned = 0;
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final repo = ref.read(importRepositoryProvider);
|
||||
final invRepo = ref.read(inventoryRepositoryProvider);
|
||||
final pantryRepo = ref.read(pantryRepositoryProvider);
|
||||
final adminRepo = ref.read(adminRepositoryProvider);
|
||||
final canManageAliases = ref.read(isAdminProvider);
|
||||
|
||||
// Bygg upp items för saveReceipt endpoint
|
||||
final saveItems = <Map<String, dynamic>>[];
|
||||
for (final i in toAdd) {
|
||||
final edit = _edits[i]!;
|
||||
final item = items[i];
|
||||
final pid = edit.productId!;
|
||||
|
||||
if (edit.destination == _Destination.pantry) {
|
||||
if (_pantryProductIds.contains(pid)) {
|
||||
pantrySkipped++;
|
||||
} else {
|
||||
await pantryRepo.createPantryItem(pid, token: token);
|
||||
pantryAdded++;
|
||||
}
|
||||
} else {
|
||||
final inferred = inferPackageFields(
|
||||
rawName: item.rawName,
|
||||
quantity: edit.quantity ?? item.quantity,
|
||||
unit: edit.unit ?? item.unit,
|
||||
);
|
||||
final packageCount = edit.packageCount ?? inferred.packageCount;
|
||||
final packQuantity = edit.packQuantity ?? inferred.packQuantity;
|
||||
final packUnit = edit.packUnit ?? inferred.packUnit ?? edit.unit ?? item.unit ?? 'st';
|
||||
final qty = packQuantity != null
|
||||
? (packQuantity * packageCount)
|
||||
: (edit.quantity ?? inferred.totalQuantity ?? item.quantity ?? 1.0);
|
||||
final unit = packUnit;
|
||||
final existing = _inventoryByProduct[pid];
|
||||
final originalUnit = (item.unit ?? '').trim();
|
||||
final preferredUnitForLearning = (existing?.unit ?? unit).trim();
|
||||
final qtyInExistingUnit = existing == null
|
||||
? null
|
||||
: convertQuantity(qty, unit, existing.unit);
|
||||
if (existing != null && qtyInExistingUnit != null) {
|
||||
await invRepo.updateInventoryItem(
|
||||
existing.id,
|
||||
{'quantity': existing.quantity + qtyInExistingUnit},
|
||||
token: token,
|
||||
);
|
||||
merged++;
|
||||
} else {
|
||||
await invRepo.createInventoryItem({
|
||||
'productId': pid,
|
||||
'quantity': qty,
|
||||
'unit': unit,
|
||||
if (item.brand != null) 'brand': item.brand,
|
||||
}, token: token);
|
||||
created++;
|
||||
}
|
||||
final saveItem = <String, dynamic>{
|
||||
'rawName': item.rawName,
|
||||
'quantity': edit.quantity ?? item.quantity ?? 0,
|
||||
'unit': (edit.unit ?? item.unit ?? 'st').trim(),
|
||||
'destination': edit.destination == _Destination.pantry ? 'pantry' : 'inventory',
|
||||
'productId': pid,
|
||||
};
|
||||
|
||||
if (originalUnit.isNotEmpty && preferredUnitForLearning.isNotEmpty) {
|
||||
try {
|
||||
await repo.upsertUnitMapping(
|
||||
productId: pid,
|
||||
originalUnit: originalUnit,
|
||||
preferredUnit: preferredUnitForLearning,
|
||||
token: token,
|
||||
);
|
||||
if (originalUnit.toLowerCase().trim() != preferredUnitForLearning.toLowerCase().trim()) {
|
||||
unitMappingsLearned++;
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('ReceiptImportTab unit mapping upsert failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
}
|
||||
// Lägg till optional fält
|
||||
if (item.price != null) saveItem['price'] = item.price;
|
||||
if (item.brand != null) saveItem['brand'] = item.brand;
|
||||
if (item.origin != null) saveItem['origin'] = item.origin;
|
||||
|
||||
// Päckfält för inventory
|
||||
if (edit.destination == _Destination.inventory) {
|
||||
if (edit.packQuantity != null) saveItem['packQuantity'] = edit.packQuantity;
|
||||
if (edit.packUnit != null) saveItem['packUnit'] = edit.packUnit;
|
||||
if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount;
|
||||
}
|
||||
|
||||
final normalizedReceiptName = item.rawName.trim().toLowerCase();
|
||||
// Spara alias för alla användare (user-scope) när raden inte redan matchades via alias,
|
||||
// eller admin sparar global alias.
|
||||
// Lär in alias om den inte redan matchades via alias
|
||||
final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid;
|
||||
final shouldLearnAlias = normalizedReceiptName.isNotEmpty && !alreadyAliasMatch;
|
||||
if (shouldLearnAlias) {
|
||||
try {
|
||||
await adminRepo.upsertReceiptAlias(
|
||||
receiptName: normalizedReceiptName,
|
||||
productId: pid,
|
||||
isGlobal: canManageAliases,
|
||||
);
|
||||
aliasesLearned++;
|
||||
} catch (e, st) {
|
||||
debugPrint('ReceiptImportTab alias upsert failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
if (item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
|
||||
saveItem['learnAlias'] = true;
|
||||
}
|
||||
|
||||
// Lär in enhetsmappning för inventory
|
||||
if (edit.destination == _Destination.inventory) {
|
||||
final originalUnit = (item.unit ?? '').trim().toLowerCase();
|
||||
final preferredUnit = (edit.unit ?? item.unit ?? 'st').trim().toLowerCase();
|
||||
if (originalUnit.isNotEmpty && preferredUnit.isNotEmpty && originalUnit != preferredUnit) {
|
||||
saveItem['learnUnitMapping'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
saveItems.add(saveItem);
|
||||
}
|
||||
|
||||
// Gör ett enda anrop till saveReceipt
|
||||
final response = await repo.saveReceipt(
|
||||
items: saveItems,
|
||||
isAdminLearning: canManageAliases,
|
||||
token: token,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Visa feedback från response
|
||||
final created = response['created'] as int? ?? 0;
|
||||
final merged = response['merged'] as int? ?? 0;
|
||||
final pantryAdded = response['pantryAdded'] as int? ?? 0;
|
||||
final pantrySkipped = response['pantrySkipped'] as int? ?? 0;
|
||||
final aliasesLearned = response['aliasesLearned'] as int? ?? 0;
|
||||
final unitMappingsLearned = response['unitMappingsLearned'] as int? ?? 0;
|
||||
final errors = response['errors'] as List? ?? [];
|
||||
|
||||
final parts = <String>[
|
||||
if (created > 0) '$created ny${created == 1 ? '' : 'a'} i inventarie',
|
||||
if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie',
|
||||
@@ -582,9 +549,25 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
|
||||
if (unitMappingsLearned > 0) '$unitMappingsLearned enhetsmappningar inlärda',
|
||||
];
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
final errorParts = <String>[];
|
||||
for (final err in errors) {
|
||||
final index = err['index'] as int?;
|
||||
final error = err['error'] as String?;
|
||||
if (index != null && error != null) {
|
||||
errorParts.add('Rad $index: $error');
|
||||
}
|
||||
}
|
||||
if (errorParts.isNotEmpty) {
|
||||
parts.add('⚠️ ${errorParts.join(', ')}');
|
||||
}
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(parts.join(', ') + '.')),
|
||||
);
|
||||
|
||||
// Avmarkera sparade rader och uppdatera inventariet
|
||||
final notifier = ref.read(receiptImportSessionProvider.notifier);
|
||||
notifier.setSelectedForIndexes(toAdd, false);
|
||||
|
||||
Reference in New Issue
Block a user