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

This commit is contained in:
Nils-Johan Gynther
2026-05-09 23:41:42 +02:00
parent b342de906e
commit 65137b41fb
17 changed files with 388 additions and 67 deletions
+13 -4
View File
@@ -29,11 +29,20 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen
## Huvudprioriteringar ## Huvudprioriteringar
1. Aliasstrategi i kvittoimport: user-scope som standard, global fallback via admin. 1. 🟡 Aliasstrategi i kvittoimport: DELVIS GENOMFÖRD (2026-05-09)
- Målbild: - Målbild:
- Vanliga användare skapar och använder privata alias som bara gäller deras egna importer. - Vanliga användare skapar och använder privata alias som bara gäller deras egna importer.
- Admin kan dessutom skapa globala alias som fungerar som fallback för alla användare. - Admin kan dessutom skapa globala alias som fungerar som fallback för alla användare.
- Matchningsordning ska alltid vara: user alias -> global alias -> vanlig produktmatchning. - Matchningsordning ska alltid vara: user alias -> global alias -> vanlig produktmatchning.
- Genomfört nu:
- Gemensam aliasnormalisering införd för lookup, upsert och alias-inlärning.
- Guardrails införda för tomma alias och brusalias som `rabatt`, `summa`, `pant`.
- Receipt import lär inte längre in alias automatiskt; användaren måste välja det explicit i edit-dialogen.
- Aliasöversikter i Flutter visar nu scope tydligare (`privat` vs `global fallback`).
- Tester tillagda för normalisering, prioritet och behörighet.
- Kvar:
- Manuell verifiering i produktionslik miljö av aliasflödet under riktig receipt import.
- Eventuell vidareutveckling av separat aliasöversikt om behov uppstår.
- Backend: - Backend:
- Centralisera normalisering av `receiptName` så samma regler används i lookup, upsert och alias-inlärning. - Centralisera normalisering av `receiptName` så samma regler används i lookup, upsert och alias-inlärning.
- Härda guardrails för alias: blockera tomma alias, uppenbart brus (`rabatt`, `summa`, `pant`) och andra olämpliga kvittonamn. - Härda guardrails för alias: blockera tomma alias, uppenbart brus (`rabatt`, `summa`, `pant`) och andra olämpliga kvittonamn.
@@ -50,9 +59,9 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen
- Verifiera att manuell korrigering + `learnAlias` ger direkt träff vid nästa import. - Verifiera att manuell korrigering + `learnAlias` ger direkt träff vid nästa import.
- Verifiera att normalisering gör alias robust mot versaler, whitespace och enklare stavningsvariationer. - Verifiera att normalisering gör alias robust mot versaler, whitespace och enklare stavningsvariationer.
- Leveransordning: - Leveransordning:
- Fas 1: backend-hardening + tester. - Fas 1: backend-hardening + tester.
- Fas 2: UI-stöd i receipt import för alias-inlärning. - Fas 2: UI-stöd i receipt import för alias-inlärning.
- Fas 3: separat aliasöversikt för användare och admin. - 🟡 Fas 3: separat aliasöversikt för användare och admin (grund finns, kan vidareutvecklas vid behov).
2.**[CLEANUP] Receipt import legacy code (2026-05-09):** KLART 2.**[CLEANUP] Receipt import legacy code (2026-05-09):** KLART
- Borttaget: `matchProducts()`, `enrichWithAiCategories()`, `findWordMatch()` (gammal), m.fl. - Borttaget: `matchProducts()`, `enrichWithAiCategories()`, `findWordMatch()` (gammal), m.fl.
- Tester uppdaterade och gröna (66/66) - Tester uppdaterade och gröna (66/66)
+62
View File
@@ -158,6 +158,68 @@ Fil: `deploy.sh`
3. UI för users: Om private rename/merge ska exponeras i användar-app (backend redan klart, saknas bara UI) 3. UI för users: Om private rename/merge ska exponeras i användar-app (backend redan klart, saknas bara UI)
4. Unit/integration tests för private endpoints 4. Unit/integration tests för private endpoints
---
# Sessionlogg: Aliasstrategi i kvittoimport (samma dag, senare)
## Mål under denna del
- Göra aliasstrategin konsekvent med user-scope som standard och global fallback via admin.
- Sluta lära alias automatiskt vid manuell korrigering och kräva explicit val i UI.
- Härda backend mot brusiga eller ogiltiga alias.
## Genomförda förändringar
### 1) Gemensam aliasnormalisering och guardrails (backend)
Filer:
- `backend/src/common/utils/receipt-alias.ts`
- `backend/src/receipt-alias/receipt-alias.service.ts`
- `backend/src/receipt-import/receipt-import.service.ts`
- Infört gemensam utility för aliasnormalisering (`trim`, lowercase, kollapsad whitespace).
- Infört validering som blockerar tomma alias och brusiga alias som `rabatt`, `summa`, `pant`, `att betala`, `totalt`, m.fl.
- Receipt import och alias-API använder nu samma regler för både lookup och sparande.
### 2) Receipt import lär inte längre alias automatiskt (Flutter)
Filer:
- `flutter/lib/features/import/data/receipt_import_session.dart`
- `flutter/lib/features/import/presentation/edit_dialog.dart`
- `flutter/lib/features/import/presentation/receipt_import_tab.dart`
- Infört explicit `learnAlias`-val i edit-dialogen.
- Alias sparas nu bara om användaren aktivt markerar att kvittonamnet ska läras in.
- Valet persisteras i receipt import-sessionen så att tabbyte inte tappar användarens val.
- Om raden redan matchades via alias visas förklarande text i stället för ny aliasinlärning.
### 3) Aliasöversikter visar scope tydligare (Flutter)
Filer:
- `flutter/lib/features/admin/domain/receipt_alias.dart`
- `flutter/lib/features/profile/presentation/user_aliases_screen.dart`
- `flutter/lib/features/admin/presentation/admin_aliases_panel.dart`
- Aliasmodellen utökad med `ownerId` och `isGlobal`.
- User alias-skärmen visar nu skillnad mellan `Privat alias` och `Global fallback`.
- Delete-knapp visas bara för privata alias i användarvyn, så UI:t matchar backend-behörigheten.
- Adminpanelen visar scope även för aliasposter.
### 4) Tester för aliasflödet
Filer:
- `backend/src/receipt-import/receipt-import.service.spec.ts`
- `backend/src/receipt-alias/receipt-alias.service.spec.ts`
- Tester tillagda för normalisering av whitespace vid alias-lookup.
- Tester tillagda för alias-upsert med normalisering.
- Tester tillagda för blockering av brusalias.
- Tester tillagda för behörighetsregler kring globala alias och borttagning.
## Verifiering
- ✅ Backend tests: 31/31 gröna för berörda aliasspecar
- ✅ Flutter analyze: OK för alla berörda alias/import-filer
## Kvar att göra
1. Manuell test i appen: receipt import med explicit alias-inlärning.
2. Produktionstest: verifiera att privata alias och global fallback beter sig rätt mot riktiga kvitton.
3. Bedöm om aliasöversikterna behöver mer avancerad filtrering eller redigering senare.
## Snabb checklista för nästa session ## Snabb checklista för nästa session
- [ ] Deploy backend + Flutter - [ ] Deploy backend + Flutter
- [ ] Testa scroll-fix i prod - [ ] Testa scroll-fix i prod
+39
View File
@@ -0,0 +1,39 @@
const ignoredReceiptAliasPatterns = [
/^rabatt\b/,
/^summa\b/,
/^moms\b/,
/^pant\b/,
/^att\s+betala\b/,
/^totalt\b/,
/^kort\b/,
/^kontant\b/,
/^willys\s+plus\s*[:\-]?\b/,
];
export function normalizeReceiptAliasName(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
}
export function isIgnoredReceiptAliasName(value: string | null | undefined): boolean {
const normalized = normalizeReceiptAliasName(value);
if (!normalized) return false;
return ignoredReceiptAliasPatterns.some((pattern) => pattern.test(normalized));
}
export function validateReceiptAliasName(value: string | null | undefined): string | null {
const normalized = normalizeReceiptAliasName(value);
if (!normalized) {
return 'Alias får inte vara tomt.';
}
if (!/[a-z0-9åäö]/.test(normalized)) {
return 'Alias måste innehålla bokstäver eller siffror.';
}
if (ignoredReceiptAliasPatterns.some((pattern) => pattern.test(normalized))) {
return 'Aliaset ser ut som en kvittorad som ska ignoreras och kan inte sparas.';
}
return null;
}
@@ -0,0 +1,67 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { ReceiptAliasService } from './receipt-alias.service';
describe('ReceiptAliasService', () => {
const prismaMock = {
receiptAlias: {
findMany: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
};
const service = new ReceiptAliasService(prismaMock as any);
beforeEach(() => {
jest.clearAllMocks();
});
it('normaliserar alias före upsert', async () => {
prismaMock.receiptAlias.findFirst.mockResolvedValue(null);
prismaMock.receiptAlias.create.mockResolvedValue({ id: 1 });
await service.upsert(
{ receiptName: ' ARLA MJOLK 1L ', productId: 7 },
10,
'user',
);
expect(prismaMock.receiptAlias.findFirst).toHaveBeenCalledWith({
where: { receiptName: 'arla mjolk 1l', ownerId: 10, isGlobal: false },
});
expect(prismaMock.receiptAlias.create).toHaveBeenCalledWith({
data: { receiptName: 'arla mjolk 1l', productId: 7, ownerId: 10, isGlobal: false },
});
});
it('blockerar brusalias', async () => {
await expect(
service.upsert({ receiptName: ' Rabatt kupong ', productId: 7 }, 10, 'user'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('tillåter inte vanlig användare att skapa globalt alias', async () => {
await expect(
service.upsert({ receiptName: 'mjolk 1l', productId: 7, isGlobal: true }, 10, 'user'),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('tillåter inte vanlig användare att ta bort globalt alias', async () => {
prismaMock.receiptAlias.findUnique.mockResolvedValue({
id: 9,
ownerId: null,
isGlobal: true,
});
await expect(service.remove(9, 10, 'user')).rejects.toBeInstanceOf(ForbiddenException);
});
it('returnerar not found vid borttagning av okänt alias', async () => {
prismaMock.receiptAlias.findUnique.mockResolvedValue(null);
await expect(service.remove(99, 10, 'admin')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -1,6 +1,15 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
import {
normalizeReceiptAliasName,
validateReceiptAliasName,
} from '../common/utils/receipt-alias';
@Injectable() @Injectable()
export class ReceiptAliasService { export class ReceiptAliasService {
@@ -24,7 +33,11 @@ export class ReceiptAliasService {
} }
async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) { async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) {
const normalized = dto.receiptName.toLowerCase().trim(); const normalized = normalizeReceiptAliasName(dto.receiptName);
const validationError = validateReceiptAliasName(normalized);
if (validationError) {
throw new BadRequestException(validationError);
}
const wantsGlobal = dto.isGlobal === true; const wantsGlobal = dto.isGlobal === true;
if (wantsGlobal && role !== 'admin') { if (wantsGlobal && role !== 'admin') {
@@ -45,10 +58,11 @@ export class ReceiptAliasService {
ownerId: number | null, ownerId: number | null,
isGlobal: boolean, isGlobal: boolean,
) { ) {
const normalizedReceiptName = normalizeReceiptAliasName(receiptName);
const existing = await this.prisma.receiptAlias.findFirst({ const existing = await this.prisma.receiptAlias.findFirst({
where: isGlobal where: isGlobal
? { receiptName, isGlobal: true } ? { receiptName: normalizedReceiptName, isGlobal: true }
: { receiptName, ownerId, isGlobal: false }, : { receiptName: normalizedReceiptName, ownerId, isGlobal: false },
}); });
if (existing) { if (existing) {
@@ -59,7 +73,7 @@ export class ReceiptAliasService {
} }
return this.prisma.receiptAlias.create({ return this.prisma.receiptAlias.create({
data: { receiptName, productId, ownerId, isGlobal }, data: { receiptName: normalizedReceiptName, productId, ownerId, isGlobal },
}); });
} }
@@ -60,6 +60,10 @@ export class SaveReceiptItemDto {
@IsBoolean() @IsBoolean()
learnAlias?: boolean; learnAlias?: boolean;
@IsOptional()
@IsBoolean()
learnAliasGlobally?: boolean;
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
learnUnitMapping?: boolean; learnUnitMapping?: boolean;
@@ -70,8 +74,4 @@ export class SaveReceiptDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => SaveReceiptItemDto) @Type(() => SaveReceiptItemDto)
items!: SaveReceiptItemDto[]; items!: SaveReceiptItemDto[];
@IsOptional()
@IsBoolean()
isAdminLearning?: boolean;
} }
@@ -102,7 +102,7 @@ export class ReceiptImportController {
} }
const isAdmin = req?.user?.role === 'admin'; const isAdmin = req?.user?.role === 'admin';
if (dto.isAdminLearning && !isAdmin) { if (dto.items.some((item) => item.learnAliasGlobally === true) && !isAdmin) {
throw new BadRequestException('Endast administratörer kan spara globala aliaser.'); throw new BadRequestException('Endast administratörer kan spara globala aliaser.');
} }
@@ -165,6 +165,28 @@ describe('ReceiptImportService test matrix', () => {
expect(result.matchedProductName).toBe('Snickers'); expect(result.matchedProductName).toBe('Snickers');
}); });
it('normaliserar whitespace vid alias-lookup', async () => {
const aliases = [
{
receiptName: 'arla mjolk 1l',
productId: 700,
product: {
id: 700,
name: 'Arla Mjolk 1l',
canonicalName: 'Mjolk',
categoryId: 30,
categoryRef: { id: 30, name: 'Mejeri' },
},
},
];
const context = makeContext(aliases, [], [], 42);
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: ' ARLA MJOLK 1L ' }, context);
expect(result.matchedProductId).toBe(700);
expect(result.matchedVia).toBe('alias');
});
it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => { it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => {
const products = [ const products = [
{ {
@@ -12,6 +12,11 @@ import { SaveReceiptResponse } from './dto/save-receipt.response';
import { AiService, CategorySuggestion } from '../ai/ai.service'; import { AiService, CategorySuggestion } from '../ai/ai.service';
import { CategoriesService } from '../categories/categories.service'; import { CategoriesService } from '../categories/categories.service';
import { normalizeName } from '../common/utils/normalize-name'; import { normalizeName } from '../common/utils/normalize-name';
import {
isIgnoredReceiptAliasName,
normalizeReceiptAliasName,
validateReceiptAliasName,
} from '../common/utils/receipt-alias';
const IMPORTER_SERVICE_URL = const IMPORTER_SERVICE_URL =
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001'; process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
@@ -37,20 +42,7 @@ function tokenize(value: string): string[] {
} }
export function isIgnoredReceiptName(value: string | null | undefined): boolean { export function isIgnoredReceiptName(value: string | null | undefined): boolean {
const normalized = (value ?? '').trim().toLowerCase(); return isIgnoredReceiptAliasName(value);
if (!normalized) return false;
if (/^rabatt\b/.test(normalized)) return true;
if (/^summa\b/.test(normalized)) return true;
if (/^moms\b/.test(normalized)) return true;
if (/^pant\b/.test(normalized)) return true;
if (/^att\s+betala\b/.test(normalized)) return true;
if (/^totalt\b/.test(normalized)) return true;
if (/^kort\b/.test(normalized)) return true;
if (/^kontant\b/.test(normalized)) return true;
if (/^willys\s+plus\s*[:\-]?\b/.test(normalized)) return true;
return false;
} }
function normalizeToken(s: string): string { function normalizeToken(s: string): string {
@@ -220,8 +212,10 @@ export class ReceiptImportService {
const aliasByReceiptName = new Map<string, AliasLite>(); const aliasByReceiptName = new Map<string, AliasLite>();
for (const alias of aliases) { for (const alias of aliases) {
if (!aliasByReceiptName.has(alias.receiptName)) { const normalizedReceiptName = normalizeReceiptAliasName(alias.receiptName);
aliasByReceiptName.set(alias.receiptName, alias); if (!normalizedReceiptName) continue;
if (!aliasByReceiptName.has(normalizedReceiptName)) {
aliasByReceiptName.set(normalizedReceiptName, alias);
} }
} }
@@ -472,15 +466,20 @@ export class ReceiptImportService {
// === Steg 4: Lär in alias om requested === // === Steg 4: Lär in alias om requested ===
if (item.learnAlias) { if (item.learnAlias) {
const normalizedReceiptName = (item.rawName ?? '').trim().toLowerCase(); const normalizedReceiptName = normalizeReceiptAliasName(item.rawName);
const aliasValidationError = validateReceiptAliasName(normalizedReceiptName);
if (aliasValidationError) {
throw new Error(aliasValidationError);
}
if (normalizedReceiptName) { if (normalizedReceiptName) {
const aliasOwnerId: number | null = dto.isAdminLearning ? null : userId || null; const isGlobalAlias = item.learnAliasGlobally === true;
const aliasOwnerId: number | null = isGlobalAlias ? null : userId || null;
await tx.receiptAlias.upsert({ await tx.receiptAlias.upsert({
where: { where: {
receiptName_ownerId_isGlobal: { receiptName_ownerId_isGlobal: {
receiptName: normalizedReceiptName, receiptName: normalizedReceiptName,
ownerId: aliasOwnerId as any, ownerId: aliasOwnerId as any,
isGlobal: dto.isAdminLearning ? true : false, isGlobal: isGlobalAlias,
}, },
}, },
update: { update: {
@@ -489,8 +488,8 @@ export class ReceiptImportService {
create: { create: {
receiptName: normalizedReceiptName, receiptName: normalizedReceiptName,
productId, productId,
ownerId: (dto.isAdminLearning ? null : userId || null) as any, ownerId: aliasOwnerId as any,
isGlobal: dto.isAdminLearning ? true : false, isGlobal: isGlobalAlias,
}, },
}); });
response.aliasesLearned++; response.aliasesLearned++;
@@ -573,7 +572,7 @@ export class ReceiptImportService {
): Promise<ParsedReceiptItem> { ): Promise<ParsedReceiptItem> {
if (!item.rawName) return item; if (!item.rawName) return item;
const raw = item.rawName.toLowerCase().trim(); const raw = normalizeReceiptAliasName(item.rawName);
const debug: MatchDebug = { steps: [], tree: {} }; const debug: MatchDebug = { steps: [], tree: {} };
try { try {
@@ -173,6 +173,9 @@ class AdminRepository {
Future<List<AdminProduct>> listProducts() => Future<List<AdminProduct>> listProducts() =>
_getList(ProductApiPaths.mine, AdminProduct.fromJson); _getList(ProductApiPaths.mine, AdminProduct.fromJson);
Future<List<AdminProduct>> listGlobalProducts() =>
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
Future<List<AdminProduct>> listDeletedProducts() => Future<List<AdminProduct>> listDeletedProducts() =>
_getList(ProductApiPaths.deleted, AdminProduct.fromJson); _getList(ProductApiPaths.deleted, AdminProduct.fromJson);
@@ -2,6 +2,8 @@ class ReceiptAlias {
final int id; final int id;
final String receiptName; final String receiptName;
final int productId; final int productId;
final int? ownerId;
final bool isGlobal;
final String? productName; final String? productName;
final String? productCanonicalName; final String? productCanonicalName;
@@ -9,10 +11,14 @@ class ReceiptAlias {
required this.id, required this.id,
required this.receiptName, required this.receiptName,
required this.productId, required this.productId,
required this.ownerId,
required this.isGlobal,
this.productName, this.productName,
this.productCanonicalName, this.productCanonicalName,
}); });
bool get isPrivate => !isGlobal;
String get displayProductName { String get displayProductName {
final canonical = productCanonicalName?.trim(); final canonical = productCanonicalName?.trim();
if (canonical != null && canonical.isNotEmpty) return canonical; if (canonical != null && canonical.isNotEmpty) return canonical;
@@ -33,6 +39,8 @@ class ReceiptAlias {
productId: (json['productId'] as num?)?.toInt() ?? productId: (json['productId'] as num?)?.toInt() ??
(productMap['id'] as num?)?.toInt() ?? (productMap['id'] as num?)?.toInt() ??
0, 0,
ownerId: (json['ownerId'] as num?)?.toInt(),
isGlobal: json['isGlobal'] == true,
productName: productMap['name']?.toString(), productName: productMap['name']?.toString(),
productCanonicalName: productMap['canonicalName']?.toString(), productCanonicalName: productMap['canonicalName']?.toString(),
); );
@@ -46,7 +46,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
try { try {
final results = await Future.wait<dynamic>([ final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listReceiptAliases(), ref.read(adminRepositoryProvider).listReceiptAliases(),
ref.read(adminRepositoryProvider).listProducts(), ref.read(adminRepositoryProvider).listGlobalProducts(),
]); ]);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -67,7 +67,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
} }
Future<void> _upsertAlias() async { Future<void> _upsertAlias() async {
final rawAlias = _aliasController.text.trim().toLowerCase(); final rawAlias = _aliasController.text.trim();
final productId = _selectedProductId; final productId = _selectedProductId;
if (rawAlias.isEmpty || productId == null) { if (rawAlias.isEmpty || productId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -186,6 +186,11 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
color: Theme.of(context).colorScheme.outline, color: Theme.of(context).colorScheme.outline,
), ),
), ),
const SizedBox(height: 4),
Chip(
visualDensity: VisualDensity.compact,
label: Text(alias.isGlobal ? 'Global' : 'Privat'),
),
], ],
), ),
trailing: IconButton( 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( final content = Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -265,7 +265,6 @@ class ImportRepository {
/// - Learning unit mappings /// - Learning unit mappings
Future<Map<String, dynamic>> saveReceipt({ Future<Map<String, dynamic>> saveReceipt({
required List<Map<String, dynamic>> items, required List<Map<String, dynamic>> items,
bool isAdminLearning = false,
String? token, String? token,
}) async { }) async {
try { try {
@@ -280,7 +279,6 @@ class ImportRepository {
}, },
body: jsonEncode({ body: jsonEncode({
'items': items, 'items': items,
if (isAdminLearning) 'isAdminLearning': true,
}), }),
).timeout( ).timeout(
const Duration(seconds: 60), const Duration(seconds: 60),
@@ -16,6 +16,8 @@ enum CategorySelectionSource { ai, manual }
class ItemEdit { class ItemEdit {
final int? productId; final int? productId;
final String? productName; final String? productName;
final bool learnAlias;
final bool learnAliasGlobally;
final int? categoryId; final int? categoryId;
final String? categoryPath; final String? categoryPath;
final CategorySelectionSource? categorySource; final CategorySelectionSource? categorySource;
@@ -29,6 +31,8 @@ class ItemEdit {
const ItemEdit({ const ItemEdit({
this.productId, this.productId,
this.productName, this.productName,
this.learnAlias = false,
this.learnAliasGlobally = false,
this.categoryId, this.categoryId,
this.categoryPath, this.categoryPath,
this.categorySource, this.categorySource,
@@ -85,6 +89,8 @@ class ReceiptImportSession {
'edits': edits.map((key, value) => MapEntry(key.toString(), { 'edits': edits.map((key, value) => MapEntry(key.toString(), {
'productId': value.productId, 'productId': value.productId,
'productName': value.productName, 'productName': value.productName,
'learnAlias': value.learnAlias,
'learnAliasGlobally': value.learnAliasGlobally,
'categoryId': value.categoryId, 'categoryId': value.categoryId,
'categoryPath': value.categoryPath, 'categoryPath': value.categoryPath,
'categorySource': value.categorySource?.name, 'categorySource': value.categorySource?.name,
@@ -114,6 +120,8 @@ class ReceiptImportSession {
edits[idx] = ItemEdit( edits[idx] = ItemEdit(
productId: (value['productId'] as num?)?.toInt(), productId: (value['productId'] as num?)?.toInt(),
productName: value['productName'] as String?, productName: value['productName'] as String?,
learnAlias: value['learnAlias'] == true,
learnAliasGlobally: value['learnAliasGlobally'] == true,
categoryId: (value['categoryId'] as num?)?.toInt(), categoryId: (value['categoryId'] as num?)?.toInt(),
categoryPath: value['categoryPath'] as String?, categoryPath: value['categoryPath'] as String?,
categorySource: switch (value['categorySource']) { categorySource: switch (value['categorySource']) {
@@ -19,6 +19,7 @@ class EditDialog extends StatefulWidget {
final List<AdminCategoryNode> categoryTree; final List<AdminCategoryNode> categoryTree;
final Future<ProductOption?> Function(String name, int? categoryId)? onCreate; final Future<ProductOption?> Function(String name, int? categoryId)? onCreate;
final ImportProductEntryMode? initialEntryMode; final ImportProductEntryMode? initialEntryMode;
final bool canLearnGlobalAlias;
const EditDialog({ const EditDialog({
super.key, super.key,
@@ -28,6 +29,7 @@ class EditDialog extends StatefulWidget {
required this.categoryTree, required this.categoryTree,
this.onCreate, this.onCreate,
this.initialEntryMode, this.initialEntryMode,
this.canLearnGlobalAlias = false,
}); });
@override @override
@@ -53,6 +55,8 @@ class _EditDialogState extends State<EditDialog> {
_Destination _destination = _Destination.inventory; _Destination _destination = _Destination.inventory;
ImportProductEntryMode _entryMode = ImportProductEntryMode.existing; ImportProductEntryMode _entryMode = ImportProductEntryMode.existing;
bool _isCreatingProduct = false; bool _isCreatingProduct = false;
bool _learnAlias = false;
bool _learnAliasGlobally = false;
// Lokal lista — utökas om nya produkter skapas under dialogen // Lokal lista — utökas om nya produkter skapas under dialogen
late List<ProductOption> _localProducts; late List<ProductOption> _localProducts;
@@ -68,6 +72,8 @@ class _EditDialogState extends State<EditDialog> {
_productName = widget.current.productName == null _productName = widget.current.productName == null
? null ? null
: normalizeProductName(widget.current.productName!); : normalizeProductName(widget.current.productName!);
_learnAlias = widget.current.learnAlias;
_learnAliasGlobally = widget.current.learnAliasGlobally;
_destination = widget.current.destination; _destination = widget.current.destination;
_entryMode = widget.initialEntryMode ?? _entryMode = widget.initialEntryMode ??
(_productId == null (_productId == null
@@ -273,6 +279,8 @@ class _EditDialogState extends State<EditDialog> {
ItemEdit( ItemEdit(
productId: _productId, productId: _productId,
productName: _productName, productName: _productName,
learnAlias: _learnAlias,
learnAliasGlobally: _learnAlias && widget.canLearnGlobalAlias && _learnAliasGlobally,
categoryId: _productCategoryId, categoryId: _productCategoryId,
categoryPath: _productCategoryPath, categoryPath: _productCategoryPath,
categorySource: _productCategorySource, categorySource: _productCategorySource,
@@ -358,6 +366,8 @@ class _EditDialogState extends State<EditDialog> {
else else
_buildCreateProductSection(theme, aiLabel), _buildCreateProductSection(theme, aiLabel),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildAliasSection(theme, item),
const SizedBox(height: 12),
if (_destination == _Destination.inventory) if (_destination == _Destination.inventory)
_buildQuantitySection(theme, totalPreview, currentUnit) _buildQuantitySection(theme, totalPreview, currentUnit)
else else
@@ -429,6 +439,70 @@ class _EditDialogState extends State<EditDialog> {
style: const ButtonStyle(visualDensity: VisualDensity.compact), 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( Widget _buildExistingProductSection(
ThemeData theme, ThemeData theme,
ParsedReceiptItem item, ParsedReceiptItem item,
@@ -402,6 +402,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
products: _products, products: _products,
categoryTree: _categoryTree, categoryTree: _categoryTree,
initialEntryMode: initialEntryMode, initialEntryMode: initialEntryMode,
canLearnGlobalAlias: ref.read(isAdminProvider),
onCreate: (name, categoryId) async { onCreate: (name, categoryId) async {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
@@ -478,7 +479,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
try { try {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider); final repo = ref.read(importRepositoryProvider);
final canManageAliases = ref.read(isAdminProvider);
// Bygg upp items för saveReceipt endpoint // Bygg upp items för saveReceipt endpoint
final saveItems = <Map<String, dynamic>>[]; final saveItems = <Map<String, dynamic>>[];
@@ -507,10 +507,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount; 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; 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; saveItem['learnAlias'] = true;
if (edit.learnAliasGlobally) {
saveItem['learnAliasGlobally'] = true;
}
} }
// Lär in enhetsmappning för inventory // Lär in enhetsmappning för inventory
@@ -528,7 +531,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
// Gör ett enda anrop till saveReceipt // Gör ett enda anrop till saveReceipt
final response = await repo.saveReceipt( final response = await repo.saveReceipt(
items: saveItems, items: saveItems,
isAdminLearning: canManageAliases,
token: token, token: token,
); );
@@ -31,7 +31,11 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
final aliases = await ref.read(adminRepositoryProvider).listReceiptAliases(); final aliases = await ref.read(adminRepositoryProvider).listReceiptAliases();
if (!mounted) return; if (!mounted) return;
setState(() { 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) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -118,7 +122,7 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( 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), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
@@ -135,23 +139,41 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
final alias = _aliases[i]; final alias = _aliases[i];
return ListTile( return ListTile(
leading: Icon( leading: Icon(
Icons.link_outlined, alias.isGlobal ? Icons.public_outlined : Icons.link_outlined,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
), ),
title: Text( title: Text(
alias.receiptName, alias.receiptName,
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
), ),
subtitle: Text( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${alias.displayProductName}', '${alias.displayProductName}',
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
trailing: IconButton( 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), icon: const Icon(Icons.delete_outline),
tooltip: 'Ta bort alias', tooltip: 'Ta bort alias',
color: theme.colorScheme.error, color: theme.colorScheme.error,
onPressed: () => _delete(alias), onPressed: () => _delete(alias),
), )
: null,
); );
}, },
); );