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
@@ -4,6 +4,7 @@ import {
HttpCode,
Post,
Request,
UnauthorizedException,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
@@ -51,7 +52,7 @@ export class FlyerImportController {
: undefined;
if (!userId) {
throw new BadRequestException('Kunde inte identifiera användaren.');
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
return this.flyerImportService.parseAndMatch(file, userId);
@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
import { CreateFlyerSelectionDto } from './create-flyer-selection.dto';
export class CreateFlyerSelectionBulkDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateFlyerSelectionDto)
items!: CreateFlyerSelectionDto[];
}
@@ -0,0 +1,86 @@
import { Type } from 'class-transformer';
import {
IsArray,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class ReceiptMatchItemDto {
@IsString()
@MaxLength(191)
rawName!: string;
@IsOptional()
@IsString()
@MaxLength(191)
normalizedName?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
productId?: number;
@IsOptional()
@Type(() => Number)
@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
quantity?: number;
@IsOptional()
@IsString()
@MaxLength(24)
unit?: string;
}
export class ReceiptMatchOverrideDto {
@Type(() => Number)
@IsInt()
@Min(0)
rowIndex!: number;
@Type(() => Number)
@IsInt()
@Min(1)
selectionId!: number;
}
export class ReceiptMatchDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReceiptMatchItemDto)
items!: ReceiptMatchItemDto[];
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
sessionId?: number;
@IsOptional()
@IsString()
@MaxLength(16)
weekKey?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ReceiptMatchOverrideDto)
overrides?: ReceiptMatchOverrideDto[];
@IsOptional()
@IsString()
@MaxLength(80)
receiptImportBatchId?: string;
@IsOptional()
@IsString()
@IsIn(['receipt_auto', 'receipt_manual'])
boughtSource?: 'receipt_auto' | 'receipt_manual';
}
@@ -0,0 +1,30 @@
export type FlyerMatchStatus = 'auto' | 'ambiguous' | 'unmatched';
export type ReceiptMatchPreviewRow = {
rowIndex: number;
status: FlyerMatchStatus;
confidence: number;
matchedVia: string;
reasonCodes: string[];
selectionId: number | null;
sessionId: number | null;
itemId: number | null;
plannedName: string | null;
plannedProductId: number | null;
plannedProductName: string | null;
};
export type ReceiptMatchPreviewResponse = {
rows: ReceiptMatchPreviewRow[];
autoCount: number;
ambiguousCount: number;
unmatchedCount: number;
candidateSelectionCount: number;
};
export type ReceiptMatchCommitResponse = {
boughtCount: number;
ambiguousCount: number;
unmatchedCount: number;
updatedSelectionIds: number[];
};
@@ -0,0 +1,207 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { normalizeName } from '../common/utils/normalize-name';
import { ReceiptMatchItemDto } from './dto/receipt-match.dto';
import { ReceiptMatchPreviewResponse, ReceiptMatchPreviewRow } from './dto/receipt-match.response';
export type CandidateSelection = {
id: number;
sessionId: number;
itemId: number;
plannedQuantity: Prisma.Decimal | null;
plannedUnit: string | null;
item: {
rawName: string;
normalizedName: string;
matchedProductId: number | null;
matchedProductName: string | null;
};
};
const AUTO_MATCH_THRESHOLD = 0.9;
const AMBIGUOUS_THRESHOLD = 0.7;
@Injectable()
export class FlyerSelectionMatcherService {
matchRows(items: ReceiptMatchItemDto[], candidates: CandidateSelection[]): ReceiptMatchPreviewRow[] {
const remainingSelectionIds = new Set(candidates.map((candidate) => candidate.id));
const byProductId = new Map<number, CandidateSelection[]>();
const byNormalizedName = new Map<string, CandidateSelection[]>();
for (const candidate of candidates) {
const productId = candidate.item.matchedProductId;
if (productId != null) {
const list = byProductId.get(productId) ?? [];
list.push(candidate);
byProductId.set(productId, list);
}
const normalized = normalizeName(candidate.item.normalizedName || candidate.item.rawName);
if (normalized) {
const list = byNormalizedName.get(normalized) ?? [];
list.push(candidate);
byNormalizedName.set(normalized, list);
}
}
const rows: ReceiptMatchPreviewRow[] = [];
for (let rowIndex = 0; rowIndex < items.length; rowIndex++) {
const receiptItem = items[rowIndex];
const candidatesForRow = this.candidatePool(receiptItem, candidates, remainingSelectionIds, byProductId, byNormalizedName);
const best = this.findBest(receiptItem, candidatesForRow);
if (!best) {
rows.push({
rowIndex,
status: 'unmatched',
confidence: 0,
matchedVia: 'none',
reasonCodes: ['no_match'],
selectionId: null,
sessionId: null,
itemId: null,
plannedName: null,
plannedProductId: null,
plannedProductName: null,
});
continue;
}
const status =
best.confidence >= AUTO_MATCH_THRESHOLD
? 'auto'
: best.confidence >= AMBIGUOUS_THRESHOLD
? 'ambiguous'
: 'unmatched';
if (status !== 'unmatched') {
remainingSelectionIds.delete(best.candidate.id);
}
rows.push({
rowIndex,
status,
confidence: Number(best.confidence.toFixed(3)),
matchedVia: best.matchedVia,
reasonCodes: best.reasons,
selectionId: best.candidate.id,
sessionId: best.candidate.sessionId,
itemId: best.candidate.itemId,
plannedName: best.candidate.item.rawName,
plannedProductId: best.candidate.item.matchedProductId,
plannedProductName: best.candidate.item.matchedProductName,
});
}
return rows;
}
toPreviewResponse(rows: ReceiptMatchPreviewRow[], candidateSelectionCount: number): ReceiptMatchPreviewResponse {
const autoCount = rows.filter((row) => row.status === 'auto').length;
const ambiguousCount = rows.filter((row) => row.status === 'ambiguous').length;
const unmatchedCount = rows.filter((row) => row.status === 'unmatched').length;
return { rows, autoCount, ambiguousCount, unmatchedCount, candidateSelectionCount };
}
private candidatePool(
receiptItem: ReceiptMatchItemDto,
allCandidates: CandidateSelection[],
remainingSelectionIds: Set<number>,
byProductId: Map<number, CandidateSelection[]>,
byNormalizedName: Map<string, CandidateSelection[]>,
): CandidateSelection[] {
const pool = new Set<CandidateSelection>();
if (receiptItem.productId != null) {
for (const candidate of byProductId.get(receiptItem.productId) ?? []) {
if (remainingSelectionIds.has(candidate.id)) pool.add(candidate);
}
}
const receiptNormalized = normalizeName(receiptItem.normalizedName ?? receiptItem.rawName);
if (receiptNormalized) {
for (const candidate of byNormalizedName.get(receiptNormalized) ?? []) {
if (remainingSelectionIds.has(candidate.id)) pool.add(candidate);
}
}
if (pool.size > 0) {
return [...pool];
}
return allCandidates.filter((candidate) => remainingSelectionIds.has(candidate.id));
}
private findBest(
receiptItem: ReceiptMatchItemDto,
candidates: CandidateSelection[],
): { candidate: CandidateSelection; confidence: number; matchedVia: string; reasons: string[] } | null {
let best: { candidate: CandidateSelection; confidence: number; matchedVia: string; reasons: string[] } | null = null;
for (const candidate of candidates) {
const evaluated = this.scoreCandidate(receiptItem, candidate);
if (evaluated.confidence <= 0) continue;
if (!best || evaluated.confidence > best.confidence) best = evaluated;
}
return best;
}
private scoreCandidate(receiptItem: ReceiptMatchItemDto, candidate: CandidateSelection) {
const reasons: string[] = [];
const receiptNormalized = normalizeName(receiptItem.normalizedName ?? receiptItem.rawName);
const flyerNormalized = normalizeName(candidate.item.normalizedName || candidate.item.rawName);
if (receiptItem.productId && candidate.item.matchedProductId === receiptItem.productId) {
reasons.push('product_id_exact');
return { candidate, confidence: 1, matchedVia: 'product_id', reasons };
}
if (receiptNormalized && flyerNormalized && receiptNormalized === flyerNormalized) {
let confidence = 0.93;
reasons.push('name_exact');
confidence += this.quantityUnitBonus(receiptItem, candidate, reasons);
return { candidate, confidence: Math.min(0.99, confidence), matchedVia: 'name_exact', reasons };
}
const overlap = this.tokenOverlap(this.tokenize(receiptItem.rawName), this.tokenize(candidate.item.rawName));
if (overlap <= 0) {
return { candidate, confidence: 0, matchedVia: 'none', reasons: ['no_token_overlap'] };
}
let confidence = Math.min(0.89, 0.45 + overlap * 0.45);
reasons.push(`token_overlap:${overlap.toFixed(2)}`);
confidence += this.quantityUnitBonus(receiptItem, candidate, reasons);
confidence = Math.min(0.89, confidence);
return { candidate, confidence, matchedVia: 'token', reasons };
}
private quantityUnitBonus(receiptItem: ReceiptMatchItemDto, candidate: CandidateSelection, reasons: string[]): number {
let bonus = 0;
if (receiptItem.quantity != null && candidate.plannedQuantity != null && Number(candidate.plannedQuantity) === receiptItem.quantity) {
bonus += 0.03;
reasons.push('quantity_match');
}
const receiptUnit = (receiptItem.unit ?? '').trim().toLowerCase();
const plannedUnit = (candidate.plannedUnit ?? '').trim().toLowerCase();
if (receiptUnit && plannedUnit && receiptUnit === plannedUnit) {
bonus += 0.03;
reasons.push('unit_match');
}
return bonus;
}
private tokenize(value: string): string[] {
return value.toLowerCase().split(/[^a-z0-9åäö]+/).map((part) => part.trim()).filter((part) => part.length >= 3);
}
private tokenOverlap(a: string[], b: string[]): number {
if (a.length === 0 || b.length === 0) return 0;
const as = new Set(a);
const bs = new Set(b);
let intersection = 0;
for (const token of as) {
if (bs.has(token)) intersection++;
}
const union = new Set([...as, ...bs]).size;
if (union === 0) return 0;
return intersection / union;
}
}
@@ -0,0 +1,68 @@
import {
Body,
Controller,
Get,
HttpCode,
Post,
Query,
Request,
UnauthorizedException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { ReceiptMatchDto } from './dto/receipt-match.dto';
import {
ReceiptMatchCommitResponse,
ReceiptMatchPreviewResponse,
} from './dto/receipt-match.response';
import { FlyerSelectionService } from './flyer-selection.service';
@Controller('flyer-selections')
export class FlyerSelectionSyncController {
constructor(private readonly flyerSelectionService: FlyerSelectionService) {}
@Get('open')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
async listOpen(
@Query('weekKey') weekKey?: string,
@Request() req?: any,
): Promise<FlyerSelectionResponse[]> {
const userId = this.getUserId(req);
return this.flyerSelectionService.listOpen(userId, weekKey);
}
@Post('receipt-match-preview')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async preview(
@Body() dto: ReceiptMatchDto,
@Request() req?: any,
): Promise<ReceiptMatchPreviewResponse> {
const userId = this.getUserId(req);
return this.flyerSelectionService.previewReceiptMatches(userId, dto);
}
@Post('receipt-match-commit')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async commit(
@Body() dto: ReceiptMatchDto,
@Request() req?: any,
): Promise<ReceiptMatchCommitResponse> {
const userId = this.getUserId(req);
return this.flyerSelectionService.commitReceiptMatches(userId, dto);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
? req.user.id
: typeof req?.user?.userId === 'number'
? req.user.userId
: undefined;
if (!userId) {
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
return userId;
}
}
@@ -9,10 +9,11 @@ import {
Post,
Request,
Get,
BadRequestException,
UnauthorizedException,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto';
import { CreateFlyerSelectionBulkDto } from './dto/create-flyer-selection-bulk.dto';
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
import { FlyerSelectionService } from './flyer-selection.service';
@@ -42,6 +43,18 @@ export class FlyerSelectionController {
return this.flyerSelectionService.create(sessionId, userId, dto);
}
@Post('bulk')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
async createMany(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Body() dto: CreateFlyerSelectionBulkDto,
@Request() req?: any,
): Promise<FlyerSelectionResponse[]> {
const userId = this.getUserId(req);
return this.flyerSelectionService.createMany(sessionId, userId, dto.items);
}
@Patch(':selectionId')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 30 } })
@@ -75,7 +88,7 @@ export class FlyerSelectionController {
? req.user.userId
: undefined;
if (!userId) {
throw new BadRequestException('Kunde inte identifiera användaren.');
throw new UnauthorizedException('Kunde inte identifiera användaren.');
}
return userId;
}
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { FlyerSelectionMatcherService } from './flyer-selection-matcher.service';
import { FlyerSelectionController } from './flyer-selection.controller';
import { FlyerSelectionSyncController } from './flyer-selection-sync.controller';
import { FlyerSelectionService } from './flyer-selection.service';
@Module({
imports: [PrismaModule],
controllers: [FlyerSelectionController],
providers: [FlyerSelectionService],
controllers: [FlyerSelectionController, FlyerSelectionSyncController],
providers: [FlyerSelectionService, FlyerSelectionMatcherService],
exports: [FlyerSelectionService],
})
export class FlyerSelectionModule {}
@@ -7,12 +7,24 @@ import {
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateFlyerSelectionDto } from './dto/create-flyer-selection.dto';
import {
ReceiptMatchCommitResponse,
ReceiptMatchPreviewResponse,
} from './dto/receipt-match.response';
import { ReceiptMatchDto } from './dto/receipt-match.dto';
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
import {
CandidateSelection,
FlyerSelectionMatcherService,
} from './flyer-selection-matcher.service';
@Injectable()
export class FlyerSelectionService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly matcher: FlyerSelectionMatcherService,
) {}
async listBySession(sessionId: number, userId: number): Promise<FlyerSelectionResponse[]> {
await this.assertSessionOwnership(sessionId, userId);
@@ -94,6 +106,103 @@ export class FlyerSelectionService {
return this.toResponse(created);
}
async createMany(
sessionId: number,
userId: number,
items: CreateFlyerSelectionDto[],
): Promise<FlyerSelectionResponse[]> {
if (items.length === 0) return [];
await this.assertSessionOwnership(sessionId, userId);
const existingItems = await this.prisma.flyerItem.findMany({
where: {
sessionId,
id: {
in: items.map((item) => item.itemId),
},
},
select: { id: true },
});
const validItemIds = new Set(existingItems.map((item) => item.id));
const invalidItem = items.find((item) => !validItemIds.has(item.itemId));
if (invalidItem) {
throw new BadRequestException(`Flyer-rad ${invalidItem.itemId} tillhör inte sessionen.`);
}
const existingSelections = await this.prisma.flyerSelection.findMany({
where: {
sessionId,
itemId: { in: items.map((item) => item.itemId) },
},
select: { itemId: true },
});
const existingItemIds = new Set(existingSelections.map((item) => item.itemId));
await this.prisma.$transaction(async (tx) => {
const toCreate = items.filter((item) => !existingItemIds.has(item.itemId));
if (toCreate.length > 0) {
await tx.flyerSelection.createMany({
data: toCreate.map((item) => ({
sessionId,
itemId: item.itemId,
userId,
plannedQuantity:
item.plannedQuantity == null ? null : new Prisma.Decimal(item.plannedQuantity),
plannedUnit: item.plannedUnit ?? null,
priority: item.priority ?? 'normal',
note: item.note ?? null,
})),
});
}
const toUpdate = items.filter((item) => existingItemIds.has(item.itemId));
await Promise.all(
toUpdate.map((item) =>
tx.flyerSelection.update({
where: {
sessionId_itemId: {
sessionId,
itemId: item.itemId,
},
},
data: {
plannedQuantity:
item.plannedQuantity == null ? undefined : new Prisma.Decimal(item.plannedQuantity),
plannedUnit: item.plannedUnit,
priority: item.priority ?? 'normal',
note: item.note,
},
}),
),
);
});
const result = await this.prisma.flyerSelection.findMany({
where: {
sessionId,
userId,
itemId: { in: items.map((item) => item.itemId) },
},
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return result.map((row) => this.toResponse(row));
}
async update(
sessionId: number,
selectionId: number,
@@ -158,6 +267,88 @@ export class FlyerSelectionService {
await this.prisma.flyerSelection.delete({ where: { id: selectionId } });
}
async listOpen(userId: number, weekKey?: string): Promise<FlyerSelectionResponse[]> {
const rows = await this.prisma.flyerSelection.findMany({
where: {
userId,
status: 'planned',
session: {
...(weekKey ? { weekKey } : {}),
},
},
include: {
item: {
select: {
id: true,
rawName: true,
normalizedName: true,
price: true,
priceUnit: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toResponse(row));
}
async previewReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise<ReceiptMatchPreviewResponse> {
const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey);
const rows = this.matcher.matchRows(dto.items, candidates);
return this.matcher.toPreviewResponse(rows, candidates.length);
}
async commitReceiptMatches(userId: number, dto: ReceiptMatchDto): Promise<ReceiptMatchCommitResponse> {
const candidates = await this.loadCandidateSelections(userId, dto.sessionId, dto.weekKey);
const previewRows = this.matcher.matchRows(dto.items, candidates);
const overrideByIndex = new Map((dto.overrides ?? []).map((o) => [o.rowIndex, o.selectionId]));
const candidateBySelectionId = new Map(candidates.map((c) => [c.id, c]));
const usedSelectionIds = new Set<number>();
const toUpdateSelectionIds: number[] = [];
for (const row of previewRows) {
if (row.status === 'auto' && row.selectionId != null && !usedSelectionIds.has(row.selectionId)) {
usedSelectionIds.add(row.selectionId);
toUpdateSelectionIds.push(row.selectionId);
continue;
}
const overrideSelectionId = overrideByIndex.get(row.rowIndex);
if (!overrideSelectionId || usedSelectionIds.has(overrideSelectionId)) {
continue;
}
if (!candidateBySelectionId.has(overrideSelectionId)) {
continue;
}
usedSelectionIds.add(overrideSelectionId);
toUpdateSelectionIds.push(overrideSelectionId);
}
if (toUpdateSelectionIds.length > 0) {
await this.prisma.flyerSelection.updateMany({
where: {
id: { in: toUpdateSelectionIds },
userId,
status: 'planned',
},
data: {
status: 'bought',
},
});
}
const summary = this.matcher.toPreviewResponse(previewRows, candidates.length);
return {
boughtCount: toUpdateSelectionIds.length,
ambiguousCount: summary.ambiguousCount,
unmatchedCount: summary.unmatchedCount,
updatedSelectionIds: toUpdateSelectionIds,
};
}
private async assertSessionOwnership(sessionId: number, userId: number): Promise<void> {
const session = await this.prisma.flyerSession.findUnique({
where: { id: sessionId },
@@ -171,6 +362,41 @@ export class FlyerSelectionService {
}
}
private async loadCandidateSelections(
userId: number,
sessionId?: number,
weekKey?: string,
): Promise<CandidateSelection[]> {
if (sessionId != null && weekKey != null) {
throw new BadRequestException('Ange antingen sessionId eller weekKey, inte båda.');
}
return this.prisma.flyerSelection.findMany({
where: {
userId,
status: 'planned',
...(sessionId != null ? { sessionId } : {}),
...(weekKey ? { session: { weekKey } } : {}),
},
select: {
id: true,
sessionId: true,
itemId: true,
plannedQuantity: true,
plannedUnit: true,
item: {
select: {
rawName: true,
normalizedName: true,
matchedProductId: true,
matchedProductName: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
private toResponse(row: any): FlyerSelectionResponse {
return {
id: row.id,
@@ -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();