Compare commits
2 Commits
a5cd49284a
...
c720f611ea
| Author | SHA1 | Date | |
|---|---|---|---|
| c720f611ea | |||
| e658f2e6f1 |
+11
-3
@@ -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.
|
- Deploy, healthcheck och testkorning ar reproducerbara i driftmiljo.
|
||||||
|
|
||||||
|
|
||||||
## Nyligen klart
|
## Nyligen klart
|
||||||
|
|
||||||
## Utförda steg (2026-05-13)
|
## 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] **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.
|
- [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.
|
||||||
|
|||||||
@@ -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.
|
- **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.
|
- **Prisma-migration:** `20260513150000_add_help_texts` — skapar `HelpText`-tabell och seedar initiala hjälptexter för kvittoimport (standard + admin-variant) på svenska.
|
||||||
|
|||||||
+13
-4
@@ -14,9 +14,18 @@ Verifiering:
|
|||||||
|
|
||||||
Se även: README.md för användarflöde, och AI-FUNKTIONER.md för AI-detaljer.
|
Se även: README.md för användarflöde, och AI-FUNKTIONER.md för AI-detaljer.
|
||||||
|
|
||||||
# Prisma-migreringar: P3009 recovery och lessons learned
|
# Prisma-migreringar: P3009 recovery och lessons learned
|
||||||
|
|
||||||
# Drift och deploy (2026-05-11)
|
# 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`.
|
- **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.
|
- **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:**
|
> **Notera:**
|
||||||
> - Aktivera endast i test/staging, inte i produktion.
|
> - Aktivera endast i test/staging, inte i produktion.
|
||||||
> - Loggarna kan vara omfattande och påverka prestanda.
|
> - 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 FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
||||||
|
|
||||||
export type FlyerImportItem = {
|
export type FlyerImportItem = {
|
||||||
flyerItemId: number | null;
|
flyerItemId: number | null;
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
comparisonPrice: number | null;
|
comparisonPrice: number | null;
|
||||||
comparisonUnit: string | null;
|
comparisonUnit: string | null;
|
||||||
offerText: string | null;
|
offerText: string | null;
|
||||||
parseConfidence: number;
|
isOffer: boolean;
|
||||||
parseReasons: string[];
|
offerLimitText: string | null;
|
||||||
matchedProductId: number | null;
|
parseConfidence: number;
|
||||||
matchedProductName: string | null;
|
parseReasons: string[];
|
||||||
matchedVia: FlyerImportMatchVia;
|
matchedProductId: number | null;
|
||||||
matchConfidence: number;
|
matchedProductName: string | null;
|
||||||
matchReasons: string[];
|
matchedVia: FlyerImportMatchVia;
|
||||||
};
|
matchConfidence: number;
|
||||||
|
matchReasons: string[];
|
||||||
export type FlyerImportResponse = {
|
};
|
||||||
sessionId: number | null;
|
|
||||||
retailer: 'willys';
|
export type FlyerImportResponse = {
|
||||||
parserVersion: 'v1';
|
sessionId: number | null;
|
||||||
items: FlyerImportItem[];
|
retailer: 'willys';
|
||||||
warnings: string[];
|
parserVersion: 'v1';
|
||||||
};
|
items: FlyerImportItem[];
|
||||||
|
warnings: string[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,299 +1,325 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { normalizeName } from '../common/utils/normalize-name';
|
import { normalizeName } from '../common/utils/normalize-name';
|
||||||
import {
|
import {
|
||||||
FlyerImportItem,
|
FlyerImportItem,
|
||||||
FlyerImportMatchVia,
|
FlyerImportMatchVia,
|
||||||
FlyerImportResponse,
|
FlyerImportResponse,
|
||||||
} from './dto/flyer-import.response';
|
} from './dto/flyer-import.response';
|
||||||
|
|
||||||
const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
const IMPORTER_SERVICE_URL = process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
||||||
|
|
||||||
type FlyerParseItem = {
|
type FlyerParseItem = {
|
||||||
rawName: string;
|
rawName: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
priceUnit: string | null;
|
priceUnit: string | null;
|
||||||
comparisonPrice: number | null;
|
comparisonPrice: number | null;
|
||||||
comparisonUnit: string | null;
|
comparisonUnit: string | null;
|
||||||
offerText: string | null;
|
offerText: string | null;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
reasonCodes: string[];
|
reasonCodes: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type FlyerParseResponse = {
|
type FlyerParseResponse = {
|
||||||
retailer: 'willys';
|
retailer: 'willys';
|
||||||
parserVersion: 'v1';
|
parserVersion: 'v1';
|
||||||
items: FlyerParseItem[];
|
items: FlyerParseItem[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProductLite = {
|
type ProductLite = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
canonicalName: string | null;
|
canonicalName: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FlyerImportService {
|
export class FlyerImportService {
|
||||||
private readonly logger = new Logger(FlyerImportService.name);
|
private readonly logger = new Logger(FlyerImportService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
|
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
|
||||||
const parsed = await this.parseViaImporter(file);
|
const parsed = await this.parseViaImporter(file);
|
||||||
|
|
||||||
const [products, aliases] = await Promise.all([
|
const [products, aliases] = await Promise.all([
|
||||||
this.prisma.product.findMany({
|
this.prisma.product.findMany({
|
||||||
where: { ownerId: userId, isActive: true },
|
where: { ownerId: userId, isActive: true },
|
||||||
select: { id: true, name: true, canonicalName: true },
|
select: { id: true, name: true, canonicalName: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.receiptAlias.findMany({
|
this.prisma.receiptAlias.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }],
|
OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }],
|
||||||
},
|
},
|
||||||
select: { receiptName: true, productId: true },
|
select: { receiptName: true, productId: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const aliasToProduct = new Map<string, number>();
|
const aliasToProduct = new Map<string, number>();
|
||||||
for (const alias of aliases) {
|
for (const alias of aliases) {
|
||||||
const normalized = normalizeName(alias.receiptName);
|
const normalized = normalizeName(alias.receiptName);
|
||||||
if (!normalized) continue;
|
if (!normalized) continue;
|
||||||
if (!aliasToProduct.has(normalized)) {
|
if (!aliasToProduct.has(normalized)) {
|
||||||
aliasToProduct.set(normalized, alias.productId);
|
aliasToProduct.set(normalized, alias.productId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const productById = new Map<number, ProductLite>();
|
const productById = new Map<number, ProductLite>();
|
||||||
for (const product of products) {
|
for (const product of products) {
|
||||||
productById.set(product.id, product);
|
productById.set(product.id, product);
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: FlyerImportItem[] = parsed.items.map((item) => {
|
const items: FlyerImportItem[] = parsed.items.map((item) => {
|
||||||
const match = this.matchItem(item, products, aliasToProduct, productById);
|
const match = this.matchItem(item, products, aliasToProduct, productById);
|
||||||
return {
|
const offerLimitText = this.extractOfferLimitText(item.offerText);
|
||||||
flyerItemId: null,
|
return {
|
||||||
rawName: item.rawName,
|
flyerItemId: null,
|
||||||
normalizedName: item.normalizedName,
|
rawName: item.rawName,
|
||||||
category: item.category,
|
normalizedName: item.normalizedName,
|
||||||
price: item.price,
|
category: item.category,
|
||||||
priceUnit: item.priceUnit,
|
price: item.price,
|
||||||
comparisonPrice: item.comparisonPrice,
|
priceUnit: item.priceUnit,
|
||||||
comparisonUnit: item.comparisonUnit,
|
comparisonPrice: item.comparisonPrice,
|
||||||
offerText: item.offerText,
|
comparisonUnit: item.comparisonUnit,
|
||||||
parseConfidence: item.confidence,
|
offerText: item.offerText,
|
||||||
parseReasons: item.reasonCodes,
|
isOffer: this.isOfferItem(item),
|
||||||
matchedProductId: match.product?.id ?? null,
|
offerLimitText,
|
||||||
matchedProductName: match.product?.name ?? null,
|
parseConfidence: item.confidence,
|
||||||
matchedVia: match.via,
|
parseReasons: item.reasonCodes,
|
||||||
matchConfidence: match.confidence,
|
matchedProductId: match.product?.id ?? null,
|
||||||
matchReasons: match.reasons,
|
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,
|
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items);
|
||||||
retailer: parsed.retailer,
|
|
||||||
parserVersion: parsed.parserVersion,
|
return {
|
||||||
items: persistedItems.items,
|
sessionId: persistedItems.sessionId,
|
||||||
warnings: parsed.warnings,
|
retailer: parsed.retailer,
|
||||||
};
|
parserVersion: parsed.parserVersion,
|
||||||
}
|
items: persistedItems.items,
|
||||||
|
warnings: parsed.warnings,
|
||||||
private async persistSessionWithItems(
|
};
|
||||||
userId: number,
|
}
|
||||||
retailer: 'willys',
|
|
||||||
items: FlyerImportItem[],
|
private async persistSessionWithItems(
|
||||||
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
|
userId: number,
|
||||||
const weekKey = this.toWeekKey(new Date());
|
retailer: 'willys',
|
||||||
|
items: FlyerImportItem[],
|
||||||
const session = await this.prisma.flyerSession.create({
|
): Promise<{ sessionId: number; items: FlyerImportItem[] }> {
|
||||||
data: {
|
const weekKey = this.toWeekKey(new Date());
|
||||||
userId,
|
|
||||||
retailer,
|
const session = await this.prisma.flyerSession.create({
|
||||||
weekKey,
|
data: {
|
||||||
status: 'draft',
|
userId,
|
||||||
},
|
retailer,
|
||||||
select: { id: true },
|
weekKey,
|
||||||
});
|
status: 'draft',
|
||||||
|
},
|
||||||
const savedItems: FlyerImportItem[] = [];
|
select: { id: true },
|
||||||
for (const item of items) {
|
});
|
||||||
const created = await this.prisma.flyerItem.create({
|
|
||||||
data: {
|
const savedItems: FlyerImportItem[] = [];
|
||||||
sessionId: session.id,
|
for (const item of items) {
|
||||||
rawName: item.rawName,
|
const created = await this.prisma.flyerItem.create({
|
||||||
normalizedName: item.normalizedName,
|
data: {
|
||||||
categoryHint: item.category,
|
sessionId: session.id,
|
||||||
price: item.price != null ? new Prisma.Decimal(item.price) : null,
|
rawName: item.rawName,
|
||||||
priceUnit: item.priceUnit,
|
normalizedName: item.normalizedName,
|
||||||
comparisonPrice:
|
categoryHint: item.category,
|
||||||
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
|
price: item.price != null ? new Prisma.Decimal(item.price) : null,
|
||||||
comparisonUnit: item.comparisonUnit,
|
priceUnit: item.priceUnit,
|
||||||
offerText: item.offerText,
|
comparisonPrice:
|
||||||
parseConfidence: item.parseConfidence,
|
item.comparisonPrice != null ? new Prisma.Decimal(item.comparisonPrice) : null,
|
||||||
parseReasons: item.parseReasons,
|
comparisonUnit: item.comparisonUnit,
|
||||||
matchedProductId: item.matchedProductId,
|
offerText: item.offerText,
|
||||||
matchedProductName: item.matchedProductName,
|
parseConfidence: item.parseConfidence,
|
||||||
matchedVia: item.matchedVia,
|
parseReasons: item.parseReasons,
|
||||||
matchConfidence: item.matchConfidence,
|
matchedProductId: item.matchedProductId,
|
||||||
matchReasons: item.matchReasons,
|
matchedProductName: item.matchedProductName,
|
||||||
},
|
matchedVia: item.matchedVia,
|
||||||
select: { id: true },
|
matchConfidence: item.matchConfidence,
|
||||||
});
|
matchReasons: item.matchReasons,
|
||||||
|
},
|
||||||
savedItems.push({ ...item, flyerItemId: created.id });
|
select: { id: true },
|
||||||
}
|
});
|
||||||
|
|
||||||
return { sessionId: session.id, items: savedItems };
|
savedItems.push({ ...item, flyerItemId: created.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
private toWeekKey(date: Date): string {
|
return { sessionId: session.id, items: savedItems };
|
||||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
}
|
||||||
const dayNum = d.getUTCDay() || 7;
|
|
||||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
private toWeekKey(date: Date): string {
|
||||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
const dayNum = d.getUTCDay() || 7;
|
||||||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
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);
|
||||||
private matchItem(
|
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
||||||
item: FlyerParseItem,
|
}
|
||||||
products: ProductLite[],
|
|
||||||
aliasToProduct: Map<string, number>,
|
private matchItem(
|
||||||
productById: Map<number, ProductLite>,
|
item: FlyerParseItem,
|
||||||
): {
|
products: ProductLite[],
|
||||||
product: ProductLite | null;
|
aliasToProduct: Map<string, number>,
|
||||||
via: FlyerImportMatchVia;
|
productById: Map<number, ProductLite>,
|
||||||
confidence: number;
|
): {
|
||||||
reasons: string[];
|
product: ProductLite | null;
|
||||||
} {
|
via: FlyerImportMatchVia;
|
||||||
const normalized = normalizeName(item.rawName || item.normalizedName);
|
confidence: number;
|
||||||
if (!normalized) {
|
reasons: string[];
|
||||||
return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] };
|
} {
|
||||||
}
|
const normalized = normalizeName(item.rawName || item.normalizedName);
|
||||||
|
if (!normalized) {
|
||||||
const aliasedProductId = aliasToProduct.get(normalized);
|
return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] };
|
||||||
if (aliasedProductId) {
|
}
|
||||||
const product = productById.get(aliasedProductId) ?? null;
|
|
||||||
return {
|
const aliasedProductId = aliasToProduct.get(normalized);
|
||||||
product,
|
if (aliasedProductId) {
|
||||||
via: product ? 'alias' : 'none',
|
const product = productById.get(aliasedProductId) ?? null;
|
||||||
confidence: product ? 1 : 0,
|
return {
|
||||||
reasons: product ? ['alias_exact'] : ['alias_points_to_missing_product'],
|
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)) {
|
for (const product of products) {
|
||||||
return {
|
const pn = normalizeName(product.name);
|
||||||
product,
|
const cn = product.canonicalName ? normalizeName(product.canonicalName) : null;
|
||||||
via: 'exact',
|
if (normalized === pn || (cn && normalized === cn)) {
|
||||||
confidence: 0.96,
|
return {
|
||||||
reasons: ['normalized_exact'],
|
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);
|
let best: { product: ProductLite; confidence: number; overlap: number } | null = null;
|
||||||
const overlap = this.tokenOverlap(itemTokens, productTokens);
|
const itemTokens = this.tokenize(item.rawName);
|
||||||
if (overlap <= 0) continue;
|
for (const product of products) {
|
||||||
const confidence = Math.min(0.92, 0.5 + overlap * 0.4);
|
const productTokens = this.tokenize(product.canonicalName ?? product.name);
|
||||||
if (!best || confidence > best.confidence) {
|
const overlap = this.tokenOverlap(itemTokens, productTokens);
|
||||||
best = { product, confidence, overlap };
|
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',
|
if (best && best.confidence >= 0.66) {
|
||||||
confidence: best.confidence,
|
return {
|
||||||
reasons: [`token_overlap:${best.overlap.toFixed(2)}`],
|
product: best.product,
|
||||||
};
|
via: 'token',
|
||||||
}
|
confidence: best.confidence,
|
||||||
|
reasons: [`token_overlap:${best.overlap.toFixed(2)}`],
|
||||||
return {
|
};
|
||||||
product: null,
|
}
|
||||||
via: 'none',
|
|
||||||
confidence: 0,
|
return {
|
||||||
reasons: ['no_match'],
|
product: null,
|
||||||
};
|
via: 'none',
|
||||||
}
|
confidence: 0,
|
||||||
|
reasons: ['no_match'],
|
||||||
private tokenize(value: string): string[] {
|
};
|
||||||
return value
|
}
|
||||||
.toLowerCase()
|
|
||||||
.split(/[^a-z0-9åäö]+/)
|
private tokenize(value: string): string[] {
|
||||||
.map((part) => part.trim())
|
return value
|
||||||
.filter((part) => part.length >= 3);
|
.toLowerCase()
|
||||||
}
|
.split(/[^a-z0-9åäö]+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
private tokenOverlap(a: string[], b: string[]): number {
|
.filter((part) => part.length >= 3);
|
||||||
if (a.length === 0 || b.length === 0) return 0;
|
}
|
||||||
const as = new Set(a);
|
|
||||||
const bs = new Set(b);
|
private tokenOverlap(a: string[], b: string[]): number {
|
||||||
let intersection = 0;
|
if (a.length === 0 || b.length === 0) return 0;
|
||||||
for (const token of as) {
|
const as = new Set(a);
|
||||||
if (bs.has(token)) intersection++;
|
const bs = new Set(b);
|
||||||
}
|
let intersection = 0;
|
||||||
const union = new Set([...as, ...bs]).size;
|
for (const token of as) {
|
||||||
if (union === 0) return 0;
|
if (bs.has(token)) intersection++;
|
||||||
return intersection / union;
|
}
|
||||||
}
|
const union = new Set([...as, ...bs]).size;
|
||||||
|
if (union === 0) return 0;
|
||||||
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
|
return intersection / union;
|
||||||
const form = new FormData();
|
}
|
||||||
form.append(
|
|
||||||
'file',
|
private isOfferItem(item: FlyerParseItem): boolean {
|
||||||
new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }),
|
return item.price != null || item.comparisonPrice != null || !!item.offerText?.trim();
|
||||||
file.originalname,
|
}
|
||||||
);
|
|
||||||
form.append('retailer', 'willys');
|
private extractOfferLimitText(offerText: string | null): string | null {
|
||||||
|
if (!offerText) return null;
|
||||||
let response: Response;
|
|
||||||
try {
|
const normalized = offerText.replace(/\s+/g, ' ' ).trim();
|
||||||
response = await fetch(`${IMPORTER_SERVICE_URL}/api/flyer/parse`, {
|
if (!normalized) return null;
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
const limitMatch = normalized.match(/(?:max|högst)\s+[^,.;]+(?:hushåll|kund)?/i);
|
||||||
});
|
if (limitMatch?.[0]) {
|
||||||
} catch (err) {
|
return limitMatch[0].trim();
|
||||||
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.');
|
|
||||||
}
|
const householdMatch = normalized.match(/[^,.;]*(?:hushåll|kund)[^,.;]*/i);
|
||||||
|
if (householdMatch?.[0]) {
|
||||||
if (!response.ok) {
|
return householdMatch[0].trim();
|
||||||
let message = `Importer-tjänsten svarade ${response.status}`;
|
}
|
||||||
try {
|
|
||||||
const body = (await response.json()) as { message?: string };
|
return null;
|
||||||
if (typeof body.message === 'string' && body.message.trim()) {
|
}
|
||||||
message = body.message;
|
|
||||||
}
|
private async parseViaImporter(file: Express.Multer.File): Promise<FlyerParseResponse> {
|
||||||
} catch {
|
const form = new FormData();
|
||||||
// ignore parse issues
|
form.append(
|
||||||
}
|
'file',
|
||||||
|
new Blob([new Uint8Array(file.buffer)], { type: file.mimetype }),
|
||||||
if (response.status >= 400 && response.status < 500) {
|
file.originalname,
|
||||||
throw new BadRequestException(message);
|
);
|
||||||
}
|
form.append('retailer', 'willys');
|
||||||
throw new ServiceUnavailableException(message);
|
|
||||||
}
|
let response: Response;
|
||||||
|
try {
|
||||||
return response.json() as Promise<FlyerParseResponse>;
|
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 {
|
class FlyerImportItem {
|
||||||
final int? flyerItemId;
|
final int? flyerItemId;
|
||||||
final String rawName;
|
final String rawName;
|
||||||
final String normalizedName;
|
final String normalizedName;
|
||||||
final String? category;
|
final String? category;
|
||||||
final double? price;
|
final double? price;
|
||||||
final String? priceUnit;
|
final String? priceUnit;
|
||||||
final String? offerText;
|
final String? offerText;
|
||||||
final int? matchedProductId;
|
final bool isOffer;
|
||||||
final String? matchedProductName;
|
final String? offerLimitText;
|
||||||
final String? matchedVia;
|
final double? comparisonPrice;
|
||||||
final double? matchConfidence;
|
final String? comparisonUnit;
|
||||||
|
final double? parseConfidence;
|
||||||
FlyerImportItem({
|
final List<String> parseReasons;
|
||||||
required this.flyerItemId,
|
final int? matchedProductId;
|
||||||
required this.rawName,
|
final String? matchedProductName;
|
||||||
required this.normalizedName,
|
final String? matchedVia;
|
||||||
this.category,
|
final double? matchConfidence;
|
||||||
this.price,
|
|
||||||
this.priceUnit,
|
FlyerImportItem({
|
||||||
this.offerText,
|
required this.flyerItemId,
|
||||||
this.matchedProductId,
|
required this.rawName,
|
||||||
this.matchedProductName,
|
required this.normalizedName,
|
||||||
this.matchedVia,
|
this.category,
|
||||||
this.matchConfidence,
|
this.price,
|
||||||
});
|
this.priceUnit,
|
||||||
|
this.offerText,
|
||||||
factory FlyerImportItem.fromJson(Map<String, dynamic> json) {
|
this.isOffer = false,
|
||||||
return FlyerImportItem(
|
this.offerLimitText,
|
||||||
flyerItemId: (json['flyerItemId'] as num?)?.toInt(),
|
this.comparisonPrice,
|
||||||
rawName: json['rawName'] as String? ?? '',
|
this.comparisonUnit,
|
||||||
normalizedName: json['normalizedName'] as String? ?? '',
|
this.parseConfidence,
|
||||||
category: json['category'] as String?,
|
this.parseReasons = const [],
|
||||||
price: (json['price'] as num?)?.toDouble(),
|
this.matchedProductId,
|
||||||
priceUnit: json['priceUnit'] as String?,
|
this.matchedProductName,
|
||||||
offerText: json['offerText'] as String?,
|
this.matchedVia,
|
||||||
matchedProductId: (json['matchedProductId'] as num?)?.toInt(),
|
this.matchConfidence,
|
||||||
matchedProductName: json['matchedProductName'] as String?,
|
});
|
||||||
matchedVia: json['matchedVia'] as String?,
|
|
||||||
matchConfidence: (json['matchConfidence'] as num?)?.toDouble(),
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../../core/utils/pdf_opener.dart';
|
||||||
import '../../auth/data/auth_providers.dart';
|
import '../../auth/data/auth_providers.dart';
|
||||||
import '../data/import_providers.dart';
|
import '../data/import_providers.dart';
|
||||||
import '../domain/flyer_import_item.dart';
|
import '../domain/flyer_import_item.dart';
|
||||||
@@ -25,7 +26,7 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
Future<void> _pickFile() async {
|
Future<void> _pickFile() async {
|
||||||
final result = await FilePicker.pickFiles(
|
final result = await FilePicker.pickFiles(
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
allowedExtensions: ['pdf', 'txt'],
|
allowedExtensions: ['pdf', 'txt', 'png', 'jpg', 'jpeg', 'webp'],
|
||||||
withData: true,
|
withData: true,
|
||||||
);
|
);
|
||||||
if (result == null || result.files.isEmpty) return;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -120,14 +206,14 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
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,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _isLoading ? null : _pickFile,
|
onPressed: _isLoading ? null : _pickFile,
|
||||||
icon: const Icon(Icons.attach_file),
|
icon: const Icon(Icons.attach_file),
|
||||||
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
|
label: Text(_pickedFile?.name ?? 'Välj flyerfil'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
@@ -135,6 +221,8 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
icon: const Icon(Icons.auto_awesome),
|
icon: const Icon(Icons.auto_awesome),
|
||||||
label: const Text('Importera flyer'),
|
label: const Text('Importera flyer'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildFlyerPreview(theme),
|
||||||
if (_isLoading) ...[
|
if (_isLoading) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const LinearProgressIndicator(),
|
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) {
|
...items.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final item = entry.value;
|
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(
|
return CheckboxListTile(
|
||||||
value: _selected[index] ?? false,
|
value: _selected[index] ?? false,
|
||||||
onChanged: (value) => setState(() => _selected[index] = value ?? false),
|
onChanged: (value) => setState(() => _selected[index] = value ?? false),
|
||||||
title: Text(item.rawName),
|
title: Row(
|
||||||
subtitle: Text([
|
children: [
|
||||||
if (item.offerText != null && item.offerText!.isNotEmpty) item.offerText!,
|
Expanded(child: Text(item.rawName)),
|
||||||
if (item.matchedProductName != null) 'Match: ${item.matchedProductName}',
|
_buildOfferBadge(item, theme),
|
||||||
].join(' · ')),
|
],
|
||||||
|
),
|
||||||
|
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,
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@@ -193,4 +296,5 @@ class _FlyerImportTabState extends ConsumerState<FlyerImportTab> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user