From 65137b41fbde1420cbe9531937195efd44efa007 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 9 May 2026 23:41:42 +0200 Subject: [PATCH] feat: implement alias strategy for receipt import with user-scoped and global fallback, enhance validation and normalization, and update UI components --- NEXT_STEPS.md | 17 ++++- SESSION_2026-05-09_RECEIPT_IMPORT.md | 62 ++++++++++++++++ backend/src/common/utils/receipt-alias.ts | 39 ++++++++++ .../receipt-alias.service.spec.ts | 67 +++++++++++++++++ .../receipt-alias/receipt-alias.service.ts | 24 ++++-- .../receipt-import/dto/save-receipt.dto.ts | 8 +- .../receipt-import.controller.ts | 2 +- .../receipt-import.service.spec.ts | 22 ++++++ .../receipt-import/receipt-import.service.ts | 43 ++++++----- .../features/admin/data/admin_repository.dart | 3 + .../features/admin/domain/receipt_alias.dart | 8 ++ .../presentation/admin_aliases_panel.dart | 20 ++--- .../import/data/import_repository.dart | 2 - .../import/data/receipt_import_session.dart | 8 ++ .../import/presentation/edit_dialog.dart | 74 +++++++++++++++++++ .../presentation/receipt_import_tab.dart | 10 ++- .../presentation/user_aliases_screen.dart | 46 +++++++++--- 17 files changed, 388 insertions(+), 67 deletions(-) create mode 100644 backend/src/common/utils/receipt-alias.ts create mode 100644 backend/src/receipt-alias/receipt-alias.service.spec.ts diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index d7d92ba3..690ca3c9 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -29,11 +29,20 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen ## 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: - 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. - 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: - 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. @@ -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 normalisering gör alias robust mot versaler, whitespace och enklare stavningsvariationer. - Leveransordning: - - Fas 1: backend-hardening + tester. - - 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 1: backend-hardening + tester. + - ✅ Fas 2: UI-stöd i receipt import för alias-inlĂ€rning. + - 🟡 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 - Borttaget: `matchProducts()`, `enrichWithAiCategories()`, `findWordMatch()` (gammal), m.fl. - Tester uppdaterade och gröna (66/66) diff --git a/SESSION_2026-05-09_RECEIPT_IMPORT.md b/SESSION_2026-05-09_RECEIPT_IMPORT.md index 951e8d62..ab8afeab 100644 --- a/SESSION_2026-05-09_RECEIPT_IMPORT.md +++ b/SESSION_2026-05-09_RECEIPT_IMPORT.md @@ -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) 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 - [ ] Deploy backend + Flutter - [ ] Testa scroll-fix i prod diff --git a/backend/src/common/utils/receipt-alias.ts b/backend/src/common/utils/receipt-alias.ts new file mode 100644 index 00000000..67cc1d6f --- /dev/null +++ b/backend/src/common/utils/receipt-alias.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/receipt-alias/receipt-alias.service.spec.ts b/backend/src/receipt-alias/receipt-alias.service.spec.ts new file mode 100644 index 00000000..e115b2eb --- /dev/null +++ b/backend/src/receipt-alias/receipt-alias.service.spec.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/backend/src/receipt-alias/receipt-alias.service.ts b/backend/src/receipt-alias/receipt-alias.service.ts index 7613806e..0c76af7c 100644 --- a/backend/src/receipt-alias/receipt-alias.service.ts +++ b/backend/src/receipt-alias/receipt-alias.service.ts @@ -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 { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; +import { + normalizeReceiptAliasName, + validateReceiptAliasName, +} from '../common/utils/receipt-alias'; @Injectable() export class ReceiptAliasService { @@ -24,7 +33,11 @@ export class ReceiptAliasService { } 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; if (wantsGlobal && role !== 'admin') { @@ -45,10 +58,11 @@ export class ReceiptAliasService { ownerId: number | null, isGlobal: boolean, ) { + const normalizedReceiptName = normalizeReceiptAliasName(receiptName); const existing = await this.prisma.receiptAlias.findFirst({ where: isGlobal - ? { receiptName, isGlobal: true } - : { receiptName, ownerId, isGlobal: false }, + ? { receiptName: normalizedReceiptName, isGlobal: true } + : { receiptName: normalizedReceiptName, ownerId, isGlobal: false }, }); if (existing) { @@ -59,7 +73,7 @@ export class ReceiptAliasService { } return this.prisma.receiptAlias.create({ - data: { receiptName, productId, ownerId, isGlobal }, + data: { receiptName: normalizedReceiptName, productId, ownerId, isGlobal }, }); } diff --git a/backend/src/receipt-import/dto/save-receipt.dto.ts b/backend/src/receipt-import/dto/save-receipt.dto.ts index 1cd430d9..8668cf0f 100644 --- a/backend/src/receipt-import/dto/save-receipt.dto.ts +++ b/backend/src/receipt-import/dto/save-receipt.dto.ts @@ -60,6 +60,10 @@ export class SaveReceiptItemDto { @IsBoolean() learnAlias?: boolean; + @IsOptional() + @IsBoolean() + learnAliasGlobally?: boolean; + @IsOptional() @IsBoolean() learnUnitMapping?: boolean; @@ -70,8 +74,4 @@ export class SaveReceiptDto { @ValidateNested({ each: true }) @Type(() => SaveReceiptItemDto) items!: SaveReceiptItemDto[]; - - @IsOptional() - @IsBoolean() - isAdminLearning?: boolean; } diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts index 04f7044a..2d0e0737 100644 --- a/backend/src/receipt-import/receipt-import.controller.ts +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -102,7 +102,7 @@ export class ReceiptImportController { } 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.'); } diff --git a/backend/src/receipt-import/receipt-import.service.spec.ts b/backend/src/receipt-import/receipt-import.service.spec.ts index bf7cc493..3348d1b8 100644 --- a/backend/src/receipt-import/receipt-import.service.spec.ts +++ b/backend/src/receipt-import/receipt-import.service.spec.ts @@ -165,6 +165,28 @@ describe('ReceiptImportService test matrix', () => { 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 () => { const products = [ { diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 01c989a2..0dbfe2c7 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -12,6 +12,11 @@ import { SaveReceiptResponse } from './dto/save-receipt.response'; import { AiService, CategorySuggestion } from '../ai/ai.service'; import { CategoriesService } from '../categories/categories.service'; import { normalizeName } from '../common/utils/normalize-name'; +import { + isIgnoredReceiptAliasName, + normalizeReceiptAliasName, + validateReceiptAliasName, +} from '../common/utils/receipt-alias'; const IMPORTER_SERVICE_URL = 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 { - const normalized = (value ?? '').trim().toLowerCase(); - 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; + return isIgnoredReceiptAliasName(value); } function normalizeToken(s: string): string { @@ -220,8 +212,10 @@ export class ReceiptImportService { const aliasByReceiptName = new Map(); for (const alias of aliases) { - if (!aliasByReceiptName.has(alias.receiptName)) { - aliasByReceiptName.set(alias.receiptName, alias); + const normalizedReceiptName = normalizeReceiptAliasName(alias.receiptName); + 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 === 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) { - 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({ where: { receiptName_ownerId_isGlobal: { receiptName: normalizedReceiptName, ownerId: aliasOwnerId as any, - isGlobal: dto.isAdminLearning ? true : false, + isGlobal: isGlobalAlias, }, }, update: { @@ -489,8 +488,8 @@ export class ReceiptImportService { create: { receiptName: normalizedReceiptName, productId, - ownerId: (dto.isAdminLearning ? null : userId || null) as any, - isGlobal: dto.isAdminLearning ? true : false, + ownerId: aliasOwnerId as any, + isGlobal: isGlobalAlias, }, }); response.aliasesLearned++; @@ -573,7 +572,7 @@ export class ReceiptImportService { ): Promise { if (!item.rawName) return item; - const raw = item.rawName.toLowerCase().trim(); + const raw = normalizeReceiptAliasName(item.rawName); const debug: MatchDebug = { steps: [], tree: {} }; try { diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 0fdba6a0..ebd50771 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -173,6 +173,9 @@ class AdminRepository { Future> listProducts() => _getList(ProductApiPaths.mine, AdminProduct.fromJson); + Future> listGlobalProducts() => + _getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false); + Future> listDeletedProducts() => _getList(ProductApiPaths.deleted, AdminProduct.fromJson); diff --git a/flutter/lib/features/admin/domain/receipt_alias.dart b/flutter/lib/features/admin/domain/receipt_alias.dart index ae312329..063169ed 100644 --- a/flutter/lib/features/admin/domain/receipt_alias.dart +++ b/flutter/lib/features/admin/domain/receipt_alias.dart @@ -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(), ); diff --git a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart index d52bdb70..0e98f9dd 100644 --- a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart @@ -46,7 +46,7 @@ class _AdminAliasesPanelState extends ConsumerState { try { final results = await Future.wait([ ref.read(adminRepositoryProvider).listReceiptAliases(), - ref.read(adminRepositoryProvider).listProducts(), + ref.read(adminRepositoryProvider).listGlobalProducts(), ]); if (!mounted) return; setState(() { @@ -67,7 +67,7 @@ class _AdminAliasesPanelState extends ConsumerState { } Future _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 { 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 { ); } - 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: [ diff --git a/flutter/lib/features/import/data/import_repository.dart b/flutter/lib/features/import/data/import_repository.dart index ba23c971..06acad1e 100644 --- a/flutter/lib/features/import/data/import_repository.dart +++ b/flutter/lib/features/import/data/import_repository.dart @@ -265,7 +265,6 @@ class ImportRepository { /// - Learning unit mappings Future> saveReceipt({ required List> 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), diff --git a/flutter/lib/features/import/data/receipt_import_session.dart b/flutter/lib/features/import/data/receipt_import_session.dart index c851bb02..c877a7e4 100644 --- a/flutter/lib/features/import/data/receipt_import_session.dart +++ b/flutter/lib/features/import/data/receipt_import_session.dart @@ -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']) { diff --git a/flutter/lib/features/import/presentation/edit_dialog.dart b/flutter/lib/features/import/presentation/edit_dialog.dart index 5800ed34..afe439ad 100644 --- a/flutter/lib/features/import/presentation/edit_dialog.dart +++ b/flutter/lib/features/import/presentation/edit_dialog.dart @@ -19,6 +19,7 @@ class EditDialog extends StatefulWidget { final List categoryTree; final Future 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 { _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 _localProducts; @@ -68,6 +72,8 @@ class _EditDialogState extends State { _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 { 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 { 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 { 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( + segments: const [ + ButtonSegment( + value: false, + label: Text('Privat alias'), + icon: Icon(Icons.lock_outline, size: 16), + ), + ButtonSegment( + 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, diff --git a/flutter/lib/features/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index 78733c93..1a3ba16d 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -402,6 +402,7 @@ class _ReceiptImportTabState extends ConsumerState { 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 { 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 = >[]; @@ -507,10 +507,13 @@ class _ReceiptImportTabState extends ConsumerState { 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 { // Gör ett enda anrop till saveReceipt final response = await repo.saveReceipt( items: saveItems, - isAdminLearning: canManageAliases, token: token, ); diff --git a/flutter/lib/features/profile/presentation/user_aliases_screen.dart b/flutter/lib/features/profile/presentation/user_aliases_screen.dart index 9ba7e54a..641f4cef 100644 --- a/flutter/lib/features/profile/presentation/user_aliases_screen.dart +++ b/flutter/lib/features/profile/presentation/user_aliases_screen.dart @@ -31,7 +31,11 @@ class _UserAliasesScreenState extends ConsumerState { 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 { ), 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 { 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, ); }, );