feat: implement alias strategy for receipt import with user-scoped and global fallback, enhance validation and normalization, and update UI components
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:
@@ -173,6 +173,9 @@ class AdminRepository {
|
||||
Future<List<AdminProduct>> listProducts() =>
|
||||
_getList(ProductApiPaths.mine, AdminProduct.fromJson);
|
||||
|
||||
Future<List<AdminProduct>> listGlobalProducts() =>
|
||||
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
|
||||
|
||||
Future<List<AdminProduct>> listDeletedProducts() =>
|
||||
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ class ReceiptAlias {
|
||||
final int id;
|
||||
final String receiptName;
|
||||
final int productId;
|
||||
final int? ownerId;
|
||||
final bool isGlobal;
|
||||
final String? productName;
|
||||
final String? productCanonicalName;
|
||||
|
||||
@@ -9,10 +11,14 @@ class ReceiptAlias {
|
||||
required this.id,
|
||||
required this.receiptName,
|
||||
required this.productId,
|
||||
required this.ownerId,
|
||||
required this.isGlobal,
|
||||
this.productName,
|
||||
this.productCanonicalName,
|
||||
});
|
||||
|
||||
bool get isPrivate => !isGlobal;
|
||||
|
||||
String get displayProductName {
|
||||
final canonical = productCanonicalName?.trim();
|
||||
if (canonical != null && canonical.isNotEmpty) return canonical;
|
||||
@@ -33,6 +39,8 @@ class ReceiptAlias {
|
||||
productId: (json['productId'] as num?)?.toInt() ??
|
||||
(productMap['id'] as num?)?.toInt() ??
|
||||
0,
|
||||
ownerId: (json['ownerId'] as num?)?.toInt(),
|
||||
isGlobal: json['isGlobal'] == true,
|
||||
productName: productMap['name']?.toString(),
|
||||
productCanonicalName: productMap['canonicalName']?.toString(),
|
||||
);
|
||||
|
||||
@@ -46,7 +46,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
ref.read(adminRepositoryProvider).listReceiptAliases(),
|
||||
ref.read(adminRepositoryProvider).listProducts(),
|
||||
ref.read(adminRepositoryProvider).listGlobalProducts(),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
@@ -67,7 +67,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
||||
}
|
||||
|
||||
Future<void> _upsertAlias() async {
|
||||
final rawAlias = _aliasController.text.trim().toLowerCase();
|
||||
final rawAlias = _aliasController.text.trim();
|
||||
final productId = _selectedProductId;
|
||||
if (rawAlias.isEmpty || productId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -186,6 +186,11 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(alias.isGlobal ? 'Global' : 'Privat'),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
@@ -198,17 +203,6 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAliasList({EdgeInsetsGeometry padding = EdgeInsets.zero}) {
|
||||
return ListView.builder(
|
||||
padding: padding,
|
||||
itemCount: filteredAliases.length,
|
||||
itemBuilder: (context, index) {
|
||||
final alias = filteredAliases[index];
|
||||
return buildAliasCard(alias);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -265,7 +265,6 @@ class ImportRepository {
|
||||
/// - Learning unit mappings
|
||||
Future<Map<String, dynamic>> saveReceipt({
|
||||
required List<Map<String, dynamic>> items,
|
||||
bool isAdminLearning = false,
|
||||
String? token,
|
||||
}) async {
|
||||
try {
|
||||
@@ -280,7 +279,6 @@ class ImportRepository {
|
||||
},
|
||||
body: jsonEncode({
|
||||
'items': items,
|
||||
if (isAdminLearning) 'isAdminLearning': true,
|
||||
}),
|
||||
).timeout(
|
||||
const Duration(seconds: 60),
|
||||
|
||||
@@ -16,6 +16,8 @@ enum CategorySelectionSource { ai, manual }
|
||||
class ItemEdit {
|
||||
final int? productId;
|
||||
final String? productName;
|
||||
final bool learnAlias;
|
||||
final bool learnAliasGlobally;
|
||||
final int? categoryId;
|
||||
final String? categoryPath;
|
||||
final CategorySelectionSource? categorySource;
|
||||
@@ -29,6 +31,8 @@ class ItemEdit {
|
||||
const ItemEdit({
|
||||
this.productId,
|
||||
this.productName,
|
||||
this.learnAlias = false,
|
||||
this.learnAliasGlobally = false,
|
||||
this.categoryId,
|
||||
this.categoryPath,
|
||||
this.categorySource,
|
||||
@@ -85,6 +89,8 @@ class ReceiptImportSession {
|
||||
'edits': edits.map((key, value) => MapEntry(key.toString(), {
|
||||
'productId': value.productId,
|
||||
'productName': value.productName,
|
||||
'learnAlias': value.learnAlias,
|
||||
'learnAliasGlobally': value.learnAliasGlobally,
|
||||
'categoryId': value.categoryId,
|
||||
'categoryPath': value.categoryPath,
|
||||
'categorySource': value.categorySource?.name,
|
||||
@@ -114,6 +120,8 @@ class ReceiptImportSession {
|
||||
edits[idx] = ItemEdit(
|
||||
productId: (value['productId'] as num?)?.toInt(),
|
||||
productName: value['productName'] as String?,
|
||||
learnAlias: value['learnAlias'] == true,
|
||||
learnAliasGlobally: value['learnAliasGlobally'] == true,
|
||||
categoryId: (value['categoryId'] as num?)?.toInt(),
|
||||
categoryPath: value['categoryPath'] as String?,
|
||||
categorySource: switch (value['categorySource']) {
|
||||
|
||||
@@ -19,6 +19,7 @@ class EditDialog extends StatefulWidget {
|
||||
final List<AdminCategoryNode> categoryTree;
|
||||
final Future<ProductOption?> Function(String name, int? categoryId)? onCreate;
|
||||
final ImportProductEntryMode? initialEntryMode;
|
||||
final bool canLearnGlobalAlias;
|
||||
|
||||
const EditDialog({
|
||||
super.key,
|
||||
@@ -28,6 +29,7 @@ class EditDialog extends StatefulWidget {
|
||||
required this.categoryTree,
|
||||
this.onCreate,
|
||||
this.initialEntryMode,
|
||||
this.canLearnGlobalAlias = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -53,6 +55,8 @@ class _EditDialogState extends State<EditDialog> {
|
||||
_Destination _destination = _Destination.inventory;
|
||||
ImportProductEntryMode _entryMode = ImportProductEntryMode.existing;
|
||||
bool _isCreatingProduct = false;
|
||||
bool _learnAlias = false;
|
||||
bool _learnAliasGlobally = false;
|
||||
|
||||
// Lokal lista — utökas om nya produkter skapas under dialogen
|
||||
late List<ProductOption> _localProducts;
|
||||
@@ -68,6 +72,8 @@ class _EditDialogState extends State<EditDialog> {
|
||||
_productName = widget.current.productName == null
|
||||
? null
|
||||
: normalizeProductName(widget.current.productName!);
|
||||
_learnAlias = widget.current.learnAlias;
|
||||
_learnAliasGlobally = widget.current.learnAliasGlobally;
|
||||
_destination = widget.current.destination;
|
||||
_entryMode = widget.initialEntryMode ??
|
||||
(_productId == null
|
||||
@@ -273,6 +279,8 @@ class _EditDialogState extends State<EditDialog> {
|
||||
ItemEdit(
|
||||
productId: _productId,
|
||||
productName: _productName,
|
||||
learnAlias: _learnAlias,
|
||||
learnAliasGlobally: _learnAlias && widget.canLearnGlobalAlias && _learnAliasGlobally,
|
||||
categoryId: _productCategoryId,
|
||||
categoryPath: _productCategoryPath,
|
||||
categorySource: _productCategorySource,
|
||||
@@ -358,6 +366,8 @@ class _EditDialogState extends State<EditDialog> {
|
||||
else
|
||||
_buildCreateProductSection(theme, aiLabel),
|
||||
const SizedBox(height: 12),
|
||||
_buildAliasSection(theme, item),
|
||||
const SizedBox(height: 12),
|
||||
if (_destination == _Destination.inventory)
|
||||
_buildQuantitySection(theme, totalPreview, currentUnit)
|
||||
else
|
||||
@@ -429,6 +439,70 @@ class _EditDialogState extends State<EditDialog> {
|
||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
);
|
||||
|
||||
Widget _buildAliasSection(ThemeData theme, ParsedReceiptItem item) {
|
||||
final alreadyAliasMatch =
|
||||
_entryMode == ImportProductEntryMode.existing &&
|
||||
_productId != null &&
|
||||
item.matchedVia == 'alias' &&
|
||||
item.matchedProductId == _productId;
|
||||
|
||||
if (alreadyAliasMatch) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'Det här kvittonamnet matchades redan via alias. Ingen ny aliasinlärning behövs.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: _learnAlias,
|
||||
onChanged: (value) => setState(() {
|
||||
_learnAlias = value ?? false;
|
||||
if (!_learnAlias) _learnAliasGlobally = false;
|
||||
}),
|
||||
title: const Text('Lär detta kvittonamn för framtiden'),
|
||||
subtitle: const Text(
|
||||
'Sparar ett alias så att samma kvittonamn kan matchas direkt vid nästa import.',
|
||||
),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
if (widget.canLearnGlobalAlias && _learnAlias)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment<bool>(
|
||||
value: false,
|
||||
label: Text('Privat alias'),
|
||||
icon: Icon(Icons.lock_outline, size: 16),
|
||||
),
|
||||
ButtonSegment<bool>(
|
||||
value: true,
|
||||
label: Text('Global fallback'),
|
||||
icon: Icon(Icons.public_outlined, size: 16),
|
||||
),
|
||||
],
|
||||
selected: {_learnAliasGlobally},
|
||||
onSelectionChanged: (selection) =>
|
||||
setState(() => _learnAliasGlobally = selection.first),
|
||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExistingProductSection(
|
||||
ThemeData theme,
|
||||
ParsedReceiptItem item,
|
||||
|
||||
@@ -402,6 +402,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
products: _products,
|
||||
categoryTree: _categoryTree,
|
||||
initialEntryMode: initialEntryMode,
|
||||
canLearnGlobalAlias: ref.read(isAdminProvider),
|
||||
onCreate: (name, categoryId) async {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final api = ref.read(apiClientProvider);
|
||||
@@ -478,7 +479,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final repo = ref.read(importRepositoryProvider);
|
||||
final canManageAliases = ref.read(isAdminProvider);
|
||||
|
||||
// Bygg upp items för saveReceipt endpoint
|
||||
final saveItems = <Map<String, dynamic>>[];
|
||||
@@ -507,10 +507,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount;
|
||||
}
|
||||
|
||||
// Lär in alias om den inte redan matchades via alias
|
||||
// Lär in alias bara om användaren uttryckligen valt det
|
||||
final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid;
|
||||
if (item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
|
||||
if (edit.learnAlias && item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
|
||||
saveItem['learnAlias'] = true;
|
||||
if (edit.learnAliasGlobally) {
|
||||
saveItem['learnAliasGlobally'] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Lär in enhetsmappning för inventory
|
||||
@@ -528,7 +531,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
// Gör ett enda anrop till saveReceipt
|
||||
final response = await repo.saveReceipt(
|
||||
items: saveItems,
|
||||
isAdminLearning: canManageAliases,
|
||||
token: token,
|
||||
);
|
||||
|
||||
|
||||
@@ -31,7 +31,11 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
||||
final aliases = await ref.read(adminRepositoryProvider).listReceiptAliases();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_aliases = aliases;
|
||||
_aliases = [...aliases]
|
||||
..sort((a, b) {
|
||||
if (a.isGlobal != b.isGlobal) return a.isGlobal ? 1 : -1;
|
||||
return a.receiptName.compareTo(b.receiptName);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -118,7 +122,7 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Alias skapas automatiskt när du sparar kvittorader i inventariet.',
|
||||
'Alias skapas när du väljer att lära in dem under kvittoimporten.',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -135,23 +139,41 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
||||
final alias = _aliases[i];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.link_outlined,
|
||||
alias.isGlobal ? Icons.public_outlined : Icons.link_outlined,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
title: Text(
|
||||
alias.receiptName,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
'→ ${alias.displayProductName}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Ta bort alias',
|
||||
color: theme.colorScheme.error,
|
||||
onPressed: () => _delete(alias),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'→ ${alias.displayProductName}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Chip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(alias.isGlobal ? 'Global fallback' : 'Privat alias'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: alias.isPrivate
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Ta bort alias',
|
||||
color: theme.colorScheme.error,
|
||||
onPressed: () => _delete(alias),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user