feat(inventory): add multi-country origin tracking
Test Suite / backend-pr-quick (push) Has been skipped
Test Suite / quick-import-pr-quick (push) Has been skipped
Test Suite / backend-full (push) Failing after 3m32s
Test Suite / flutter-quality (push) Failing after 1m0s

- 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
This commit is contained in:
Nils-Johan Gynther
2026-05-24 21:31:53 +02:00
parent ca1eed5061
commit 27d622bfe6
9 changed files with 167 additions and 66 deletions
@@ -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.
@@ -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))));
+1
View File
@@ -104,6 +104,7 @@ model InventoryItem {
unit String
brand String?
origin String?
originCountries Json?
receiptName String?
location String?
purchaseDate DateTime?
@@ -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 = {
@@ -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,
};
}
@@ -36,6 +36,9 @@ export class CreateInventoryDto {
@IsString()
origin?: string;
@IsOptional()
originCountries?: string[];
@IsOptional()
@IsString()
receiptName?: string;
@@ -35,6 +35,13 @@ export class UpdateInventoryDto {
@IsString()
brand?: string;
@IsOptional()
@IsString()
origin?: string;
@IsOptional()
originCountries?: string[];
@IsOptional()
@IsString()
receiptName?: string;
@@ -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();
}
@@ -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;