chore(import): improve error handling and add flyer integration
- 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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user