feat(shopping-list): add shopping list feature with 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) Successful in 5m8s
Test Suite / flutter-quality (push) Failing after 1m41s

This commit introduces a comprehensive shopping list feature with the following key changes:

Backend:
- Added ShoppingListItem model with relations to User, Product, and Category
- Added new fields to FlyerSession for source file metadata
- Added categoryId field to FlyerItem model
- Implemented session source file retrieval endpoint
- Added endpoint for updating flyer session items with category assignment
- Added endpoint for planning flyer selections to shopping list
- Implemented backfillCategoriesMine for AI-assisted category assignment
- Added ShoppingListModule and integrated with FlyerSelectionModule

Frontend:
- Added ShoppingListScreen and navigation route
- Implemented API paths and client methods for shopping list operations
- Added category tree loading for shopping list item creation
- Integrated shopping list functionality in flyer import tab
- Added category backfill trigger in inventory screen
- Updated FlyerImportItem model with categoryId support
- Added methods for updating flyer session items and retrieving source files

Database:
- Added new Prisma migration for flyer source metadata and shopping list items
- Updated schema with new relations and indexes

The shopping list feature allows users to:
1. Plan flyer selections directly to their shopping list
2. View and manage their shopping list items
3. Update flyer session items with proper categorization
4. Retrieve original flyer source files
5. Automatically backfill categories for uncategorized products
This commit is contained in:
Nils-Johan Gynther
2026-05-20 09:07:30 +02:00
parent 996f0d774b
commit a1a2c33427
37 changed files with 1843 additions and 102 deletions
+152
View File
@@ -0,0 +1,152 @@
# Åtgärdsplan utifrån E2E-fynd (Flyer + Inventory)
## Mål
- Göra flyerflödet praktiskt användbart för jämförelse, redigering och planering till inköpslista.
- Säkerställa att inventory/pantry visar korrekta kategorier i stället för att allt hamnar i `Övrigt`.
## Scope (utifrån dina punkter)
1. Flyer-vy:
- Visa importerad flyer-PDF för jämförelse mot extraherade rader.
- Redigera poster (namn + kategori) med samma kategorikälla som products/pantry.
- `Planera X markerade` ska skapa en faktisk inköpslista i en egen flik.
2. Inventory:
- Felsöka och åtgärda varför poster visas under `Övrigt`.
---
## Nulägesbild (från kodbasen)
- Flutter har redan `FlyerImportTab` med:
- filval/import,
- checkboxar,
- `Planera X markerade` -> `POST /flyer-sessions/:id/selections/bulk`.
- PDF-visning finns idag bara för aktuell uppladdad fil i minnet (`_pickedFile.bytes`) och kan inte återöppnas säkert vid återställd session/app-omstart.
- Flyer-rader kan ännu inte redigeras i UI (ingen inline edit-dialog för flyer-item).
- Meal Plan har en shopping-sektion, men ingen dedikerad flik för flyer-planerade köp.
- Kategori-träd finns redan och används i flera vyer via `/categories/tree`.
- Inventory läser kategori via `product.categoryRef`; null blir `Övrigt` i UI.
---
## Genomförandeplan
### 1) Flyer-PDF: beständig förhandsvisning per session
**Backend**
- Lägg till lagring av originalfil för flyer-session (MVP: lokal filstore eller DB blob beroende på befintligt mönster i projektet).
- Utöka `flyer_session` metadata med filreferens (filnamn, mime, storlek, storageKey).
- Ny endpoint: `GET /flyer-import/sessions/:sessionId/source` (auth + ägarskap) som streamar PDF/bild.
**Flutter**
- I `FlyerImportTab`:
- använd befintlig local preview direkt efter uppladdning,
- när session återställs: hämta source-endpoint och visa `Visa flyer` även då.
- Behåll fallback-meddelande för plattformar som inte kan öppna PDF direkt.
**Klart-kriterium**
- Samma importerade flyer kan öppnas efter tab-byte och app-omstart för samma användare.
### 2) Redigering av flyer-poster (namn + kategori)
**Backend**
- Lägg till endpoint för uppdatering av flyer-item i session, t.ex.
- `PATCH /flyer-import/sessions/:sessionId/items/:itemId`
- fält: `rawName` (eller `displayName`) och `categoryId` (ev. `categoryHintPath` för visning).
- Validera att kategori finns i samma kategoriträd (`categories`).
- Ägarskapskontroll via sessionens `userId`.
**Flutter**
- I listan i `FlyerImportTab`: lägg till `Redigera`-action per rad.
- Edit-dialog:
- textfält för namn,
- kategori-väljare baserad på samma träd/komponentmönster som inventory/pantry/admin.
- Spara uppdatering till backend och uppdatera lokal/session state.
**Klart-kriterium**
- Användaren kan ändra namn och kategori på en flyer-rad och ser ändringen direkt i listan.
### 3) Flyer -> Inköpslista i egen flik
**Backend**
- Definiera enkel shopping-list-resurs för MVP (user-scoped):
- tabell t.ex. `shopping_list_item` (name/productId/categoryId/quantity/unit/source/status/userId).
- Ny endpoint för att skapa inköpsrader från flyer-selections:
- `POST /flyer-sessions/:sessionId/selections/plan-to-shopping-list`
- alternativt återanvänd bulk-create i shopping-modul.
- Deduplicering/regler:
- om samma `productId+unit` finns öppet: summera eller hoppa över (bestäms i implementation; rekommenderat: summera).
**Flutter**
- Lägg till ny flik/skärm `Inköpslista` i app-shell.
- `Planera X markerade` i flyer-vyn ska:
1) skapa/uppdatera flyer selections,
2) trigga backend-mappning till shopping-list,
3) visa snackbar med antal tillagda/uppdaterade rader.
- Inköpslista-vyn (MVP): lista rader + enkel check/avprickning.
**Klart-kriterium**
- Klick på `Planera X markerade` flyttar markerade flyer-produkter till Inköpslista-fliken.
### 4) Inventory-fel: allt i `Övrigt`
**Felsökning**
- Verifiera varför `product.categoryId/categoryRef` blir null i aktuella poster:
- skapade produkter utan kategori,
- importflöden som inte persistar vald kategori,
- äldre data utan backfill.
**Åtgärd**
- Säkerställ att produktskapande från import/edit alltid skickar/sätter kategori när sådan är vald.
- Lägg skydd i backend så kategori inte tappas vid update-flöden.
- Engångs-backfill för befintliga produkter utan kategori:
- använd befintlig kategoriseringslogik (regel/AI) + fallback till rimlig underkategori.
- Kör re-fetch/invalidations i Flutter inventory/pantry efter backfill.
**Klart-kriterium**
- Inventory/pantry visar blandade korrekta kategorier; endast okända poster ligger kvar i `Övrigt`.
---
## Testplan
### Backend
- Nya tester för:
- auth/ownership på source-endpoint och item-edit endpoint,
- validering av kategori-id,
- plan-to-shopping-list (antal skapade, dedupe, idempotens).
### Flutter widget/integration
- Flyer:
- render av `Visa flyer` efter restore,
- edit-dialog uppdaterar rad,
- `Planera X markerade` ger förväntad feedback.
- Inköpslista:
- ny flik syns,
- mottar rader från flyer.
- Inventory regression:
- kategori visas från product category path när satt,
- `Övrigt` endast fallback.
### E2E-checklista
- Importera flyer PDF -> öppna PDF -> redigera 2 rader -> planera markerade -> verifiera Inköpslista-fliken.
- Starta om app -> återöppna samma flyer-PDF -> verifiera ändringar kvar.
- Kontrollera inventory/pantry-kategorier efter backfill.
---
## Leveransordning (rekommenderad)
1. Inventory-kategori bugfix + backfill (snabbt värde, hög påverkan).
2. Flyer item-redigering (namn/kategori).
3. Inköpslista-flik + backend-mappning från flyer.
4. Beständig flyer-PDF source-visning (kan byggas parallellt med 2/3 om backendkapacitet finns).
---
## Risker och mitigering
- **Datamigrering/backfill-risk**: kör först mot staging + logga träffsäkerhet och antal fallback till `Övrigt`.
- **Dubbelposter i inköpslista**: inför tydlig dedupe-regel och testfall.
- **PDF-hantering per plattform**: behåll web-first öppning och tydligt fallback-meddelande där native viewer saknas.
- **Prestanda vid stora flyers**: paginera/virtuallista om UI blir tungt.
---
## Definition of Done
- Flyer-vyn har fungerande: PDF-visning, redigering av namn/kategori, planering till inköpslista.
- Inköpslista finns som separat flik och visar planerade flyer-rader.
- Inventory/pantry kategoriserar korrekt och `Övrigt` används endast som verklig fallback.
- Nya backend- och Flutter-tester gröna.
@@ -0,0 +1,51 @@
ALTER TABLE `FlyerSession`
ADD COLUMN `sourceFileName` VARCHAR(191) NULL,
ADD COLUMN `sourceMimeType` VARCHAR(191) NULL,
ADD COLUMN `sourceFileSize` INTEGER NULL,
ADD COLUMN `sourceStorageKey` VARCHAR(191) NULL,
ADD COLUMN `sourceData` LONGBLOB NULL;
ALTER TABLE `FlyerItem`
ADD COLUMN `categoryId` INTEGER NULL;
ALTER TABLE `FlyerItem`
ADD CONSTRAINT `FlyerItem_categoryId_fkey`
FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`)
ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX `FlyerItem_categoryId_idx` ON `FlyerItem`(`categoryId`);
CREATE TABLE `ShoppingListItem` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`userId` INTEGER NOT NULL,
`name` VARCHAR(191) NOT NULL,
`productId` INTEGER NULL,
`categoryId` INTEGER NULL,
`quantity` DECIMAL(10, 2) NULL,
`unit` VARCHAR(191) NULL,
`source` VARCHAR(191) NOT NULL DEFAULT 'manual',
`status` VARCHAR(191) NOT NULL DEFAULT 'open',
`checkedAt` DATETIME(3) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `ShoppingListItem_userId_status_idx`(`userId`, `status`),
INDEX `ShoppingListItem_productId_unit_status_idx`(`productId`, `unit`, `status`),
INDEX `ShoppingListItem_categoryId_idx`(`categoryId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `ShoppingListItem`
ADD CONSTRAINT `ShoppingListItem_userId_fkey`
FOREIGN KEY (`userId`) REFERENCES `User`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE `ShoppingListItem`
ADD CONSTRAINT `ShoppingListItem_productId_fkey`
FOREIGN KEY (`productId`) REFERENCES `Product`(`id`)
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE `ShoppingListItem`
ADD CONSTRAINT `ShoppingListItem_categoryId_fkey`
FOREIGN KEY (`categoryId`) REFERENCES `Category`(`id`)
ON DELETE SET NULL ON UPDATE CASCADE;
+35
View File
@@ -32,6 +32,7 @@ model User {
unitMappings UnitMapping[]
flyerSessions FlyerSession[]
flyerSelections FlyerSelection[]
shoppingListItems ShoppingListItem[]
}
model Product {
@@ -58,6 +59,7 @@ model Product {
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
isPrivate Boolean @default(false)
unitMappings UnitMapping[]
shoppingListItems ShoppingListItem[]
}
model Category {
@@ -67,6 +69,8 @@ model Category {
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
products Product[]
flyerItems FlyerItem[]
shoppingListItems ShoppingListItem[]
@@unique([name, parentId])
@@index([parentId])
@@ -289,6 +293,11 @@ model FlyerSession {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
expiresAt DateTime?
sourceFileName String?
sourceMimeType String?
sourceFileSize Int?
sourceStorageKey String?
sourceData Bytes?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items FlyerItem[]
@@ -305,6 +314,7 @@ model FlyerItem {
rawName String
normalizedName String
categoryHint String?
categoryId Int?
price Decimal? @db.Decimal(10, 2)
priceUnit String?
comparisonPrice Decimal? @db.Decimal(10, 2)
@@ -321,10 +331,12 @@ model FlyerItem {
updatedAt DateTime @updatedAt
session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
selections FlyerSelection[]
@@index([sessionId])
@@index([normalizedName])
@@index([categoryId])
}
model FlyerSelection {
@@ -348,3 +360,26 @@ model FlyerSelection {
@@index([sessionId])
@@index([userId, status])
}
model ShoppingListItem {
id Int @id @default(autoincrement())
userId Int
name String
productId Int?
categoryId Int?
quantity Decimal? @db.Decimal(10, 2)
unit String?
source String @default("manual")
status String @default("open")
checkedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
@@index([userId, status])
@@index([productId, unit, status])
@@index([categoryId])
}
+2
View File
@@ -20,6 +20,7 @@ import { RealtimeModule } from './realtime/realtime.module';
import { HelpTextsModule } from './help-texts/help-texts.module';
import { FlyerImportModule } from './flyer-import/flyer-import.module';
import { FlyerSelectionModule } from './flyer-selection/flyer-selection.module';
import { ShoppingListModule } from './shopping-list/shopping-list.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard';
@@ -52,6 +53,7 @@ import { RolesGuard } from './auth/roles.guard';
HelpTextsModule,
FlyerImportModule,
FlyerSelectionModule,
ShoppingListModule,
],
providers: [
{
@@ -5,6 +5,7 @@ export type FlyerImportItem = {
rawName: string;
normalizedName: string;
category: string | null;
categoryId: number | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
@@ -25,6 +26,10 @@ export type FlyerImportResponse = {
sessionId: number | null;
retailer: 'willys';
parserVersion: 'v1';
sourceAvailable: boolean;
sourceFileName: string | null;
sourceMimeType: string | null;
sourceFileSize: number | null;
items: FlyerImportItem[];
warnings: string[];
};
@@ -0,0 +1,20 @@
import { Transform } from 'class-transformer';
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
export class UpdateFlyerItemDto {
@IsOptional()
@IsString()
@MaxLength(191)
rawName?: string;
@IsOptional()
@Transform(({ value }) => {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number') return value;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : value;
})
@IsInt()
@Min(1)
categoryId?: number | null;
}
@@ -1,12 +1,16 @@
import {
Body,
BadRequestException,
Controller,
Get,
Header,
HttpCode,
Patch,
Param,
ParseIntPipe,
Post,
Request,
StreamableFile,
UnauthorizedException,
UploadedFile,
UseInterceptors,
@@ -15,6 +19,7 @@ import { Throttle } from '@nestjs/throttler';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { FlyerImportResponse } from './dto/flyer-import.response';
import { UpdateFlyerItemDto } from './dto/update-flyer-item.dto';
import { FlyerImportService } from './flyer-import.service';
const ALLOWED_MIMES = [
@@ -72,6 +77,35 @@ export class FlyerImportController {
return this.flyerImportService.getSession(sessionId, userId);
}
@Get('sessions/:sessionId/source')
@Throttle({ default: { ttl: 60_000, limit: 30 } })
@Header('Cache-Control', 'private, max-age=300')
async getSessionSource(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Request() req?: any,
): Promise<StreamableFile> {
const userId = this.getUserId(req);
const source = await this.flyerImportService.getSessionSource(sessionId, userId);
return new StreamableFile(source.data, {
disposition: `inline; filename="${source.fileName.replace(/"/g, '')}"`,
type: source.mimeType,
length: source.contentLength,
});
}
@Patch('sessions/:sessionId/items/:itemId')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 60 } })
async updateSessionItem(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Param('itemId', ParseIntPipe) itemId: number,
@Request() req: any,
@Body() dto: UpdateFlyerItemDto,
) {
const userId = this.getUserId(req);
return this.flyerImportService.updateSessionItem(sessionId, itemId, userId, dto);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
@@ -1,10 +1,18 @@
import { NotFoundException } from '@nestjs/common';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { FlyerImportService } from './flyer-import.service';
describe('FlyerImportService', () => {
const prismaMock = {
flyerSession: {
findFirst: jest.fn(),
findUnique: jest.fn(),
},
flyerItem: {
findUnique: jest.fn(),
update: jest.fn(),
},
category: {
findUnique: jest.fn(),
},
};
@@ -28,7 +36,27 @@ describe('FlyerImportService', () => {
await expect(service.getSession(123, 1)).rejects.toBeInstanceOf(NotFoundException);
expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({
where: { id: 123, userId: 1 },
include: { items: { orderBy: { id: 'asc' } } },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
});
@@ -64,6 +92,7 @@ describe('FlyerImportService', () => {
expect(result.items).toHaveLength(1);
expect(result.items[0].flyerItemId).toBe(99);
expect(result.items[0].matchedVia).toBe('exact');
expect(result.sourceAvailable).toBe(false);
});
});
@@ -79,8 +108,89 @@ describe('FlyerImportService', () => {
expect(prismaMock.flyerSession.findFirst).toHaveBeenCalledWith({
where: { userId: 1 },
orderBy: { createdAt: 'desc' },
include: { items: { orderBy: { id: 'asc' } } },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
});
});
describe('updateSessionItem', () => {
it('updates rawName and category path', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({ id: 7, userId: 1 });
prismaMock.flyerItem.findUnique.mockResolvedValue({
id: 12,
sessionId: 7,
rawName: 'Tomat',
});
prismaMock.category.findUnique.mockResolvedValue({
id: 3,
name: 'Tomater',
parent: { name: 'Grönsaker', parent: { name: 'Mat', parent: null } },
});
prismaMock.flyerItem.update.mockResolvedValue({
id: 12,
rawName: 'Cocktailtomater',
normalizedName: 'cocktailtomater',
categoryHint: 'Mat > Grönsaker > Tomater',
categoryId: 3,
price: null,
priceUnit: null,
comparisonPrice: null,
comparisonUnit: null,
offerText: null,
parseConfidence: 1,
parseReasons: [],
matchedProductId: null,
matchedProductName: null,
matchedVia: 'none',
matchConfidence: null,
matchReasons: [],
categoryRef: { name: 'Tomater', parent: { name: 'Grönsaker', parent: { name: 'Mat' } } },
});
const service = createService();
const result = await service.updateSessionItem(7, 12, 1, {
rawName: 'Cocktailtomater',
categoryId: 3,
});
expect(result.rawName).toBe('Cocktailtomater');
expect(result.categoryId).toBe(3);
expect(result.category).toBe('Mat > Grönsaker > Tomater');
});
});
describe('getSessionSource', () => {
it('throws when session belongs to another user', async () => {
prismaMock.flyerSession.findUnique.mockResolvedValue({
userId: 99,
sourceFileName: 'flyer.pdf',
sourceMimeType: 'application/pdf',
sourceFileSize: 10,
sourceData: Buffer.from('abc'),
});
const service = createService();
await expect(service.getSessionSource(1, 1)).rejects.toBeInstanceOf(ForbiddenException);
});
});
});
@@ -1,5 +1,6 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
@@ -105,6 +106,7 @@ export class FlyerImportService {
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.category,
categoryId: null,
price,
priceUnit,
comparisonPrice,
@@ -122,21 +124,151 @@ export class FlyerImportService {
};
});
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file);
return {
sessionId: persistedItems.sessionId,
retailer: parsed.retailer,
parserVersion: parsed.parserVersion,
sourceAvailable: true,
sourceFileName: file.originalname ?? null,
sourceMimeType: file.mimetype ?? null,
sourceFileSize: file.size ?? null,
items: persistedItems.items,
warnings: parsed.warnings,
};
}
async getSessionSource(sessionId: number, userId: number): Promise<{
fileName: string;
mimeType: string;
contentLength: number;
data: Buffer;
}> {
const session = await this.prisma.flyerSession.findUnique({
where: { id: sessionId },
select: {
userId: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceData: true,
},
});
if (!session) {
throw new NotFoundException('Flyer-session hittades inte.');
}
if (session.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna session.');
}
if (!session.sourceData || !session.sourceFileName || !session.sourceMimeType) {
throw new NotFoundException('Källfil saknas för denna flyer-session.');
}
const data = Buffer.from(session.sourceData);
return {
fileName: session.sourceFileName,
mimeType: session.sourceMimeType,
contentLength: session.sourceFileSize ?? data.length,
data,
};
}
async updateSessionItem(
sessionId: number,
itemId: number,
userId: number,
payload: { rawName?: string; categoryId?: number | null },
): Promise<FlyerImportItem> {
const session = await this.prisma.flyerSession.findUnique({
where: { id: sessionId },
select: { id: true, userId: true },
});
if (!session) {
throw new NotFoundException('Flyer-session hittades inte.');
}
if (session.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna session.');
}
const item = await this.prisma.flyerItem.findUnique({
where: { id: itemId },
select: { id: true, sessionId: true, rawName: true },
});
if (!item || item.sessionId !== sessionId) {
throw new NotFoundException('Flyer-rad hittades inte i sessionen.');
}
const updateData: Prisma.FlyerItemUncheckedUpdateInput = {};
if (typeof payload.rawName === 'string') {
const trimmed = payload.rawName.trim();
if (!trimmed) {
throw new BadRequestException('Namn får inte vara tomt.');
}
updateData.rawName = trimmed;
updateData.normalizedName = normalizeName(trimmed) || normalizeName(item.rawName);
}
if (payload.categoryId !== undefined) {
if (payload.categoryId === null) {
updateData.categoryId = null;
updateData.categoryHint = null;
} else {
const path = await this.resolveCategoryPath(payload.categoryId);
updateData.categoryId = payload.categoryId;
updateData.categoryHint = path;
}
}
if (Object.keys(updateData).length === 0) {
throw new BadRequestException('Inga giltiga fält att uppdatera.');
}
const updated = await this.prisma.flyerItem.update({
where: { id: itemId },
data: updateData,
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
});
return this.toFlyerImportItem(updated as any);
}
async getSession(sessionId: number, userId: number): Promise<FlyerImportResponse> {
const session = await this.prisma.flyerSession.findFirst({
where: { id: sessionId, userId },
include: { items: { orderBy: { id: 'asc' } } },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
if (!session) {
@@ -150,7 +282,27 @@ export class FlyerImportService {
const latest = await this.prisma.flyerSession.findFirst({
where: { userId },
orderBy: { createdAt: 'desc' },
include: { items: { orderBy: { id: 'asc' } } },
select: {
id: true,
sourceFileName: true,
sourceMimeType: true,
sourceFileSize: true,
sourceStorageKey: true,
items: {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
orderBy: { id: 'asc' },
},
},
});
if (!latest) {
@@ -158,6 +310,10 @@ export class FlyerImportService {
sessionId: null,
retailer: 'willys',
parserVersion: 'v1',
sourceAvailable: false,
sourceFileName: null,
sourceMimeType: null,
sourceFileSize: null,
items: [],
warnings: [],
};
@@ -170,6 +326,7 @@ export class FlyerImportService {
userId: number,
retailer: 'willys',
items: FlyerImportItem[],
file: Express.Multer.File,
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
const weekKey = this.toWeekKey(new Date());
@@ -179,6 +336,11 @@ export class FlyerImportService {
retailer,
weekKey,
status: 'draft',
sourceFileName: file.originalname ?? null,
sourceMimeType: file.mimetype ?? null,
sourceFileSize: file.size ?? file.buffer.length,
sourceStorageKey: this.buildSourceStorageKey(userId, weekKey),
sourceData: Buffer.from(file.buffer),
},
select: { id: true },
});
@@ -191,6 +353,7 @@ export class FlyerImportService {
rawName: item.rawName,
normalizedName: item.normalizedName,
categoryHint: item.category,
categoryId: item.categoryId,
price: item.price != null ? new Prisma.Decimal(item.price) : null,
priceUnit: item.priceUnit,
comparisonPrice:
@@ -472,6 +635,16 @@ export class FlyerImportService {
rawName: string;
normalizedName: string;
categoryHint: string | null;
categoryId: number | null;
categoryRef?: {
name: string;
parent?: {
name: string;
parent?: {
name: string;
} | null;
} | null;
} | null;
price: Prisma.Decimal | null;
priceUnit: string | null;
comparisonPrice: Prisma.Decimal | null;
@@ -495,6 +668,8 @@ export class FlyerImportService {
? item.matchedVia
: 'none';
const categoryPath = this.buildCategoryPath(item.categoryRef) ?? item.categoryHint;
const offerLimitText = this.extractOfferLimitText(item.offerText);
const offerSignals = this.extractOfferSignals(item.offerText);
@@ -502,7 +677,8 @@ export class FlyerImportService {
flyerItemId: item.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.categoryHint,
category: categoryPath,
categoryId: item.categoryId,
price: item.price != null ? item.price.toNumber() : offerSignals.price,
priceUnit: this.normalizeUnit(item.priceUnit) ?? offerSignals.priceUnit,
comparisonPrice: item.comparisonPrice != null ? item.comparisonPrice.toNumber() : offerSignals.comparisonPrice,
@@ -524,13 +700,44 @@ export class FlyerImportService {
};
}
private buildCategoryPath(categoryRef?: {
name: string;
parent?: {
name: string;
parent?: { name: string } | null;
} | null;
} | null): string | null {
if (!categoryRef) return null;
const names: string[] = [];
let current: { name: string; parent?: any } | null = categoryRef;
while (current) {
names.unshift(current.name);
current = current.parent ?? null;
}
return names.length > 0 ? names.join(' > ') : null;
}
private toFlyerImportResponseFromSession(session: {
id: number;
sourceFileName?: string | null;
sourceMimeType?: string | null;
sourceFileSize?: number | null;
sourceStorageKey?: string | null;
items: Array<{
id: number;
rawName: string;
normalizedName: string;
categoryHint: string | null;
categoryId: number | null;
categoryRef?: {
name: string;
parent?: {
name: string;
parent?: {
name: string;
} | null;
} | null;
} | null;
price: Prisma.Decimal | null;
priceUnit: string | null;
comparisonPrice: Prisma.Decimal | null;
@@ -549,8 +756,41 @@ export class FlyerImportService {
sessionId: session.id,
retailer: 'willys',
parserVersion: 'v1',
sourceAvailable: !!session.sourceStorageKey,
sourceFileName: session.sourceFileName ?? null,
sourceMimeType: session.sourceMimeType ?? null,
sourceFileSize: session.sourceFileSize ?? null,
items: session.items.map((item) => this.toFlyerImportItem(item)),
warnings: [],
};
}
private async resolveCategoryPath(categoryId: number): Promise<string> {
const category = await this.prisma.category.findUnique({
where: { id: categoryId },
include: {
parent: {
include: {
parent: true,
},
},
},
});
if (!category) {
throw new BadRequestException(`Kategori med id ${categoryId} hittades inte.`);
}
const names: string[] = [];
let current: { name: string; parent: any } | null = category as any;
while (current) {
names.unshift(current.name);
current = current.parent;
}
return names.join(' > ');
}
private buildSourceStorageKey(userId: number, weekKey: string): string {
return `flyer/${userId}/${weekKey}/${Date.now()}`;
}
}
@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { IsArray, IsInt, IsOptional, Min } from 'class-validator';
export class PlanToShoppingListDto {
@IsOptional()
@IsArray()
@Type(() => Number)
@IsInt({ each: true })
@Min(1, { each: true })
itemIds?: number[];
}
@@ -17,6 +17,7 @@ import { CreateFlyerSelectionBulkDto } from './dto/create-flyer-selection-bulk.d
import { FlyerSelectionResponse } from './dto/flyer-selection.response';
import { UpdateFlyerSelectionDto } from './dto/update-flyer-selection.dto';
import { FlyerSelectionService } from './flyer-selection.service';
import { PlanToShoppingListDto } from './dto/plan-to-shopping-list.dto';
@Controller('flyer-sessions/:sessionId/selections')
export class FlyerSelectionController {
@@ -80,6 +81,18 @@ export class FlyerSelectionController {
await this.flyerSelectionService.remove(sessionId, selectionId, userId);
}
@Post('plan-to-shopping-list')
@HttpCode(200)
@Throttle({ default: { ttl: 60_000, limit: 20 } })
async planToShoppingList(
@Param('sessionId', ParseIntPipe) sessionId: number,
@Body() dto: PlanToShoppingListDto,
@Request() req?: any,
): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> {
const userId = this.getUserId(req);
return this.flyerSelectionService.planToShoppingList(sessionId, userId, dto.itemIds);
}
private getUserId(req?: any): number {
const userId =
typeof req?.user?.id === 'number'
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ShoppingListModule } from '../shopping-list/shopping-list.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],
imports: [PrismaModule, ShoppingListModule],
controllers: [FlyerSelectionController, FlyerSelectionSyncController],
providers: [FlyerSelectionService, FlyerSelectionMatcherService],
exports: [FlyerSelectionService],
@@ -18,12 +18,14 @@ import {
CandidateSelection,
FlyerSelectionMatcherService,
} from './flyer-selection-matcher.service';
import { ShoppingListService } from '../shopping-list/shopping-list.service';
@Injectable()
export class FlyerSelectionService {
constructor(
private readonly prisma: PrismaService,
private readonly matcher: FlyerSelectionMatcherService,
private readonly shoppingListService: ShoppingListService,
) {}
async listBySession(sessionId: number, userId: number): Promise<FlyerSelectionResponse[]> {
@@ -295,6 +297,15 @@ export class FlyerSelectionService {
return rows.map((row) => this.toResponse(row));
}
async planToShoppingList(
sessionId: number,
userId: number,
itemIds?: number[],
): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> {
await this.assertSessionOwnership(sessionId, userId);
return this.shoppingListService.upsertFromFlyerSelections(sessionId, userId, itemIds);
}
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);
@@ -176,6 +176,12 @@ export class ProductsController {
return this.productsService.updateCategoryMine(req.user.id, id, body.categoryId);
}
@Post('mine/backfill-categories')
@HttpCode(200)
backfillCategoriesMine(@Request() req: { user: { id: number } }) {
return this.productsService.backfillCategoriesMine(req.user.id);
}
@Roles('admin')
@Post('merge')
merge(@Body() body: MergeProductsDto) {
+63
View File
@@ -664,4 +664,67 @@ export class ProductsService {
select: { id: true, categoryId: true },
});
}
async backfillCategoriesMine(userId: number): Promise<{ updated: number; fallbackToOvrigt: number }> {
const [categories, products] = await Promise.all([
this.categoriesService.findFlattened(),
this.prisma.product.findMany({
where: {
ownerId: userId,
isActive: true,
categoryId: null,
},
select: {
id: true,
name: true,
canonicalName: true,
},
}),
]);
if (products.length === 0) {
return { updated: 0, fallbackToOvrigt: 0 };
}
const fallback =
categories.find((category) => category.path.toLowerCase().endsWith(' > övrigt'))
?? categories.find((category) => category.name.toLowerCase() === 'övrigt')
?? categories[0];
let updated = 0;
let fallbackToOvrigt = 0;
for (const product of products) {
let targetCategoryId = fallback?.id;
let usedFallback = true;
try {
const suggestion = await this.aiService.suggestCategory(
product.canonicalName ?? product.name,
categories,
);
if (suggestion?.categoryId) {
targetCategoryId = suggestion.categoryId;
usedFallback = suggestion.usedFallback === true;
}
} catch {
usedFallback = true;
}
if (!targetCategoryId) {
continue;
}
await this.prisma.product.update({
where: { id: product.id },
data: { categoryId: targetCategoryId },
});
updated += 1;
if (usedFallback) {
fallbackToOvrigt += 1;
}
}
return { updated, fallbackToOvrigt };
}
}
@@ -388,6 +388,14 @@ export class ReceiptImportService {
if (!product) {
throw new Error(`Produkten med ID ${item.productId} hittades inte.`);
}
if (item.categoryId != null && product.categoryId !== item.categoryId) {
await tx.product.update({
where: { id: product.id },
data: { categoryId: item.categoryId },
});
}
productId = product.id;
} else {
throw new Error('Antingen productId eller createProductName måste anges.');
@@ -0,0 +1,14 @@
export type ShoppingListItemResponse = {
id: number;
userId: number;
name: string;
productId: number | null;
categoryId: number | null;
quantity: number | null;
unit: string | null;
source: string;
status: string;
checkedAt: string | null;
createdAt: string;
updatedAt: string;
};
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateShoppingListItemStatusDto {
@IsBoolean()
checked!: boolean;
}
@@ -0,0 +1,49 @@
import {
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Request,
UnauthorizedException,
Body,
} from '@nestjs/common';
import { ShoppingListService } from './shopping-list.service';
import { UpdateShoppingListItemStatusDto } from './dto/update-shopping-list-item-status.dto';
import { ShoppingListItemResponse } from './dto/shopping-list-item.response';
@Controller('shopping-list')
export class ShoppingListController {
constructor(private readonly shoppingListService: ShoppingListService) {}
@Get('items')
async listOpen(@Request() req?: any): Promise<ShoppingListItemResponse[]> {
const userId = this.getUserId(req);
return this.shoppingListService.listOpen(userId);
}
@Patch('items/:itemId/status')
async updateStatus(
@Param('itemId', ParseIntPipe) itemId: number,
@Body() dto: UpdateShoppingListItemStatusDto,
@Request() req?: any,
): Promise<ShoppingListItemResponse> {
const userId = this.getUserId(req);
return this.shoppingListService.updateCheckedStatus(userId, itemId, dto.checked);
}
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;
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { ShoppingListController } from './shopping-list.controller';
import { ShoppingListService } from './shopping-list.service';
@Module({
imports: [PrismaModule],
controllers: [ShoppingListController],
providers: [ShoppingListService],
exports: [ShoppingListService],
})
export class ShoppingListModule {}
@@ -0,0 +1,81 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { ShoppingListService } from '../shopping-list/shopping-list.service';
describe('ShoppingListService', () => {
const prismaMock = {
shoppingListItem: {
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
},
flyerSelection: {
findMany: jest.fn(),
},
$transaction: jest.fn(),
};
const createService = () => new ShoppingListService(prismaMock as any);
beforeEach(() => {
jest.clearAllMocks();
});
it('throws when updating another users shopping item', async () => {
prismaMock.shoppingListItem.findUnique.mockResolvedValue({ id: 1, userId: 99 });
const service = createService();
await expect(service.updateCheckedStatus(1, 1, true)).rejects.toBeInstanceOf(ForbiddenException);
});
it('throws when shopping item is missing', async () => {
prismaMock.shoppingListItem.findUnique.mockResolvedValue(null);
const service = createService();
await expect(service.updateCheckedStatus(1, 1, true)).rejects.toBeInstanceOf(NotFoundException);
});
it('deduplicates by productId+unit when planning from flyer selections', async () => {
prismaMock.flyerSelection.findMany.mockResolvedValue([
{
id: 10,
plannedQuantity: new Prisma.Decimal(1),
plannedUnit: 'kg',
item: {
id: 100,
rawName: 'Tomat',
matchedProductId: 7,
categoryId: 22,
priceUnit: 'kg',
},
},
{
id: 11,
plannedQuantity: new Prisma.Decimal(2),
plannedUnit: 'kg',
item: {
id: 101,
rawName: 'Tomat',
matchedProductId: 7,
categoryId: 22,
priceUnit: 'kg',
},
},
]);
prismaMock.$transaction.mockImplementation(async (cb: any) => cb(prismaMock));
prismaMock.shoppingListItem.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 999 });
const service = createService();
const result = await service.upsertFromFlyerSelections(1, 1, [100, 101]);
expect(result.created).toBe(1);
expect(result.updated).toBe(1);
expect(prismaMock.shoppingListItem.create).toHaveBeenCalledTimes(1);
expect(prismaMock.shoppingListItem.update).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,162 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { ShoppingListItemResponse } from './dto/shopping-list-item.response';
@Injectable()
export class ShoppingListService {
constructor(private readonly prisma: PrismaService) {}
async listOpen(userId: number): Promise<ShoppingListItemResponse[]> {
const rows = await this.prisma.shoppingListItem.findMany({
where: { userId, status: 'open' },
orderBy: [{ createdAt: 'desc' }],
});
return rows.map((row) => this.toResponse(row));
}
async updateCheckedStatus(
userId: number,
itemId: number,
checked: boolean,
): Promise<ShoppingListItemResponse> {
const existing = await this.prisma.shoppingListItem.findUnique({ where: { id: itemId } });
if (!existing) {
throw new NotFoundException('Inköpsrad hittades inte.');
}
if (existing.userId !== userId) {
throw new ForbiddenException('Du saknar åtkomst till denna inköpsrad.');
}
const updated = await this.prisma.shoppingListItem.update({
where: { id: itemId },
data: {
status: checked ? 'checked' : 'open',
checkedAt: checked ? new Date() : null,
},
});
return this.toResponse(updated);
}
async upsertFromFlyerSelections(
sessionId: number,
userId: number,
itemIds?: number[],
): Promise<{ created: number; updated: number; processedSelectionIds: number[] }> {
const selections = await this.prisma.flyerSelection.findMany({
where: {
sessionId,
userId,
status: 'planned',
...(itemIds && itemIds.length > 0 ? { itemId: { in: itemIds } } : {}),
},
include: {
item: {
select: {
id: true,
rawName: true,
categoryId: true,
matchedProductId: true,
priceUnit: true,
},
},
},
});
if (selections.length === 0) {
return { created: 0, updated: 0, processedSelectionIds: [] };
}
let created = 0;
let updated = 0;
await this.prisma.$transaction(async (tx) => {
for (const selection of selections) {
const quantity = selection.plannedQuantity ?? new Prisma.Decimal(1);
const unit = selection.plannedUnit ?? selection.item.priceUnit ?? 'st';
const normalizedUnit = unit.trim() || 'st';
const productId = selection.item.matchedProductId ?? null;
let existing: { id: number } | null = null;
if (productId != null) {
existing = await tx.shoppingListItem.findFirst({
where: {
userId,
status: 'open',
productId,
unit: normalizedUnit,
},
select: { id: true },
});
}
if (existing) {
await tx.shoppingListItem.update({
where: { id: existing.id },
data: {
quantity: {
increment: quantity,
},
name: selection.item.rawName,
categoryId: selection.item.categoryId ?? undefined,
source: 'flyer',
},
});
updated += 1;
continue;
}
await tx.shoppingListItem.create({
data: {
userId,
name: selection.item.rawName,
productId,
categoryId: selection.item.categoryId,
quantity,
unit: normalizedUnit,
source: 'flyer',
status: 'open',
},
});
created += 1;
}
});
return {
created,
updated,
processedSelectionIds: selections.map((selection) => selection.id),
};
}
private toResponse(row: {
id: number;
userId: number;
name: string;
productId: number | null;
categoryId: number | null;
quantity: Prisma.Decimal | null;
unit: string | null;
source: string;
status: string;
checkedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): ShoppingListItemResponse {
return {
id: row.id,
userId: row.userId,
name: row.name,
productId: row.productId,
categoryId: row.categoryId,
quantity: row.quantity == null ? null : Number(row.quantity),
unit: row.unit,
source: row.source,
status: row.status,
checkedAt: row.checkedAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+10
View File
@@ -14,6 +14,7 @@ class ProductApiPaths {
static const merge = '/products/merge';
static const mergePrivate = '/products/private/merge';
static String updateMineCategory(int id) => '/products/mine/$id/category';
static const backfillMineCategories = '/products/mine/backfill-categories';
static String mergePreview(int sourceProductId, int targetProductId) =>
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
static String setStatus(int id) => '/products/$id/status';
@@ -42,14 +43,23 @@ class FlyerImportApiPaths {
static const parse = '/flyer-import/parse';
static const latestSession = '/flyer-import/sessions/latest';
static String bySession(int sessionId) => '/flyer-import/sessions/$sessionId';
static String sourceBySession(int sessionId) => '/flyer-import/sessions/$sessionId/source';
static String patchItem(int sessionId, int itemId) => '/flyer-import/sessions/$sessionId/items/$itemId';
}
class FlyerSelectionApiPaths {
static String bySession(int sessionId) => '/flyer-sessions/$sessionId/selections';
static String bulkBySession(int sessionId) => '/flyer-sessions/$sessionId/selections/bulk';
static String planToShoppingListBySession(int sessionId) =>
'/flyer-sessions/$sessionId/selections/plan-to-shopping-list';
static const open = '/flyer-selections/open';
}
class ShoppingListApiPaths {
static const items = '/shopping-list/items';
static String updateStatus(int itemId) => '/shopping-list/items/$itemId/status';
}
class HelpTextApiPaths {
static String byKey(String key) => '/help-texts/${Uri.encodeComponent(key)}';
}
+12 -2
View File
@@ -22,6 +22,7 @@ import '../../features/inventory/presentation/consumption_history_screen.dart';
import '../../features/meal_plan/presentation/meal_plan_screen.dart';
import '../../features/pantry/presentation/pantry_screen.dart';
import '../../features/import/presentation/import_screen.dart';
import '../../features/shopping_list/presentation/shopping_list_screen.dart';
import '../../features/admin/presentation/admin_screen.dart';
int? _shellBranchIndexForPath(String path) {
@@ -30,8 +31,9 @@ int? _shellBranchIndexForPath(String path) {
if (path.startsWith('/matsedel')) return 2;
if (path.startsWith('/baslager')) return 3;
if (path.startsWith('/import')) return 4;
if (path.startsWith('/profile')) return 5;
if (path.startsWith('/admin')) return 6;
if (path.startsWith('/inkopslista')) return 5;
if (path.startsWith('/profile')) return 6;
if (path.startsWith('/admin')) return 7;
return null;
}
@@ -250,6 +252,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
path: '/inkopslista',
builder: (context, state) => const ShoppingListScreen(),
),
],
),
StatefulShellBranch(
routes: [
GoRoute(
+6
View File
@@ -55,6 +55,12 @@ class AppShell extends ConsumerWidget {
icon: Icons.upload_file_outlined,
label: 'Importera',
),
_AppDestination(
path: '/inkopslista',
title: 'Inköpslista',
icon: Icons.shopping_cart_outlined,
label: 'Inköpslista',
),
];
List<_AppDestination> _destinations() => _baseDestinations;
@@ -7,6 +7,7 @@ import 'dart:developer' as developer;
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_exception.dart';
import '../domain/flyer_import_item.dart';
import '../domain/flyer_import_result.dart';
import '../domain/help_text_content.dart';
import '../domain/quick_import_result.dart';
@@ -282,6 +283,95 @@ class ImportRepository {
return parsed.cast<Map<String, dynamic>>();
}
Future<Map<String, dynamic>> planFlyerSelectionsToShoppingList({
required int sessionId,
required List<int> itemIds,
String? token,
}) async {
final uri = Uri.parse('$_baseUrl${FlyerSelectionApiPaths.planToShoppingListBySession(sessionId)}');
final response = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({'itemIds': itemIds}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte planera till inköpslista: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
if (parsed is! Map<String, dynamic>) return const {};
return parsed;
}
Future<FlyerImportItem> updateFlyerSessionItem({
required int sessionId,
required int itemId,
required String rawName,
required int? categoryId,
String? token,
}) async {
final uri = Uri.parse('$_baseUrl${FlyerImportApiPaths.patchItem(sessionId, itemId)}');
final response = await _client.patch(
uri,
headers: {
'Content-Type': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
},
body: jsonEncode({
'rawName': rawName,
'categoryId': categoryId,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte uppdatera flyer-rad: ${response.body}',
statusCode: response.statusCode,
);
}
final parsed = _parseResponse(response);
if (parsed is! Map<String, dynamic>) {
throw ApiException(
type: ApiErrorType.unknown,
message: 'Felaktigt svar vid uppdatering av flyer-rad.',
);
}
return FlyerImportItem.fromJson(parsed);
}
Future<Uint8List> getFlyerSourceBytes({
required int sessionId,
String? token,
}) async {
final response = await _client.get(
Uri.parse('$_baseUrl${FlyerImportApiPaths.sourceBySession(sessionId)}'),
headers: {
if (token != null) 'Authorization': 'Bearer $token',
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(
type: _mapStatusCodeToErrorType(response.statusCode),
message: 'Kunde inte hämta flyerkälla: ${response.body}',
statusCode: response.statusCode,
);
}
return response.bodyBytes;
}
/// Upload a file (PDF or image) for recipe extraction.
///
/// [bytes] — raw file bytes from file_picker.
@@ -3,6 +3,7 @@ class FlyerImportItem {
final String rawName;
final String normalizedName;
final String? category;
final int? categoryId;
final double? price;
final String? priceUnit;
final String? offerText;
@@ -22,6 +23,7 @@ class FlyerImportItem {
required this.rawName,
required this.normalizedName,
this.category,
this.categoryId,
this.price,
this.priceUnit,
this.offerText,
@@ -43,6 +45,7 @@ class FlyerImportItem {
rawName: json['rawName'] as String? ?? '',
normalizedName: json['normalizedName'] as String? ?? '',
category: json['category'] as String?,
categoryId: (json['categoryId'] as num?)?.toInt(),
price: (json['price'] as num?)?.toDouble(),
priceUnit: json['priceUnit'] as String?,
offerText: json['offerText'] as String?,
@@ -65,6 +68,7 @@ class FlyerImportItem {
'rawName': rawName,
'normalizedName': normalizedName,
'category': category,
'categoryId': categoryId,
'price': price,
'priceUnit': priceUnit,
'offerText': offerText,
@@ -80,4 +84,31 @@ class FlyerImportItem {
'matchConfidence': matchConfidence,
};
}
FlyerImportItem copyWith({
String? rawName,
String? category,
int? categoryId,
}) {
return FlyerImportItem(
flyerItemId: flyerItemId,
rawName: rawName ?? this.rawName,
normalizedName: normalizedName,
category: category ?? this.category,
categoryId: categoryId ?? this.categoryId,
price: price,
priceUnit: priceUnit,
offerText: offerText,
isOffer: isOffer,
offerLimitText: offerLimitText,
comparisonPrice: comparisonPrice,
comparisonUnit: comparisonUnit,
parseConfidence: parseConfidence,
parseReasons: parseReasons,
matchedProductId: matchedProductId,
matchedProductName: matchedProductName,
matchedVia: matchedVia,
matchConfidence: matchConfidence,
);
}
}
@@ -4,11 +4,19 @@ class FlyerImportResult {
final int? sessionId;
final List<FlyerImportItem> items;
final List<String> warnings;
final bool sourceAvailable;
final String? sourceFileName;
final String? sourceMimeType;
final int? sourceFileSize;
FlyerImportResult({
required this.sessionId,
required this.items,
required this.warnings,
required this.sourceAvailable,
this.sourceFileName,
this.sourceMimeType,
this.sourceFileSize,
});
factory FlyerImportResult.fromJson(Map<String, dynamic> json) {
@@ -24,6 +32,10 @@ class FlyerImportResult {
.map((item) => FlyerImportItem.fromJson(item as Map<String, dynamic>))
.toList(),
warnings: warnings,
sourceAvailable: json['sourceAvailable'] == true,
sourceFileName: json['sourceFileName'] as String?,
sourceMimeType: json['sourceMimeType'] as String?,
sourceFileSize: (json['sourceFileSize'] as num?)?.toInt(),
);
}
@@ -32,6 +44,10 @@ class FlyerImportResult {
'sessionId': sessionId,
'items': items.map((item) => item.toJson()).toList(),
'warnings': warnings,
'sourceAvailable': sourceAvailable,
'sourceFileName': sourceFileName,
'sourceMimeType': sourceMimeType,
'sourceFileSize': sourceFileSize,
};
}
}
@@ -1,9 +1,16 @@
import 'package:file_picker/file_picker.dart';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../admin/domain/admin_category_node.dart';
import '../../../core/utils/pdf_opener.dart';
import '../../auth/data/auth_providers.dart';
import '../../shopping_list/data/shopping_list_providers.dart';
import '../data/flyer_import_session.dart';
import '../data/import_providers.dart';
import '../domain/flyer_import_item.dart';
@@ -21,15 +28,38 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
bool _isLoading = false;
bool _isSaving = false;
PlatformFile? _pickedFile;
Uint8List? _restoredSourceBytes;
List<AdminCategoryNode> _categoryTree = const [];
FlyerImportResult? _result;
final Map<int, bool> _selected = {};
@override
void initState() {
super.initState();
_loadCategoryTree();
_restoreSession();
}
Future<void> _loadCategoryTree() async {
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
final categoryData = await api.getJson(CategoryApiPaths.tree, token: token);
final categoryList = categoryData is List<dynamic>
? categoryData
: (categoryData is Map<String, dynamic> && categoryData['items'] is List<dynamic>)
? categoryData['items'] as List<dynamic>
: const <dynamic>[];
final tree = categoryList
.map((e) => AdminCategoryNode.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
if (!mounted) return;
setState(() => _categoryTree = tree);
} catch (_) {
// Kategoriträdet är valfritt för att visa listan.
}
}
Future<void> _restoreSession() async {
final notifier = ref.read(flyerImportSessionProvider.notifier);
await notifier.restore();
@@ -53,10 +83,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
: session.selected;
setState(() {
_result = serverResult;
_restoredSourceBytes = null;
_selected
..clear()
..addAll(selected);
});
await _loadRestoredSourceIfNeeded(serverResult, token);
notifier.setImportedResult(
result: serverResult,
selected: selected,
@@ -78,10 +110,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
};
setState(() {
_result = latest;
_restoredSourceBytes = null;
_selected
..clear()
..addAll(selected);
});
await _loadRestoredSourceIfNeeded(latest, token);
notifier.setImportedResult(
result: latest,
selected: selected,
@@ -100,6 +134,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
if (!mounted || session?.result == null) return;
setState(() {
_result = session!.result;
_restoredSourceBytes = null;
_selected
..clear()
..addAll(session.selected);
@@ -107,6 +142,20 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
}
Future<void> _loadRestoredSourceIfNeeded(FlyerImportResult result, String? token) async {
if (result.sessionId == null || result.sourceAvailable != true) return;
if (_pickedFile?.bytes != null) return;
try {
final repo = ref.read(importRepositoryProvider);
final bytes = await repo.getFlyerSourceBytes(sessionId: result.sessionId!, token: token);
if (!mounted) return;
setState(() => _restoredSourceBytes = bytes);
} catch (_) {
if (!mounted) return;
setState(() => _restoredSourceBytes = null);
}
}
Future<void> _pickFile() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
@@ -138,6 +187,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
setState(() {
_result = parsed;
_restoredSourceBytes = null;
_selected
..clear()
..addAll(selected);
@@ -159,10 +209,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
if (result?.sessionId == null) return;
final itemsToSave = <Map<String, dynamic>>[];
final selectedItemIds = <int>[];
for (var i = 0; i < result!.items.length; i++) {
final item = result.items[i];
final isSelected = _selected[i] == true;
if (!isSelected || item.flyerItemId == null) continue;
selectedItemIds.add(item.flyerItemId!);
itemsToSave.add({
'itemId': item.flyerItemId,
'plannedQuantity': 1,
@@ -187,9 +239,23 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
items: itemsToSave,
token: token,
);
final shopping = await repo.planFlyerSelectionsToShoppingList(
sessionId: result.sessionId!,
itemIds: selectedItemIds,
token: token,
);
if (!mounted) return;
final created = (shopping['created'] as num?)?.toInt() ?? 0;
final updated = (shopping['updated'] as num?)?.toInt() ?? 0;
ref.invalidate(shoppingListItemsProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${saved.length} varor planerade.')),
SnackBar(
content: Text('${saved.length} planerade. Inköpslista: $created tillagda, $updated uppdaterade.'),
action: SnackBarAction(
label: 'Öppna',
onPressed: () => context.go('/inkopslista'),
),
),
);
} catch (e) {
if (mounted) showErrorDialog(context, 'Kunde inte planera varor: $e');
@@ -198,6 +264,141 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
}
Future<void> _editItem(int index, FlyerImportItem item) async {
final sessionId = _result?.sessionId;
final itemId = item.flyerItemId;
if (sessionId == null || itemId == null) return;
final nameController = TextEditingController(text: item.rawName);
int? selectedCategoryId = item.categoryId;
String? selectedCategoryPath = item.category;
final payload = await showDialog<({String name, int? categoryId, String? categoryPath})>(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setLocalState) {
return AlertDialog(
title: const Text('Redigera rad'),
content: SizedBox(
width: 420,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Namn',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
selectedCategoryPath == null || selectedCategoryPath!.isEmpty
? 'Ingen kategori vald'
: selectedCategoryPath!,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: _categoryTree.isEmpty
? null
: () async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: _categoryTree,
preselectedCategoryId: selectedCategoryId,
);
if (selected == null) return;
setLocalState(() {
selectedCategoryId = selected.id;
selectedCategoryPath = selected.path;
});
},
icon: const Icon(Icons.category_outlined),
label: const Text('Välj kategori'),
),
TextButton(
onPressed: () {
setLocalState(() {
selectedCategoryId = null;
selectedCategoryPath = null;
});
},
child: const Text('Rensa'),
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () {
Navigator.of(context).pop((
name: nameController.text.trim(),
categoryId: selectedCategoryId,
categoryPath: selectedCategoryPath,
));
},
child: const Text('Spara'),
),
],
);
},
);
},
);
nameController.dispose();
if (payload == null || payload.name.isEmpty) return;
try {
final token = await ref.read(authStateProvider.future);
final repo = ref.read(importRepositoryProvider);
final updated = await repo.updateFlyerSessionItem(
sessionId: sessionId,
itemId: itemId,
rawName: payload.name,
categoryId: payload.categoryId,
token: token,
);
if (!mounted) return;
final result = _result;
if (result == null) return;
final nextItems = [...result.items];
nextItems[index] = updated;
final nextResult = FlyerImportResult(
sessionId: result.sessionId,
items: nextItems,
warnings: result.warnings,
sourceAvailable: result.sourceAvailable,
sourceFileName: result.sourceFileName,
sourceMimeType: result.sourceMimeType,
sourceFileSize: result.sourceFileSize,
);
setState(() {
_result = nextResult;
});
ref.read(flyerImportSessionProvider.notifier).setImportedResult(
result: nextResult,
selected: Map<int, bool>.from(_selected),
fileName: _pickedFile?.name ?? result.sourceFileName,
);
} catch (e) {
if (!mounted) return;
showErrorDialog(context, 'Kunde inte uppdatera rad: $e');
}
}
String _formatPrice(double? price, String? unit) {
if (price == null) return '';
final raw = price.toStringAsFixed(2).replaceAll('.', ',');
@@ -320,10 +521,10 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
Widget _buildFlyerPreview(ThemeData theme) {
final file = _pickedFile;
final bytes = file?.bytes;
final bytes = file?.bytes ?? _restoredSourceBytes;
if (bytes == null) return const SizedBox.shrink();
final filename = file?.name ?? '';
final filename = file?.name ?? _result?.sourceFileName ?? '';
final fallbackExt = filename.contains('.') ? filename.split('.').last : '';
final ext = (file?.extension ?? fallbackExt).toLowerCase();
final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext);
@@ -339,7 +540,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
color: theme.colorScheme.primary,
),
title: const Text('Flyerförhandsvisning'),
subtitle: Text(file?.name ?? ''),
subtitle: Text(filename),
trailing: isImage
? null
: OutlinedButton.icon(
@@ -452,6 +653,12 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
title: Row(
children: [
Expanded(child: Text(item.rawName)),
IconButton(
tooltip: 'Redigera',
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.edit_outlined, size: 18),
onPressed: () => _editItem(index, item),
),
const SizedBox(width: 8),
_buildQualityBadge(item, theme),
const SizedBox(width: 8),
@@ -462,6 +669,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (priceText.isNotEmpty) Text('Pris: $priceText'),
if ((item.category ?? '').trim().isNotEmpty)
Text('Kategori: ${item.category}'),
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
if (limitText != null && limitText.isNotEmpty)
Text(
@@ -249,6 +249,7 @@ class _CreateInventoryScreenState
setState(() => _saving = true);
try {
final token = await ref.read(authStateProvider.future);
await _syncSelectedProductCategory(token);
await ref
.read(pantryRepositoryProvider)
.createPantryItem(
@@ -527,4 +528,3 @@ class _CreateInventoryScreenState
);
}
}
@@ -3,10 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/api/api_paths.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart';
import '../../../core/utils/display_labels.dart';
import '../../auth/data/auth_providers.dart';
import '../../pantry/data/pantry_providers.dart';
import '../domain/inventory_item.dart';
import '../data/inventory_providers.dart';
import 'swipeable_inventory_tile.dart';
@@ -21,6 +24,27 @@ class InventoryScreen extends ConsumerStatefulWidget {
class _InventoryScreenState extends ConsumerState<InventoryScreen> {
final Set<int> _selectedIds = <int>{};
static const _sortByDisplayedCategory = 'l1CategoryAsc';
bool _backfillTriggered = false;
@override
void initState() {
super.initState();
_triggerCategoryBackfill();
}
Future<void> _triggerCategoryBackfill() async {
if (_backfillTriggered) return;
_backfillTriggered = true;
try {
final token = await ref.read(authStateProvider.future);
final api = ref.read(apiClientProvider);
await api.postJson(ProductApiPaths.backfillMineCategories, token: token);
ref.invalidate(inventoryProvider);
ref.invalidate(pantryProvider);
} catch (_) {
// Ignorera fel här för att inte blockera vyn.
}
}
static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_providers.dart';
import '../../../core/api/guarded_api_call.dart';
import '../../auth/data/auth_providers.dart';
import '../domain/shopping_list_item.dart';
import 'shopping_list_repository.dart';
final shoppingListRepositoryProvider = Provider<ShoppingListRepository>((ref) {
return ShoppingListRepository(ref.watch(apiClientProvider));
});
final shoppingListItemsProvider = FutureProvider<List<ShoppingListItem>>((ref) async {
final token = await ref.watch(authStateProvider.future);
return guardedApiCall(ref, () {
return ref.read(shoppingListRepositoryProvider).fetchOpenItems(token: token);
});
});
@@ -0,0 +1,60 @@
import '../../../core/api/api_client.dart';
import '../../../core/api/api_exception.dart';
import '../../../core/api/api_paths.dart';
import '../domain/shopping_list_item.dart';
class ShoppingListRepository {
final ApiClient _api;
const ShoppingListRepository(this._api);
Future<List<ShoppingListItem>> fetchOpenItems({String? token}) async {
try {
final data = await _api.getJson(ShoppingListApiPaths.items, token: token);
if (data is! List) {
throw const ApiException(
type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.',
);
}
return data
.map((item) => ShoppingListItem.fromJson(item as Map<String, dynamic>))
.toList();
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte hämta inköpslistan.',
);
}
}
Future<ShoppingListItem> updateStatus({
required int itemId,
required bool checked,
String? token,
}) async {
try {
final data = await _api.patchJson(
ShoppingListApiPaths.updateStatus(itemId),
body: {'checked': checked},
token: token,
);
if (data is! Map<String, dynamic>) {
throw const ApiException(
type: ApiErrorType.unknown,
message: 'Ogiltigt svar från servern.',
);
}
return ShoppingListItem.fromJson(data);
} on ApiException {
rethrow;
} catch (_) {
throw const ApiException(
type: ApiErrorType.network,
message: 'Kunde inte uppdatera inköpsrad.',
);
}
}
}
@@ -0,0 +1,40 @@
class ShoppingListItem {
final int id;
final String name;
final int? productId;
final int? categoryId;
final double? quantity;
final String? unit;
final String status;
const ShoppingListItem({
required this.id,
required this.name,
required this.productId,
required this.categoryId,
required this.quantity,
required this.unit,
required this.status,
});
factory ShoppingListItem.fromJson(Map<String, dynamic> json) {
return ShoppingListItem(
id: (json['id'] as num).toInt(),
name: (json['name'] ?? '').toString(),
productId: (json['productId'] as num?)?.toInt(),
categoryId: (json['categoryId'] as num?)?.toInt(),
quantity: (json['quantity'] as num?)?.toDouble(),
unit: json['unit'] as String?,
status: (json['status'] ?? 'open').toString(),
);
}
String get quantityLabel {
if (quantity == null) return '';
final text = quantity == quantity!.roundToDouble()
? quantity!.toStringAsFixed(0)
: quantity!.toStringAsFixed(2).replaceAll('.', ',');
if (unit == null || unit!.trim().isEmpty) return text;
return '$text ${unit!.trim()}';
}
}
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart';
import '../../../core/ui/async_state_views.dart';
import '../../auth/data/auth_providers.dart';
import '../data/shopping_list_providers.dart';
import '../domain/shopping_list_item.dart';
class ShoppingListScreen extends ConsumerWidget {
const ShoppingListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncItems = ref.watch(shoppingListItemsProvider);
return asyncItems.when(
loading: () => const LoadingStateView(label: 'Laddar inköpslista...'),
error: (error, _) => ErrorStateView(
message: mapErrorToUserMessage(error, context),
onRetry: () => ref.invalidate(shoppingListItemsProvider),
),
data: (items) {
if (items.isEmpty) {
return const EmptyStateView(
title: 'Inköpslistan är tom',
description: 'Planerade flyer-varor hamnar här.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final item = items[index];
return _ShoppingListTile(item: item);
},
);
},
);
}
}
class _ShoppingListTile extends ConsumerStatefulWidget {
final ShoppingListItem item;
const _ShoppingListTile({required this.item});
@override
ConsumerState<_ShoppingListTile> createState() => _ShoppingListTileState();
}
class _ShoppingListTileState extends ConsumerState<_ShoppingListTile> {
bool _saving = false;
Future<void> _checkOff() async {
if (_saving) return;
setState(() => _saving = true);
try {
final token = await ref.read(authStateProvider.future);
await ref.read(shoppingListRepositoryProvider).updateStatus(
itemId: widget.item.id,
checked: true,
token: token,
);
ref.invalidate(shoppingListItemsProvider);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final item = widget.item;
return Card(
child: ListTile(
leading: _saving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Checkbox(
value: false,
onChanged: (_) => _checkOff(),
),
title: Text(item.name),
subtitle: item.quantityLabel.isEmpty ? null : Text(item.quantityLabel),
),
);
}
}
@@ -31,6 +31,7 @@ void main() {
),
],
warnings: const [],
sourceAvailable: false,
),
selected: const {0: true},
fileName: 'flyer.pdf',
@@ -0,0 +1,41 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:recipe_flutter/features/inventory/domain/inventory_item.dart';
void main() {
group('InventoryItem.l1Category', () {
test('uses first category level from category path', () {
final item = InventoryItem.fromJson({
'id': 1,
'productId': 10,
'quantity': 1,
'unit': 'st',
'product': {
'name': 'Tomat',
'categoryRef': {
'name': 'Tomater',
'parent': {
'name': 'Grönsaker',
'parent': {'name': 'Mat'},
},
},
},
});
expect(item.categoryPath, 'Mat > Grönsaker > Tomater');
expect(item.l1Category, 'Mat');
});
test('falls back to Övrigt when category path is missing', () {
final item = InventoryItem.fromJson({
'id': 1,
'productId': 10,
'quantity': 1,
'unit': 'st',
'product': {'name': 'Okänd', 'categoryRef': null},
});
expect(item.categoryPath, isNull);
expect(item.l1Category, 'Övrigt');
});
});
}