chore(import): improve error handling and add flyer integration
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 3m41s
Test Suite / flutter-quality (push) Successful in 2m3s

- Replace BadRequestException with UnauthorizedException for authentication failures in flyer-import and flyer-selection controllers
- Add bulk selection endpoint in FlyerSelectionController for creating multiple selections in one request
- Update FlyerSelectionModule to include new FlyerSelectionMatcherService and FlyerSelectionSyncController
- Extend FlyerSelectionService with createMany method for bulk operations
- Add new DTOs for bulk selection and receipt matching functionality
- Update ReceiptImportService to accept FlyerSelectionService dependency and track successful rows
- Extend SaveReceiptResponse with flyerAutoSync field for receipt-to-flyer matching results
- Add new API paths for flyer import and selection endpoints
- Update Flutter UI to include Flyer import tab and adjust tab controller length
- Add new domain models and repository methods for flyer import functionality
- Update test files to include new FlyerSelectionService dependency
- Modify .kilo plan documentation to reflect current system architecture
This commit is contained in:
Nils-Johan Gynther
2026-05-18 22:51:27 +02:00
parent 24a96c3da1
commit d5f903db98
26 changed files with 1359 additions and 247 deletions
@@ -12,11 +12,12 @@ 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';
import {
isIgnoredReceiptAliasName,
normalizeReceiptAliasName,
validateReceiptAliasName,
} from '../common/utils/receipt-alias';
import { FlyerSelectionService } from '../flyer-selection/flyer-selection.service';
const IMPORTER_SERVICE_URL =
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
@@ -125,11 +126,12 @@ type MatchDebug = {
export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name);
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
private readonly categoriesService: CategoriesService,
) {}
constructor(
private readonly prisma: PrismaService,
private readonly aiService: AiService,
private readonly categoriesService: CategoriesService,
private readonly flyerSelectionService: FlyerSelectionService,
) {}
async parseReceipt(file: Express.Multer.File, _isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
// Steg 1: Delegera AI-parsning till microservice-importer
@@ -297,7 +299,7 @@ export class ReceiptImportService {
});
}
async saveReceipt(userId: number, dto: SaveReceiptDto): Promise<SaveReceiptResponse> {
async saveReceipt(userId: number, dto: SaveReceiptDto): Promise<SaveReceiptResponse> {
const response: SaveReceiptResponse = {
created: 0,
merged: 0,
@@ -308,7 +310,13 @@ export class ReceiptImportService {
errors: [],
};
const prismaAny = this.prisma as any;
const prismaAny = this.prisma as any;
const successfulRows: Array<{
rawName: string;
productId?: number;
quantity?: number;
unit?: string;
}> = [];
// Preload existierande pantry-poster för denna användare
const userPantry = await this.prisma.pantryItem.findMany({
@@ -386,7 +394,7 @@ export class ReceiptImportService {
}
// === Steg 2: Hantera pantry eller inventory ===
if (item.destination === 'pantry') {
if (item.destination === 'pantry') {
if (pantryProductIds.has(productId)) {
response.pantrySkipped++;
} else {
@@ -395,8 +403,8 @@ export class ReceiptImportService {
});
response.pantryAdded++;
pantryProductIds.add(productId);
}
} else {
}
} else {
// inventory
const quantity = item.quantity ?? 0;
const unit = (item.unit ?? '').trim() || 'st';
@@ -461,8 +469,15 @@ export class ReceiptImportService {
});
response.unitMappingsLearned++;
}
}
}
}
}
successfulRows.push({
rawName: item.rawName,
productId,
quantity: item.quantity,
unit: item.unit,
});
// === Steg 4: Lär in alias om requested ===
if (item.learnAlias) {
@@ -510,10 +525,45 @@ export class ReceiptImportService {
throw new BadRequestException(
`Transaktionfel vid sparande av kvittovaror: ${err instanceof Error ? err.message : String(err)}`,
);
}
return response;
}
}
if (successfulRows.length > 0) {
const syncPayload = {
items: successfulRows,
receiptImportBatchId: `receipt-save-${Date.now()}-${userId}`,
boughtSource: 'receipt_auto' as const,
};
try {
const sync = await this.flyerSelectionService.commitReceiptMatches(userId, syncPayload);
response.flyerAutoSync = {
bought: sync.boughtCount,
ambiguous: sync.ambiguousCount,
unmatched: sync.unmatchedCount,
};
} catch (err) {
this.logger.warn(`Flyer auto-sync failed after receipt save (attempt 1): ${String(err)}`);
try {
const sync = await this.flyerSelectionService.commitReceiptMatches(userId, syncPayload);
response.flyerAutoSync = {
bought: sync.boughtCount,
ambiguous: sync.ambiguousCount,
unmatched: sync.unmatchedCount,
};
} catch (retryErr) {
const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
this.logger.warn(`Flyer auto-sync failed after receipt save (attempt 2): ${message}`);
response.flyerAutoSync = {
bought: 0,
ambiguous: 0,
unmatched: 0,
error: message,
};
}
}
}
return response;
}
private async parseReceiptViaImporter(file: Express.Multer.File): Promise<ParsedReceiptItem[]> {
const form = new FormData();