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
@@ -1,9 +1,15 @@
export interface SaveReceiptResponse {
created: number;
merged: number;
pantryAdded: number;
pantrySkipped: number;
aliasesLearned: number;
unitMappingsLearned: number;
errors?: Array<{ index: number; error: string }>;
}
export interface SaveReceiptResponse {
created: number;
merged: number;
pantryAdded: number;
pantrySkipped: number;
aliasesLearned: number;
unitMappingsLearned: number;
flyerAutoSync?: {
bought: number;
ambiguous: number;
unmatched: number;
error?: string;
};
errors?: Array<{ index: number; error: string }>;
}
@@ -6,9 +6,10 @@ import {
Request,
UploadedFile,
UseGuards,
UseInterceptors,
BadRequestException,
} from '@nestjs/common';
UseInterceptors,
BadRequestException,
UnauthorizedException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
@@ -72,7 +73,7 @@ export class ReceiptImportController {
? req.user.userId
: undefined;
if (!userId) {
throw new BadRequestException('Kunde inte identifiera användaren.');
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
return this.receiptImportService.upsertUnitMapping(
@@ -98,7 +99,7 @@ export class ReceiptImportController {
? req.user.userId
: undefined;
if (!userId) {
throw new BadRequestException('Kunde inte identifiera användaren.');
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
const isAdmin = req?.user?.role === 'admin';
@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { ReceiptImportController } from './receipt-import.controller';
import { ReceiptImportService } from './receipt-import.service';
import { PrismaModule } from '../prisma/prisma.module';
import { AiModule } from '../ai/ai.module';
import { CategoriesModule } from '../categories/categories.module';
@Module({
imports: [PrismaModule, AiModule, CategoriesModule],
controllers: [ReceiptImportController],
providers: [ReceiptImportService],
})
export class ReceiptImportModule {}
import { PrismaModule } from '../prisma/prisma.module';
import { AiModule } from '../ai/ai.module';
import { CategoriesModule } from '../categories/categories.module';
import { FlyerSelectionModule } from '../flyer-selection/flyer-selection.module';
@Module({
imports: [PrismaModule, AiModule, CategoriesModule, FlyerSelectionModule],
controllers: [ReceiptImportController],
providers: [ReceiptImportService],
})
export class ReceiptImportModule {}
@@ -27,11 +27,12 @@ describe('ReceiptImportService parseReceipt flow', () => {
findFlattened: jest.fn(),
};
const service = new ReceiptImportService(
prismaMock as any,
aiServiceMock as any,
categoriesServiceMock as any,
);
const service = new ReceiptImportService(
prismaMock as any,
aiServiceMock as any,
categoriesServiceMock as any,
{ commitReceiptMatches: jest.fn() } as any,
);
beforeEach(() => {
jest.clearAllMocks();
@@ -67,12 +67,13 @@ describe('ReceiptImportService.saveReceipt', () => {
$transaction: jest.fn().mockImplementation(async (cb: (tx: typeof txMock) => Promise<void>) => cb(txMock)),
};
service = new ReceiptImportService(
prismaMock as any,
{} as any, // aiService används ej i saveReceipt
{} as any, // categoriesService används ej i saveReceipt
);
});
service = new ReceiptImportService(
prismaMock as any,
{} as any, // aiService används ej i saveReceipt
{} as any, // categoriesService används ej i saveReceipt
{ commitReceiptMatches: jest.fn().mockResolvedValue({ boughtCount: 0, ambiguousCount: 0, unmatchedCount: 0 }) } as any,
);
});
// ── 1. Skapar ny inventariepost ─────────────────────────────────────────────
it('skapar ny inventariepost när produkten finns och inte finns i inventariet', async () => {
@@ -51,11 +51,12 @@ describe('ReceiptImportService test matrix', () => {
findFlattened: jest.fn(),
};
const service = new ReceiptImportService(
prismaMock as any,
aiServiceMock as any,
categoriesServiceMock as any,
);
const service = new ReceiptImportService(
prismaMock as any,
aiServiceMock as any,
categoriesServiceMock as any,
{ commitReceiptMatches: jest.fn() } as any,
);
beforeEach(() => {
jest.clearAllMocks();
@@ -404,4 +405,4 @@ describe('ReceiptImportService test matrix', () => {
expect(aiFallbackResult.categorySuggestion?.categoryId).toBe(51);
});
});
});
});
@@ -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();