From 27d622bfe67880fbbf5efd250aed9f10a871ce72 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 24 May 2026 21:31:53 +0200 Subject: [PATCH] feat(inventory): add multi-country origin tracking - Added `originCountries` field to `InventoryItem` model for multi-country origin support - Updated `CreateInventoryDto` and `UpdateInventoryDto` with `originCountries` array field - Modified `InventoryService` to handle `originCountries` in create and update operations - Added `origin` field to `FlyerImportItem` response type for consistency - Added `categoryId` field to `ParsedReceiptItem` DTO for improved receipt parsing - Created database migration `20260524_add_origin_countries` for schema changes --- .kilo/plans/1779644560462-playful-eagle.md | 69 ++++++++++++++++ .../migration.sql | 9 +++ backend/prisma/schema.prisma | 1 + .../flyer-import/dto/flyer-import.response.ts | 55 ++++++------- .../src/flyer-import/flyer-import.service.ts | 79 ++++++++++--------- .../src/inventory/dto/create-inventory.dto.ts | 3 + .../src/inventory/dto/update-inventory.dto.ts | 7 ++ backend/src/inventory/inventory.service.ts | 9 +++ .../dto/parsed-receipt-item.dto.ts | 1 + 9 files changed, 167 insertions(+), 66 deletions(-) create mode 100644 .kilo/plans/1779644560462-playful-eagle.md create mode 100644 backend/prisma/migrations/20260524_add_origin_countries/migration.sql diff --git a/.kilo/plans/1779644560462-playful-eagle.md b/.kilo/plans/1779644560462-playful-eagle.md new file mode 100644 index 00000000..aa15dda6 --- /dev/null +++ b/.kilo/plans/1779644560462-playful-eagle.md @@ -0,0 +1,69 @@ +# Plan: Harmonisering av importfält baserat på inventory-tabellen + +## Mål +Skapa konsistens mellan kvitto-import, flyer-import och inventory-tabellen genom att anpassa fältnamn, datatyper och struktur. Detta kommer att förenkla integrationen och minska risken för fel. + +## Bakgrund +- `inventory`-tabellen är central och har en väletablerad struktur. +- Kvitto-import och flyer-import använder olika fältnamn och datatyper, vilket skapar inkonsistenser. +- Flyer-import använder `signals.originCountries` (array), medan `inventory` använder `origin` (string). + +## Scope +- Uppdatera `ParsedReceiptItem` och `FlyerImportItem` för att matcha `inventory`-tabellen. +- Uppdatera mappningslogiken i importfunktionerna. +- Uppdatera databasen för att stödja `originCountries` som en array (lång sikt). + +## Implementationsplan + +### 1. Uppdatera `ParsedReceiptItem` (kvitto-import) +- **Mål**: Anpassa fältnamn och datatyper för att matcha `inventory`-tabellen. +- **Åtgärder**: + - Lägg till `categoryId` för att möjliggöra kategorisättning. + - Använd `rawName` istället för `receiptName` för konsistens. + - Mappa `origin` till `inventory.origin`. + +### 2. Uppdatera `FlyerImportItem` (flyer-import) +- **Mål**: Anpassa fältnamn och datatyper för att matcha `inventory`-tabellen. +- **Åtgärder**: + - Använd `rawName` istället för `receiptName` för konsistens. + - Mappa `signals.originCountries[0]` till `inventory.origin`. + - Mappa `categoryId` till `product.categoryId` om en produkt skapas/uppdateras. + +### 3. Uppdatera mappningslogiken +- **Mål**: Förenkla mappningen från importfunktionerna till `inventory`-tabellen. +- **Åtgärder**: + - Uppdatera `receipt-import.service.ts` för att använda `inventory`-fältnamn. + - Uppdatera `flyer-import.service.ts` för att använda `inventory`-fältnamn. + +### 4. Uppdatera databasen (lång sikt) +- **Mål**: Stödja `originCountries` som en array i `inventory`-tabellen. +- **Åtgärder**: + - Lägg till `originCountries Json?` i `inventory`-tabellen. + - Uppdatera `CreateInventoryDto` för att inkludera `originCountries`. + +### 5. Uppdatera DTO:er +- **Mål**: Säkerställa att DTO:er matchar `inventory`-tabellen. +- **Åtgärder**: + - Uppdatera `CreateInventoryDto` för att inkludera `originCountries`. + +## Leverabler +- Uppdaterade `ParsedReceiptItem` och `FlyerImportItem` som matchar `inventory`-tabellen. +- Uppdaterad mappningslogik i `receipt-import.service.ts` och `flyer-import.service.ts`. +- Uppdaterad databas för att stödja `originCountries` som en array. +- Uppdaterade DTO:er för att inkludera `originCountries`. + +## Acceptanskriterier +- `ParsedReceiptItem` och `FlyerImportItem` använder samma fältnamn och datatyper som `inventory`-tabellen. +- Mappningslogiken i importfunktionerna är förenklad och använder `inventory`-fältnamn. +- `inventory`-tabellen stödjer `originCountries` som en array. +- `CreateInventoryDto` inkluderar `originCountries`. + +## Rekommenderad genomförandeordning +1. Uppdatera `ParsedReceiptItem` och `FlyerImportItem`. +2. Uppdatera mappningslogiken i importfunktionerna. +3. Uppdatera databasen för att stödja `originCountries` som en array. +4. Uppdatera DTO:er för att inkludera `originCountries`. + +## Handover from Planning Session +- Planen är klar och redo för implementering. +- Inga frågor eller otydligheter kvarstår. diff --git a/backend/prisma/migrations/20260524_add_origin_countries/migration.sql b/backend/prisma/migrations/20260524_add_origin_countries/migration.sql new file mode 100644 index 00000000..04c95d4c --- /dev/null +++ b/backend/prisma/migrations/20260524_add_origin_countries/migration.sql @@ -0,0 +1,9 @@ +-- Add originCountries field to InventoryItem table +-- This migration adds support for multiple origin countries as a JSON array + +ALTER TABLE `InventoryItem` +ADD COLUMN `originCountries` JSON NULL +AFTER `origin`; + +-- Create an index for the originCountries field for better query performance +CREATE INDEX `IDX_InventoryItem_originCountries` ON `InventoryItem` ((CAST(`originCountries` AS CHAR(255)))); \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ee59a37c..92167dab 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -104,6 +104,7 @@ model InventoryItem { unit String brand String? origin String? + originCountries Json? receiptName String? location String? purchaseDate DateTime? diff --git a/backend/src/flyer-import/dto/flyer-import.response.ts b/backend/src/flyer-import/dto/flyer-import.response.ts index 9e1e5f74..a685a7cb 100644 --- a/backend/src/flyer-import/dto/flyer-import.response.ts +++ b/backend/src/flyer-import/dto/flyer-import.response.ts @@ -17,35 +17,36 @@ export type FlyerReasonDescriptor = { location: string | null; }; -export type FlyerImportItem = { - flyerItemId: number | null; - rawName: string; - normalizedName: string; - brand: string | null; - category: string | null; - categoryId: number | null; - price: number | null; - priceUnit: string | null; - comparisonPrice: number | null; - comparisonUnit: string | null; - weight: string | null; - bundleWeight: string | null; - isBundle: boolean; - bundleItems: string[]; - displayNameDetailed: string | null; - signals: FlyerImportSignals | null; - offerText: string | null; +export type FlyerImportItem = { + flyerItemId: number | null; + rawName: string; + normalizedName: string; + brand: string | null; + category: string | null; + categoryId: number | null; + price: number | null; + priceUnit: string | null; + comparisonPrice: number | null; + comparisonUnit: string | null; + weight: string | null; + bundleWeight: string | null; + isBundle: boolean; + bundleItems: string[]; + displayNameDetailed: string | null; + signals: FlyerImportSignals | null; + offerText: string | null; isOffer: boolean; offerLimitText: string | null; - parseConfidence: number; - parseReasons: string[]; - parseReasonsDetailed: FlyerReasonDescriptor[]; - matchedProductId: number | null; - matchedProductName: string | null; - matchedVia: FlyerImportMatchVia; - matchConfidence: number; - matchReasons: string[]; - matchReasonsDetailed: FlyerReasonDescriptor[]; + parseConfidence: number; + parseReasons: string[]; + parseReasonsDetailed: FlyerReasonDescriptor[]; + matchedProductId: number | null; + matchedProductName: string | null; + matchedVia: FlyerImportMatchVia; + matchConfidence: number; + matchReasons: string[]; + matchReasonsDetailed: FlyerReasonDescriptor[]; + origin?: string | null; }; export type FlyerImportResponse = { diff --git a/backend/src/flyer-import/flyer-import.service.ts b/backend/src/flyer-import/flyer-import.service.ts index f703bc1d..685cded3 100644 --- a/backend/src/flyer-import/flyer-import.service.ts +++ b/backend/src/flyer-import/flyer-import.service.ts @@ -874,45 +874,46 @@ export class FlyerImportService { const offerLimitText = this.extractOfferLimitText(item.offerText); const offerSignals = this.extractOfferSignals(item.offerText); - return { - flyerItemId: item.id, - rawName: item.rawName, - normalizedName: item.normalizedName, - brand: item.brand, - 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, - comparisonUnit: this.normalizeUnit(item.comparisonUnit) ?? offerSignals.comparisonUnit, - weight: item.weight, - bundleWeight: item.bundleWeight, - isBundle: item.isBundle, - bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)), - displayNameDetailed: - item.displayNameDetailed ?? - buildDisplayNameDetailed({ - rawName: item.rawName, - isBundle: item.isBundle, - bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)), - }), - signals: toSignals(item.signals), - offerText: item.offerText, - isOffer: - item.price != null - || item.comparisonPrice != null - || !!item.offerText?.trim() - || offerSignals.hasCampaignPattern, - offerLimitText, - parseConfidence: item.parseConfidence, - parseReasons: toStringArray(item.parseReasons), - parseReasonsDetailed: this.describeParseReasons(toStringArray(item.parseReasons)), - matchedProductId: item.matchedProductId, - matchedProductName: item.matchedProductName, - matchedVia: normalizedMatchVia, - matchConfidence: item.matchConfidence ?? 0, - matchReasons: toStringArray(item.matchReasons), - matchReasonsDetailed: this.describeMatchReasons(toStringArray(item.matchReasons)), + return { + flyerItemId: item.id, + rawName: item.rawName, + normalizedName: item.normalizedName, + brand: item.brand, + 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, + comparisonUnit: this.normalizeUnit(item.comparisonUnit) ?? offerSignals.comparisonUnit, + weight: item.weight, + bundleWeight: item.bundleWeight, + isBundle: item.isBundle, + bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)), + displayNameDetailed: + item.displayNameDetailed ? + buildDisplayNameDetailed({ + rawName: item.rawName, + isBundle: item.isBundle, + bundleItems: this.sanitizeBundleItems(toStringArray(item.bundleItems)), + }), + signals: toSignals(item.signals), + offerText: item.offerText, + isOffer: + item.price != null + || item.comparisonPrice != null + || !!item.offerText?.trim() + || offerSignals.hasCampaignPattern, + offerLimitText, + parseConfidence: item.parseConfidence, + parseReasons: toStringArray(item.parseReasons), + parseReasonsDetailed: this.describeParseReasons(toStringArray(item.parseReasons)), + matchedProductId: item.matchedProductId, + matchedProductName: item.matchedProductName, + matchedVia: normalizedMatchVia, + matchConfidence: item.matchConfidence ?? 0, + matchReasons: toStringArray(item.matchReasons), + matchReasonsDetailed: this.describeMatchReasons(toStringArray(item.matchReasons)), + origin: toSignals(item.signals)?.originCountries?.[0] ?? null, }; } diff --git a/backend/src/inventory/dto/create-inventory.dto.ts b/backend/src/inventory/dto/create-inventory.dto.ts index cf029b4a..8f8b6df8 100644 --- a/backend/src/inventory/dto/create-inventory.dto.ts +++ b/backend/src/inventory/dto/create-inventory.dto.ts @@ -36,6 +36,9 @@ export class CreateInventoryDto { @IsString() origin?: string; + @IsOptional() + originCountries?: string[]; + @IsOptional() @IsString() receiptName?: string; diff --git a/backend/src/inventory/dto/update-inventory.dto.ts b/backend/src/inventory/dto/update-inventory.dto.ts index 79d85039..08a43793 100644 --- a/backend/src/inventory/dto/update-inventory.dto.ts +++ b/backend/src/inventory/dto/update-inventory.dto.ts @@ -35,6 +35,13 @@ export class UpdateInventoryDto { @IsString() brand?: string; + @IsOptional() + @IsString() + origin?: string; + + @IsOptional() + originCountries?: string[]; + @IsOptional() @IsString() receiptName?: string; diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index 1ce4a647..bb619642 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -91,6 +91,7 @@ export class InventoryService { location: data.location?.trim() || undefined, brand: data.brand?.trim() || undefined, origin: data.origin?.trim() || undefined, + originCountries: data.originCountries || undefined, receiptName: data.receiptName?.trim() || undefined, suitableFor: data.suitableFor?.trim() || undefined, comment: data.comment?.trim() || undefined, @@ -128,6 +129,14 @@ export class InventoryService { updateData.brand = data.brand.trim(); } + if (typeof data.origin === 'string') { + updateData.origin = data.origin.trim(); + } + + if (Array.isArray(data.originCountries)) { + updateData.originCountries = data.originCountries; + } + if (typeof data.receiptName === 'string') { updateData.receiptName = data.receiptName.trim(); } diff --git a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts index 147db208..83c10118 100644 --- a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts +++ b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts @@ -9,6 +9,7 @@ export interface ParsedReceiptItem { price?: number | null; brand?: string | null; origin?: string | null; + categoryId?: number | null; // alias-match: säker, användaren slipper bekräfta matchedProductId?: number; matchedProductName?: string;