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:
+13
-4
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 { 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 },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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<string, AliasLite>();
|
||||
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<ParsedReceiptItem> {
|
||||
if (!item.rawName) return item;
|
||||
|
||||
const raw = item.rawName.toLowerCase().trim();
|
||||
const raw = normalizeReceiptAliasName(item.rawName);
|
||||
const debug: MatchDebug = { steps: [], tree: {} };
|
||||
|
||||
try {
|
||||
|
||||
@@ -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(
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'→ ${alias.displayProductName}',
|
||||
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),
|
||||
tooltip: 'Ta bort alias',
|
||||
color: theme.colorScheme.error,
|
||||
onPressed: () => _delete(alias),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user