Compare commits

..

2 Commits

Author SHA1 Message Date
Nils-Johan Gynther c720f611ea Merge branch 'main' of ssh://gitea.gynther.se:2222/nilsjohan/recipe-app
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 1m36s
Test Suite / flutter-quality (push) Failing after 1m19s
2026-05-18 23:27:31 +02:00
Nils-Johan Gynther e658f2e6f1 chore(ci): update project documentation and flyer import features
Update project documentation with recent CI improvements and flyer import enhancements:

- Add ESLint configuration for backend and Dart lints for Flutter
- Document Prisma query logging via PRISMA_LOG_QUERIES environment variable
- Update NEXT_STEPS.md, README.md, and TEKNISK_BESKRIVNING.md with new features
- Add isOffer, offerLimitText, comparisonPrice, comparisonUnit, parseConfidence, and parseReasons fields to FlyerImportItem
- Update FlyerImportResponse type to include new fields
- Extend file picker to support image formats (png, jpg, jpeg, webp)
- Add offer badge display and price formatting in Flutter UI
- Implement PDF preview functionality for flyer import
2026-05-18 23:27:20 +02:00
7 changed files with 563 additions and 388 deletions
+11 -3
View File
@@ -41,9 +41,17 @@ MVP ar uppnadd nar en vanlig anvandare kan importera, granska och spara kvitto/r
- Deploy, healthcheck och testkorning ar reproducerbara i driftmiljo.
## Nyligen klart
## Utförda steg (2026-05-13)
## Nyligen klart
## Utförda steg (2026-05-18)
- [x] **ESLint i backend + CI:** ESLint-konfiguration tillagd i backend och CI-workflow uppdaterad med lint-step för PR/push.
- [x] **Dart lint-konfig aktiverad:** `flutter/analysis_options.yaml` tillagd för att säkerställa `flutter_lints` i analyskörningar.
- [x] **Prisma query logging styrbar per miljö:** `PRISMA_LOG_QUERIES` implementerad i backend samt kopplad i `compose.yml`.
- [x] **Dokumenterat aktivering av query-loggar:** Instruktion att sätta `PRISMA_LOG_QUERIES=1` och starta om `recipe-api` i test/staging.
- [x] **Korrigerat testförväntan i receipt-import:** Security-test för saknat användar-id uppdaterat till `UnauthorizedException`.
## Utförda steg (2026-05-13)
- [x] **Centralt hjälptextsystem (backend):** Nytt `HelpTextsModule` med service, controller och DTO. `GET /api/help-texts/:key` returnerar rätt hjälptext baserat på användarroll (prioritetsordning: admin → user → default). `PUT /api/help-texts/:key/:scope` kräver admin-roll.
- [x] **Prisma-migration:** `20260513150000_add_help_texts``HelpText`-tabell med `@@unique([key, scope])`-constraint och index. Seed-data för `receipt_import` (default + admin-scope) på svenska.
+9 -1
View File
@@ -1,5 +1,13 @@
# Nyheter och förbättringar (2026-05-13)
# Nyheter och förbättringar (2026-05-18)
- **CI: ESLint för backend:** ESLint är infört i backend (`backend/eslint.config.mjs`) och körs i GitHub Actions (`.github/workflows/test.yml`) via steget `Lint backend`.
- **CI: Dart lints aktiverade:** `flutter/analysis_options.yaml` är tillagd med `include: package:flutter_lints/flutter.yaml`, så `flutter analyze` använder explicita lint-regler.
- **Prisma query logging i test/staging:** Backend stödjer nu env-styrd query-loggning via `PRISMA_LOG_QUERIES` i `backend/src/prisma/prisma.service.ts`.
- **Compose-stöd för loggning:** `compose.yml` har `PRISMA_LOG_QUERIES: "${PRISMA_LOG_QUERIES:-0}"` för säker default av.
- **Testfix receipt-import:** Säkerhetstestet för saknat användar-id i `upsertUnitMapping` är uppdaterat till `UnauthorizedException`, i linje med controllerns beteende.
# Nyheter och förbättringar (2026-05-13)
- **Centralt hjälptextsystem:** Nytt backend-modul (`HelpTextsModule`) med `GET /api/help-texts/:key` (rollmedveten) och `PUT /api/help-texts/:key/:scope` (admin). Stöd för scopade hjälptexter: `admin`, `user`, `default` med prioritetsordning beroende på användarroll.
- **Prisma-migration:** `20260513150000_add_help_texts` — skapar `HelpText`-tabell och seedar initiala hjälptexter för kvittoimport (standard + admin-variant) på svenska.
+13 -4
View File
@@ -14,9 +14,18 @@ Verifiering:
Se även: README.md för användarflöde, och AI-FUNKTIONER.md för AI-detaljer.
# Prisma-migreringar: P3009 recovery och lessons learned
# Drift och deploy (2026-05-11)
# Prisma-migreringar: P3009 recovery och lessons learned
# Nyheter och förbättringar (2026-05-18)
- **Backend linting i CI:** ESLint är infört för backend (`backend/eslint.config.mjs`, `npm run lint`) och körs i `.github/workflows/test.yml`.
- **Flutter lint-konfiguration:** `flutter/analysis_options.yaml` är tillagd och inkluderar `package:flutter_lints/flutter.yaml`.
- **Prisma query logging (miljöstyrd):** `PrismaService` konfigurerar loggnivåer via env-variabeln `PRISMA_LOG_QUERIES`.
- **Runtime-konfiguration:** `compose.yml` exponerar `PRISMA_LOG_QUERIES` till `recipe-api` med default `0`.
- **Aktivering i testmiljö:** Sätt `PRISMA_LOG_QUERIES=1` och starta om `recipe-api` för att få SQL query-loggar.
- **Verifierad testjustering:** `receipt-import.security.spec.ts` validerar nu `UnauthorizedException` vid saknat användar-id i `upsertUnitMapping`.
# Drift och deploy (2026-05-11)
- **Flutter build-artifacts:** Byggda filer i `flutter/build/` och `.flutter-plugins-dependencies` ska inte versionshanteras. Vid deploy på server: kör `git restore flutter/build flutter/.flutter-plugins-dependencies` och `git clean -fd flutter/build` innan `git pull`.
- **Vanliga fel:** Om du får felmeddelandet "Your local changes to the following files would be overwritten by merge", beror det på att genererade filer är modifierade lokalt. Se till att alltid rensa dessa innan uppdatering.
@@ -2091,4 +2100,4 @@ För att aktivera Prisma query logging i testmiljön:
> **Notera:**
> - Aktivera endast i test/staging, inte i produktion.
> - Loggarna kan vara omfattande och påverka prestanda.
> - Variabeln är avsiktligt inte dokumenterad i huvudkonfigurationen för att undvika oavsiktlig aktivering.
> - Variabeln är avsiktligt inte dokumenterad i huvudkonfigurationen för att undvika oavsiktlig aktivering.
@@ -1,28 +1,30 @@
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
export type FlyerImportItem = {
flyerItemId: number | null;
rawName: string;
normalizedName: string;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
offerText: string | null;
parseConfidence: number;
parseReasons: string[];
matchedProductId: number | null;
matchedProductName: string | null;
matchedVia: FlyerImportMatchVia;
matchConfidence: number;
matchReasons: string[];
};
export type FlyerImportResponse = {
sessionId: number | null;
retailer: 'willys';
parserVersion: 'v1';
items: FlyerImportItem[];
warnings: string[];
};
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
export type FlyerImportItem = {
flyerItemId: number | null;
rawName: string;
normalizedName: string;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
offerText: string | null;
isOffer: boolean;
offerLimitText: string | null;
parseConfidence: number;
parseReasons: string[];
matchedProductId: number | null;
matchedProductName: string | null;
matchedVia: FlyerImportMatchVia;
matchConfidence: number;
matchReasons: string[];
};
export type FlyerImportResponse = {
sessionId: number | null;
retailer: 'willys';
parserVersion: 'v1';
items: FlyerImportItem[];
warnings: string[];
};
+325 -299
View File
@@ -1,299 +1,325 @@
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name';
import {
FlyerImportItem,
FlyerImportMatchVia,
FlyerImportResponse,
} from './dto/flyer-import.response';
const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
type FlyerParseItem = {
rawName: string;
normalizedName: string;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
offerText: string | null;
confidence: number;
reasonCodes: string[];
};
type FlyerParseResponse = {
retailer: 'willys';
parserVersion: 'v1';
items: FlyerParseItem[];
warnings: string[];
};
type ProductLite = {
id: number;
name: string;
canonicalName: string | null;
};
@Injectable()
export class FlyerImportService {
private readonly logger = new Logger(FlyerImportService.name);
constructor(private readonly prisma: PrismaService) {}
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
const parsed = await this.parseViaImporter(file);
const [products, aliases] = await Promise.all([
this.prisma.product.findMany({
where: { ownerId: userId, isActive: true },
select: { id: true, name: true, canonicalName: true },
}),
this.prisma.receiptAlias.findMany({
where: {
OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }],
},
select: { receiptName: true, productId: true },
}),
]);
const aliasToProduct = new Map<string, number>();
for (const alias of aliases) {
const normalized = normalizeName(alias.receiptName);
if (!normalized) continue;
if (!aliasToProduct.has(normalized)) {
aliasToProduct.set(normalized, alias.productId);
}
}
const productById = new Map<number, ProductLite>();
for (const product of products) {
productById.set(product.id, product);
}
const items: FlyerImportItem[] = parsed.items.map((item) => {
const match = this.matchItem(item, products, aliasToProduct, productById);
return {
flyerItemId: null,
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.category,
price: item.price,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit,
offerText: item.offerText,
parseConfidence: item.confidence,
parseReasons: item.reasonCodes,
matchedProductId: match.product?.id ?? null,
matchedProductName: match.product?.name ?? null,
matchedVia: match.via,
matchConfidence: match.confidence,
matchReasons: match.reasons,
};
});
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
return {
sessionId: persistedItems.sessionId,
retailer: parsed.retailer,
parserVersion: parsed.parserVersion,
items: persistedItems.items,
warnings: parsed.warnings,
};
}
private async persistSessionWithItems(
userId: number,
retailer: 'willys',
items: FlyerImportItem[],
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
const weekKey = this.toWeekKey(new Date());
const session = await this.prisma.flyerSession.create({
data: {
userId,
retailer,
weekKey,
status: 'draft',
},
select: { id: true },
});
const savedItems: FlyerImportItem[] = [];
for (const item of items) {
const created = await this.prisma.flyerItem.create({
data: {
sessionId: session.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
categoryHint: item.category,
price: item.price != null ? new Prisma.Decimal(item.price) : null,
priceUnit: item.priceUnit,
comparisonPrice:
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
comparisonUnit: item.comparisonUnit,
offerText: item.offerText,
parseConfidence: item.parseConfidence,
parseReasons: item.parseReasons,
matchedProductId: item.matchedProductId,
matchedProductName: item.matchedProductName,
matchedVia: item.matchedVia,
matchConfidence: item.matchConfidence,
matchReasons: item.matchReasons,
},
select: { id: true },
});
savedItems.push({ ...item, flyerItemId: created.id });
}
return { sessionId: session.id, items: savedItems };
}
private toWeekKey(date: Date): string {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
private matchItem(
item: FlyerParseItem,
products: ProductLite[],
aliasToProduct: Map<string, number>,
productById: Map<number, ProductLite>,
): {
product: ProductLite | null;
via: FlyerImportMatchVia;
confidence: number;
reasons: string[];
} {
const normalized = normalizeName(item.rawName || item.normalizedName);
if (!normalized) {
return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] };
}
const aliasedProductId = aliasToProduct.get(normalized);
if (aliasedProductId) {
const product = productById.get(aliasedProductId) ?? null;
return {
product,
via: product ? 'alias' : 'none',
confidence: product ? 1 : 0,
reasons: product ? ['alias_exact'] : ['alias_points_to_missing_product'],
};
}
for (const product of products) {
const pn = normalizeName(product.name);
const cn = product.canonicalName ? normalizeName(product.canonicalName) : null;
if (normalized === pn || (cn && normalized === cn)) {
return {
product,
via: 'exact',
confidence: 0.96,
reasons: ['normalized_exact'],
};
}
}
let best: { product: ProductLite; confidence: number; overlap: number } | null = null;
const itemTokens = this.tokenize(item.rawName);
for (const product of products) {
const productTokens = this.tokenize(product.canonicalName ?? product.name);
const overlap = this.tokenOverlap(itemTokens, productTokens);
if (overlap <= 0) continue;
const confidence = Math.min(0.92, 0.5 + overlap * 0.4);
if (!best || confidence > best.confidence) {
best = { product, confidence, overlap };
}
}
if (best && best.confidence >= 0.66) {
return {
product: best.product,
via: 'token',
confidence: best.confidence,
reasons: [`token_overlap:${best.overlap.toFixed(2)}`],
};
}
return {
product: null,
via: 'none',
confidence: 0,
reasons: ['no_match'],
};
}
private tokenize(value: string): string[] {
return value
.toLowerCase()
.split(/[^a-z0-9åäö]+/)
.map((part) => part.trim())
.filter((part) => part.length >= 3);
}
private tokenOverlap(a: string[], b: string[]): number {
if (a.length === 0 || b.length === 0) return 0;
const as = new Set(a);
const bs = new Set(b);
let intersection = 0;
for (const token of as) {
if (bs.has(token)) intersection++;
}
const union = new Set([...as, ...bs]).size;
if (union === 0) return 0;
return intersection / union;
}
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
const form = new FormData();
form.append(
'file',
new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }),
file.originalname,
);
form.append('retailer', 'willys');
let response: Response;
try {
response = await fetch(`${IMPORTER_SERVICE_URL}/api/flyer/parse`, {
method: 'POST',
body: form,
});
} catch (err) {
this.logger.error(`Kunde inte nå importer-api för flyer-parse: ${String(err)}`);
throw new ServiceUnavailableException('Importer-tjänsten är inte tillgänglig just nu.');
}
if (!response.ok) {
let message = `Importer-tjänsten svarade ${response.status}`;
try {
const body = (await response.json()) as { message?: string };
if (typeof body.message === 'string' && body.message.trim()) {
message = body.message;
}
} catch {
// ignore parse issues
}
if (response.status >= 400 && response.status < 500) {
throw new BadRequestException(message);
}
throw new ServiceUnavailableException(message);
}
return response.json() as Promise<FlyerParseResponse>;
}
}
import {
BadRequestException,
Injectable,
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name';
import {
FlyerImportItem,
FlyerImportMatchVia,
FlyerImportResponse,
} from './dto/flyer-import.response';
const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
type FlyerParseItem = {
rawName: string;
normalizedName: string;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
offerText: string | null;
confidence: number;
reasonCodes: string[];
};
type FlyerParseResponse = {
retailer: 'willys';
parserVersion: 'v1';
items: FlyerParseItem[];
warnings: string[];
};
type ProductLite = {
id: number;
name: string;
canonicalName: string | null;
};
@Injectable()
export class FlyerImportService {
private readonly logger = new Logger(FlyerImportService.name);
constructor(private readonly prisma: PrismaService) {}
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
const parsed = await this.parseViaImporter(file);
const [products, aliases] = await Promise.all([
this.prisma.product.findMany({
where: { ownerId: userId, isActive: true },
select: { id: true, name: true, canonicalName: true },
}),
this.prisma.receiptAlias.findMany({
where: {
OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }],
},
select: { receiptName: true, productId: true },
}),
]);
const aliasToProduct = new Map<string, number>();
for (const alias of aliases) {
const normalized = normalizeName(alias.receiptName);
if (!normalized) continue;
if (!aliasToProduct.has(normalized)) {
aliasToProduct.set(normalized, alias.productId);
}
}
const productById = new Map<number, ProductLite>();
for (const product of products) {
productById.set(product.id, product);
}
const items: FlyerImportItem[] = parsed.items.map((item) => {
const match = this.matchItem(item, products, aliasToProduct, productById);
const offerLimitText = this.extractOfferLimitText(item.offerText);
return {
flyerItemId: null,
rawName: item.rawName,
normalizedName: item.normalizedName,
category: item.category,
price: item.price,
priceUnit: item.priceUnit,
comparisonPrice: item.comparisonPrice,
comparisonUnit: item.comparisonUnit,
offerText: item.offerText,
isOffer: this.isOfferItem(item),
offerLimitText,
parseConfidence: item.confidence,
parseReasons: item.reasonCodes,
matchedProductId: match.product?.id ?? null,
matchedProductName: match.product?.name ?? null,
matchedVia: match.via,
matchConfidence: match.confidence,
matchReasons: match.reasons,
};
});
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
return {
sessionId: persistedItems.sessionId,
retailer: parsed.retailer,
parserVersion: parsed.parserVersion,
items: persistedItems.items,
warnings: parsed.warnings,
};
}
private async persistSessionWithItems(
userId: number,
retailer: 'willys',
items: FlyerImportItem[],
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
const weekKey = this.toWeekKey(new Date());
const session = await this.prisma.flyerSession.create({
data: {
userId,
retailer,
weekKey,
status: 'draft',
},
select: { id: true },
});
const savedItems: FlyerImportItem[] = [];
for (const item of items) {
const created = await this.prisma.flyerItem.create({
data: {
sessionId: session.id,
rawName: item.rawName,
normalizedName: item.normalizedName,
categoryHint: item.category,
price: item.price != null ? new Prisma.Decimal(item.price) : null,
priceUnit: item.priceUnit,
comparisonPrice:
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
comparisonUnit: item.comparisonUnit,
offerText: item.offerText,
parseConfidence: item.parseConfidence,
parseReasons: item.parseReasons,
matchedProductId: item.matchedProductId,
matchedProductName: item.matchedProductName,
matchedVia: item.matchedVia,
matchConfidence: item.matchConfidence,
matchReasons: item.matchReasons,
},
select: { id: true },
});
savedItems.push({ ...item, flyerItemId: created.id });
}
return { sessionId: session.id, items: savedItems };
}
private toWeekKey(date: Date): string {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
}
private matchItem(
item: FlyerParseItem,
products: ProductLite[],
aliasToProduct: Map<string, number>,
productById: Map<number, ProductLite>,
): {
product: ProductLite | null;
via: FlyerImportMatchVia;
confidence: number;
reasons: string[];
} {
const normalized = normalizeName(item.rawName || item.normalizedName);
if (!normalized) {
return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] };
}
const aliasedProductId = aliasToProduct.get(normalized);
if (aliasedProductId) {
const product = productById.get(aliasedProductId) ?? null;
return {
product,
via: product ? 'alias' : 'none',
confidence: product ? 1 : 0,
reasons: product ? ['alias_exact'] : ['alias_points_to_missing_product'],
};
}
for (const product of products) {
const pn = normalizeName(product.name);
const cn = product.canonicalName ? normalizeName(product.canonicalName) : null;
if (normalized === pn || (cn && normalized === cn)) {
return {
product,
via: 'exact',
confidence: 0.96,
reasons: ['normalized_exact'],
};
}
}
let best: { product: ProductLite; confidence: number; overlap: number } | null = null;
const itemTokens = this.tokenize(item.rawName);
for (const product of products) {
const productTokens = this.tokenize(product.canonicalName ?? product.name);
const overlap = this.tokenOverlap(itemTokens, productTokens);
if (overlap <= 0) continue;
const confidence = Math.min(0.92, 0.5 + overlap * 0.4);
if (!best || confidence > best.confidence) {
best = { product, confidence, overlap };
}
}
if (best && best.confidence >= 0.66) {
return {
product: best.product,
via: 'token',
confidence: best.confidence,
reasons: [`token_overlap:${best.overlap.toFixed(2)}`],
};
}
return {
product: null,
via: 'none',
confidence: 0,
reasons: ['no_match'],
};
}
private tokenize(value: string): string[] {
return value
.toLowerCase()
.split(/[^a-z0-9åäö]+/)
.map((part) => part.trim())
.filter((part) => part.length >= 3);
}
private tokenOverlap(a: string[], b: string[]): number {
if (a.length === 0 || b.length === 0) return 0;
const as = new Set(a);
const bs = new Set(b);
let intersection = 0;
for (const token of as) {
if (bs.has(token)) intersection++;
}
const union = new Set([...as, ...bs]).size;
if (union === 0) return 0;
return intersection / union;
}
private isOfferItem(item: FlyerParseItem): boolean {
return item.price != null || item.comparisonPrice != null || !!item.offerText?.trim();
}
private extractOfferLimitText(offerText: string | null): string | null {
if (!offerText) return null;
const normalized = offerText.replace(/\s+/g, ' ' ).trim();
if (!normalized) return null;
const limitMatch = normalized.match(/(?:max|högst)\s+[^,.;]+(?:hushåll|kund)?/i);
if (limitMatch?.[0]) {
return limitMatch[0].trim();
}
const householdMatch = normalized.match(/[^,.;]*(?:hushåll|kund)[^,.;]*/i);
if (householdMatch?.[0]) {
return householdMatch[0].trim();
}
return null;
}
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
const form = new FormData();
form.append(
'file',
new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }),
file.originalname,
);
form.append('retailer', 'willys');
let response: Response;
try {
response = await fetch(`${IMPORTER_SERVICE_URL}/api/flyer/parse`, {
method: 'POST',
body: form,
});
} catch (err) {
this.logger.error(`Kunde inte nå importer-api för flyer-parse: ${String(err)}`);
throw new ServiceUnavailableException('Importer-tjänsten är inte tillgänglig just nu.');
}
if (!response.ok) {
let message = `Importer-tjänsten svarade ${response.status}`;
try {
const body = (await response.json()) as { message?: string };
if (typeof body.message === 'string' && body.message.trim()) {
message = body.message;
}
} catch {
// ignore parse issues
}
if (response.status >= 400 && response.status < 500) {
throw new BadRequestException(message);
}
throw new ServiceUnavailableException(message);
}
return response.json() as Promise<FlyerParseResponse>;
}
}
@@ -1,43 +1,61 @@
class FlyerImportItem {
final int? flyerItemId;
final String rawName;
final String normalizedName;
final String? category;
final double? price;
final String? priceUnit;
final String? offerText;
final int? matchedProductId;
final String? matchedProductName;
final String? matchedVia;
final double? matchConfidence;
FlyerImportItem({
required this.flyerItemId,
required this.rawName,
required this.normalizedName,
this.category,
this.price,
this.priceUnit,
this.offerText,
this.matchedProductId,
this.matchedProductName,
this.matchedVia,
this.matchConfidence,
});
factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
return FlyerImportItem(
flyerItemId: (json['flyerItemId'] as num?)?.toInt(),
rawName: json['rawName'] as String? ?? '',
normalizedName: json['normalizedName'] as String? ?? '',
category: json['category'] as String?,
price: (json['price'] as num?)?.toDouble(),
priceUnit: json['priceUnit'] as String?,
offerText: json['offerText'] as String?,
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?,
matchedVia: json['matchedVia'] as String?,
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(),
);
}
}
class FlyerImportItem {
final int? flyerItemId;
final String rawName;
final String normalizedName;
final String? category;
final double? price;
final String? priceUnit;
final String? offerText;
final bool isOffer;
final String? offerLimitText;
final double? comparisonPrice;
final String? comparisonUnit;
final double? parseConfidence;
final List<String> parseReasons;
final int? matchedProductId;
final String? matchedProductName;
final String? matchedVia;
final double? matchConfidence;
FlyerImportItem({
required this.flyerItemId,
required this.rawName,
required this.normalizedName,
this.category,
this.price,
this.priceUnit,
this.offerText,
this.isOffer = false,
this.offerLimitText,
this.comparisonPrice,
this.comparisonUnit,
this.parseConfidence,
this.parseReasons = const [],
this.matchedProductId,
this.matchedProductName,
this.matchedVia,
this.matchConfidence,
});
factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
return FlyerImportItem(
flyerItemId: (json['flyerItemId'] as num?)?.toInt(),
rawName: json['rawName'] as String? ?? '',
normalizedName: json['normalizedName'] as String? ?? '',
category: json['category'] as String?,
price: (json['price'] as num?)?.toDouble(),
priceUnit: json['priceUnit'] as String?,
offerText: json['offerText'] as String?,
isOffer: json['isOffer'] == true,
offerLimitText: json['offerLimitText'] as String?,
comparisonPrice: (json['comparisonPrice'] as num?)?.toDouble(),
comparisonUnit: json['comparisonUnit'] as String?,
parseConfidence: (json['parseConfidence'] as num?)?.toDouble(),
parseReasons: (json['parseReasons'] as List?)?.map((e) => e.toString()).toList() ?? const [],
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
matchedProductName: json['matchedProductName'] as String?,
matchedVia: json['matchedVia'] as String?,
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(),
);
}
}
@@ -2,6 +2,7 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/utils/pdf_opener.dart';
import '../../auth/data/auth_providers.dart';
import '../data/import_providers.dart';
import '../domain/flyer_import_item.dart';
@@ -25,7 +26,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
Future<void> _pickFile() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf', 'txt'],
allowedExtensions: ['pdf', 'txt', 'png', 'jpg', 'jpeg', 'webp'],
withData: true,
);
if (result == null || result.files.isEmpty) return;
@@ -108,6 +109,91 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
}
String _formatPrice(double? price, String? unit) {
if (price == null) return '';
final raw = price.toStringAsFixed(2).replaceAll('.', ',');
final unitPart = (unit != null && unit.trim().isNotEmpty) ? '/${unit.trim()}' : '';
return '$raw kr$unitPart';
}
Widget _buildOfferBadge(FlyerImportItem item, ThemeData theme) {
final hasOffer = item.isOffer || (item.offerText?.trim().isNotEmpty ?? false) || item.price != null;
if (!hasOffer) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.red.shade200),
),
child: Text(
'ERBJUDANDE',
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.red.shade900,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _buildFlyerPreview(ThemeData theme) {
final file = _pickedFile;
final bytes = file?.bytes;
if (bytes == null) return const SizedBox.shrink();
final filename = file?.name ?? '';
final fallbackExt = filename.contains('.') ? filename.split('.').last : '';
final ext = (file?.extension ?? fallbackExt).toLowerCase();
final isImage = ['png', 'jpg', 'jpeg', 'webp', 'bmp'].contains(ext);
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
dense: true,
leading: Icon(
isImage ? Icons.image_outlined : Icons.picture_as_pdf_outlined,
color: theme.colorScheme.primary,
),
title: const Text('Flyerförhandsvisning'),
subtitle: Text(file?.name ?? ''),
trailing: isImage
? null
: OutlinedButton.icon(
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Visa flyer'),
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
onPressed: () async {
final opened = await openPdfBytes(bytes);
if (!context.mounted || opened) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF kan bara öppnas direkt i webbversionen just nu.'),
),
);
},
),
),
if (isImage)
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 420),
child: Image.memory(bytes, fit: BoxFit.contain),
),
),
)
else
const SizedBox(height: 8),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -120,14 +206,14 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ladda upp flyer (PDF/txt), granska rader och planera inköp med ett klick.',
'Ladda upp flyer, granska erbjudanden och planera inköp med ett klick.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: _isLoading ? null : _pickFile,
icon: const Icon(Icons.attach_file),
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
),
const SizedBox(height: 12),
FilledButton.icon(
@@ -135,6 +221,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
icon: const Icon(Icons.auto_awesome),
label: const Text('Importera flyer'),
),
const SizedBox(height: 12),
_buildFlyerPreview(theme),
if (_isLoading) ...[
const SizedBox(height: 12),
const LinearProgressIndicator(),
@@ -154,7 +242,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
}
});
},
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
child: Text(selectedCount < items.length ? 'Välj alla' : 'Avmarkera alla'),
),
],
),
@@ -162,14 +250,29 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final priceText = _formatPrice(item.price, item.priceUnit);
final comparisonText = _formatPrice(item.comparisonPrice, item.comparisonUnit);
final limitText = item.offerLimitText?.trim();
return CheckboxListTile(
value: _selected[index] ?? false,
onChanged: (value) => setState(() => _selected[index] = value ?? false),
title: Text(item.rawName),
subtitle: Text([
if (item.offerText != null && item.offerText!.isNotEmpty) item.offerText!,
if (item.matchedProductName != null) 'Match: ${item.matchedProductName}',
].join(' · ')),
title: Row(
children: [
Expanded(child: Text(item.rawName)),
_buildOfferBadge(item, theme),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (priceText.isNotEmpty) Text('Pris: $priceText'),
if (comparisonText.isNotEmpty) Text('Jämförpris: $comparisonText'),
if (limitText != null && limitText.isNotEmpty) Text('Begränsning: $limitText'),
if ((item.offerText?.trim().isNotEmpty ?? false)) Text(item.offerText!.trim()),
if (item.matchedProductName != null) Text('Match: ${item.matchedProductName}'),
],
),
controlAffinity: ListTileControlAffinity.leading,
);
}),
@@ -193,4 +296,5 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
),
);
}
}
}