feat(flyer-import): add detailed product signals and display names
- Added `signals` and `displayNameDetailed` fields to FlyerItem model in Prisma schema - Introduced `FlyerImportSignals` type with origin countries, labels, quality flags, variant, and packaging - Added `displayNameDetailed` field to FlyerImportItem DTO and Flutter model - Implemented utility functions for signal extraction and display name building - Updated flyer import service to persist and return signals/category data - Enhanced Flutter UI to display detailed product information including badges for signals - Added new test coverage for signals persistence and display name generation - Added new import-common module for shared import utilities - Created database migration for new fields - Added Kilo plan for feature development
This commit is contained in:
+4
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `FlyerItem`
|
||||
ADD COLUMN `signals` JSON NULL,
|
||||
ADD COLUMN `displayNameDetailed` VARCHAR(191) NULL;
|
||||
@@ -325,6 +325,8 @@ model FlyerItem {
|
||||
bundleWeight String?
|
||||
isBundle Boolean @default(false)
|
||||
bundleItems Json?
|
||||
signals Json?
|
||||
displayNameDetailed String?
|
||||
offerText String?
|
||||
parseConfidence Float
|
||||
parseReasons Json?
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
export type FlyerImportMatchVia = 'alias' | 'exact' | 'token' | 'none';
|
||||
|
||||
export type FlyerImportSignals = {
|
||||
originCountries: string[];
|
||||
labels: string[];
|
||||
qualityFlags: string[];
|
||||
variant: string | null;
|
||||
packaging: string | null;
|
||||
};
|
||||
|
||||
export type FlyerReasonDescriptor = {
|
||||
code: string;
|
||||
kind: 'parse' | 'match';
|
||||
@@ -24,6 +32,8 @@ export type FlyerImportItem = {
|
||||
bundleWeight: string | null;
|
||||
isBundle: boolean;
|
||||
bundleItems: string[];
|
||||
displayNameDetailed: string | null;
|
||||
signals: FlyerImportSignals | null;
|
||||
offerText: string | null;
|
||||
isOffer: boolean;
|
||||
offerLimitText: string | null;
|
||||
|
||||
@@ -5,15 +5,18 @@ import { FlyerImportService } from './flyer-import.service';
|
||||
import { TextExtractorService } from './services/text-extractor.service';
|
||||
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
|
||||
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
||||
import { CategoriesModule } from '../categories/categories.module';
|
||||
import { CategoryResolverService } from '../import-common/category-resolver.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, CategoriesModule],
|
||||
controllers: [FlyerImportController],
|
||||
providers: [
|
||||
FlyerImportService,
|
||||
TextExtractorService,
|
||||
AiFlyerParserService,
|
||||
FlyerNormalizerService,
|
||||
CategoryResolverService,
|
||||
],
|
||||
})
|
||||
export class FlyerImportModule {}
|
||||
|
||||
@@ -3,25 +3,44 @@ import { FlyerImportService } from './flyer-import.service';
|
||||
|
||||
describe('FlyerImportService', () => {
|
||||
const prismaMock = {
|
||||
product: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
receiptAlias: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
flyerSession: {
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
flyerItem: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
aiTrace: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
category: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const createService = () =>
|
||||
const createService = (overrides?: {
|
||||
categoriesService?: any;
|
||||
categoryResolver?: any;
|
||||
textExtractor?: any;
|
||||
aiParser?: any;
|
||||
normalizer?: any;
|
||||
}) =>
|
||||
new FlyerImportService(
|
||||
prismaMock as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
overrides?.categoriesService ?? { findFlattened: jest.fn().mockResolvedValue([]) },
|
||||
overrides?.categoryResolver ?? { resolveForFlyer: jest.fn().mockReturnValue(null) },
|
||||
overrides?.textExtractor ?? {},
|
||||
overrides?.aiParser ?? {},
|
||||
overrides?.normalizer ?? {},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -78,6 +97,8 @@ describe('FlyerImportService', () => {
|
||||
bundleWeight: null,
|
||||
isBundle: false,
|
||||
bundleItems: [],
|
||||
displayNameDetailed: 'Tomat',
|
||||
signals: { originCountries: ['Sverige'], labels: [], qualityFlags: [], variant: null, packaging: null },
|
||||
offerText: 'Max 2 kop/hushall',
|
||||
parseConfidence: 0.9,
|
||||
parseReasons: ['ai_parsed'],
|
||||
@@ -97,6 +118,8 @@ describe('FlyerImportService', () => {
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].flyerItemId).toBe(99);
|
||||
expect(result.items[0].matchedVia).toBe('exact');
|
||||
expect(result.items[0].displayNameDetailed).toBe('Tomat');
|
||||
expect(result.items[0].signals?.originCountries).toEqual(['Sverige']);
|
||||
expect(result.items[0].parseReasonsDetailed[0].title).toBe('AI-tolkad rad');
|
||||
expect(result.items[0].matchReasonsDetailed[0].title).toBe('Exakt normaliserad matchning');
|
||||
expect(result.sourceAvailable).toBe(false);
|
||||
@@ -140,6 +163,162 @@ describe('FlyerImportService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAndMatch', () => {
|
||||
it('persists and returns signals/displayNameDetailed/categoryId in parse pipeline', async () => {
|
||||
prismaMock.product.findMany.mockResolvedValue([
|
||||
{ id: 11, name: 'Fläskytterfilé', canonicalName: 'Fläskytterfilé', categoryId: 7 },
|
||||
]);
|
||||
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
|
||||
prismaMock.flyerSession.create.mockResolvedValue({ id: 200 });
|
||||
prismaMock.flyerItem.create
|
||||
.mockResolvedValueOnce({ id: 1001 });
|
||||
prismaMock.aiTrace.create.mockResolvedValue({ id: 1 });
|
||||
|
||||
const categoriesService = { findFlattened: jest.fn().mockResolvedValue([]) };
|
||||
const categoryResolver = { resolveForFlyer: jest.fn().mockReturnValue(7) };
|
||||
const textExtractor = { extractText: jest.fn().mockResolvedValue('raw flyer text') };
|
||||
const aiParser = {
|
||||
parseWithAI: jest.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
rawName: 'Fläskytterfilé (Sverige) EKO',
|
||||
normalizedName: 'flaskytterfile sverige eko',
|
||||
brand: 'Garant',
|
||||
category: 'Kött',
|
||||
price: 99.9,
|
||||
unit: 'kg',
|
||||
comparisonPrice: null,
|
||||
comparisonUnit: null,
|
||||
weight: '900g',
|
||||
bundleWeight: null,
|
||||
isBundle: true,
|
||||
bundleItems: ['Del 1', 'Del 2'],
|
||||
offer: 'ekologiskt från Sverige',
|
||||
confidence: 0.93,
|
||||
reasonCodes: ['ai_parsed'],
|
||||
},
|
||||
],
|
||||
trace: { prompt: null, rawOutput: null, chunkCount: 1, retryCount: 0 },
|
||||
}),
|
||||
};
|
||||
const normalizer = {
|
||||
normalize: jest.fn().mockReturnValue([
|
||||
{
|
||||
rawName: 'Fläskytterfilé (Sverige) EKO',
|
||||
normalizedName: 'flaskytterfile sverige eko',
|
||||
brand: 'Garant',
|
||||
categoryHint: 'Kött',
|
||||
price: 99.9,
|
||||
priceUnit: 'kg',
|
||||
comparisonPrice: null,
|
||||
comparisonUnit: null,
|
||||
weight: '900g',
|
||||
bundleWeight: null,
|
||||
isBundle: true,
|
||||
bundleItems: ['Del 1', 'Del 2'],
|
||||
offerText: 'ekologiskt från Sverige',
|
||||
parseConfidence: 0.93,
|
||||
parseReasons: ['ai_parsed'],
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
categoriesService,
|
||||
categoryResolver,
|
||||
textExtractor,
|
||||
aiParser,
|
||||
normalizer,
|
||||
});
|
||||
|
||||
const result = await service.parseAndMatch(
|
||||
{
|
||||
originalname: 'flyer.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
size: 10,
|
||||
buffer: Buffer.from('pdf'),
|
||||
} as any,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].displayNameDetailed).toBe('Fläskytterfilé (Sverige) EKO (Del 1 + Del 2)');
|
||||
expect(result.items[0].signals?.originCountries).toEqual(['Sverige']);
|
||||
expect(result.items[0].signals?.qualityFlags).toContain('eco');
|
||||
expect(result.items[0].categoryId).toBe(7);
|
||||
expect(result.items[0].normalizedName).toBe('flaskytterfile');
|
||||
expect(prismaMock.flyerItem.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
displayNameDetailed: 'Fläskytterfilé (Sverige) EKO (Del 1 + Del 2)',
|
||||
categoryId: 7,
|
||||
signals: expect.objectContaining({ originCountries: ['Sverige'] }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs warning when categories fallback is used', async () => {
|
||||
prismaMock.product.findMany.mockResolvedValue([]);
|
||||
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
|
||||
prismaMock.flyerSession.create.mockResolvedValue({ id: 201 });
|
||||
prismaMock.flyerItem.create.mockResolvedValue({ id: 1002 });
|
||||
prismaMock.aiTrace.create.mockResolvedValue({ id: 2 });
|
||||
|
||||
const categoriesService = { findFlattened: jest.fn().mockRejectedValue(new Error('db down')) };
|
||||
const textExtractor = { extractText: jest.fn().mockResolvedValue('raw') };
|
||||
const aiParser = {
|
||||
parseWithAI: jest.fn().mockResolvedValue({
|
||||
items: [{ rawName: 'Tomat' }],
|
||||
trace: { prompt: null, rawOutput: null, chunkCount: 1, retryCount: 0 },
|
||||
}),
|
||||
};
|
||||
const normalizer = {
|
||||
normalize: jest.fn().mockReturnValue([
|
||||
{
|
||||
rawName: 'Tomat',
|
||||
normalizedName: 'tomat',
|
||||
brand: null,
|
||||
categoryHint: null,
|
||||
price: null,
|
||||
priceUnit: null,
|
||||
comparisonPrice: null,
|
||||
comparisonUnit: null,
|
||||
weight: null,
|
||||
bundleWeight: null,
|
||||
isBundle: false,
|
||||
bundleItems: [],
|
||||
offerText: null,
|
||||
parseConfidence: 0.9,
|
||||
parseReasons: ['ai_parsed'],
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
const service = createService({
|
||||
categoriesService,
|
||||
textExtractor,
|
||||
aiParser,
|
||||
normalizer,
|
||||
});
|
||||
const warnSpy = jest.spyOn((service as any).logger, 'warn');
|
||||
|
||||
await service.parseAndMatch(
|
||||
{
|
||||
originalname: 'flyer.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
size: 10,
|
||||
buffer: Buffer.from('pdf'),
|
||||
} as any,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Could not load categories for flyer import'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestSession', () => {
|
||||
it('returns empty response when no sessions exist', async () => {
|
||||
prismaMock.flyerSession.findFirst.mockResolvedValue(null);
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { normalizeName } from '../common/utils/normalize-name';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { normalizeName } from '../common/utils/normalize-name';
|
||||
import { CategoriesService } from '../categories/categories.service';
|
||||
import {
|
||||
FlyerImportItem,
|
||||
FlyerImportMatchVia,
|
||||
@@ -18,6 +19,10 @@ import { TextExtractorService } from './services/text-extractor.service';
|
||||
import { AiFlyerParserService } from './services/ai-flyer-parser.service';
|
||||
import { FlyerNormalizerService } from './services/flyer-normalizer.service';
|
||||
import { describeMatchReason, describeParseReason } from './services/reason-codes';
|
||||
import { CategoryResolverService } from '../import-common/category-resolver.service';
|
||||
import { buildDisplayNameDetailed } from '../import-common/import-display-name.util';
|
||||
import { extractImportSignals } from '../import-common/import-signals.util';
|
||||
import { ImportedItemSignals } from '../import-common/import-item.types';
|
||||
|
||||
type FlyerParseItem = {
|
||||
rawName: string;
|
||||
@@ -58,11 +63,12 @@ type ExtractedOfferSignals = {
|
||||
hasCampaignPattern: boolean;
|
||||
};
|
||||
|
||||
type ProductLite = {
|
||||
id: number;
|
||||
name: string;
|
||||
canonicalName: string | null;
|
||||
};
|
||||
type ProductLite = {
|
||||
id: number;
|
||||
name: string;
|
||||
canonicalName: string | null;
|
||||
categoryId: number | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FlyerImportService {
|
||||
@@ -70,29 +76,37 @@ export class FlyerImportService {
|
||||
private readonly MAX_BUNDLE_ITEMS = 20;
|
||||
private readonly MAX_BUNDLE_ITEM_LENGTH = 120;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly textExtractor: TextExtractorService,
|
||||
private readonly aiParser: AiFlyerParserService,
|
||||
private readonly normalizer: FlyerNormalizerService,
|
||||
) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly categoriesService: CategoriesService,
|
||||
private readonly categoryResolver: CategoryResolverService,
|
||||
private readonly textExtractor: TextExtractorService,
|
||||
private readonly aiParser: AiFlyerParserService,
|
||||
private readonly normalizer: FlyerNormalizerService,
|
||||
) {}
|
||||
|
||||
async parseAndMatch(file: Express.Multer.File, userId: number): Promise<FlyerImportResponse> {
|
||||
const startedAt = Date.now();
|
||||
const parsed = await this.parseViaInternal(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 [products, aliases, categories] = await Promise.all([
|
||||
this.prisma.product.findMany({
|
||||
where: { ownerId: userId, isActive: true },
|
||||
select: { id: true, name: true, canonicalName: true, categoryId: true },
|
||||
}),
|
||||
this.prisma.receiptAlias.findMany({
|
||||
where: {
|
||||
OR: [{ ownerId: userId, isGlobal: false }, { isGlobal: true }],
|
||||
},
|
||||
select: { receiptName: true, productId: true },
|
||||
}),
|
||||
this.categoriesService.findFlattened().catch((error) => {
|
||||
this.logger.warn(
|
||||
`Could not load categories for flyer import, proceeding without rule categories: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
|
||||
const aliasToProduct = new Map<string, number>();
|
||||
for (const alias of aliases) {
|
||||
@@ -109,20 +123,39 @@ export class FlyerImportService {
|
||||
}
|
||||
|
||||
const items: FlyerImportItem[] = parsed.items.map((item) => {
|
||||
const match = this.matchItem(item, products, aliasToProduct, productById);
|
||||
const signalData = extractImportSignals({
|
||||
rawName: item.rawName,
|
||||
brand: item.brand,
|
||||
offerText: item.offerText,
|
||||
});
|
||||
|
||||
const match = this.matchItem(item, signalData.normalizedMatchName, signalData.signals, products, aliasToProduct, productById);
|
||||
const signals = this.extractOfferSignals(item.offerText);
|
||||
const price = item.price ?? signals.price;
|
||||
const priceUnit = this.normalizeUnit(item.priceUnit) ?? signals.priceUnit;
|
||||
const comparisonPrice = item.comparisonPrice ?? signals.comparisonPrice;
|
||||
const comparisonUnit = this.normalizeUnit(item.comparisonUnit) ?? signals.comparisonUnit;
|
||||
const offerLimitText = this.extractOfferLimitText(item.offerText);
|
||||
const displayNameDetailed = buildDisplayNameDetailed({
|
||||
rawName: item.rawName,
|
||||
isBundle: item.isBundle,
|
||||
bundleItems: this.sanitizeBundleItems(item.bundleItems),
|
||||
});
|
||||
const categoryId = this.categoryResolver.resolveForFlyer({
|
||||
categories,
|
||||
signalText: [item.rawName, item.brand ?? '', item.offerText ?? ''].join(' ').trim(),
|
||||
categoryHint: item.category,
|
||||
matchedProductCategoryId: match.product?.categoryId ?? null,
|
||||
matchConfidence: match.confidence,
|
||||
});
|
||||
|
||||
return {
|
||||
flyerItemId: null,
|
||||
rawName: item.rawName,
|
||||
normalizedName: item.normalizedName,
|
||||
normalizedName: signalData.normalizedMatchName || item.normalizedName,
|
||||
brand: item.brand,
|
||||
category: item.category,
|
||||
categoryId: null,
|
||||
categoryId,
|
||||
price,
|
||||
priceUnit,
|
||||
comparisonPrice,
|
||||
@@ -131,6 +164,8 @@ export class FlyerImportService {
|
||||
bundleWeight: item.bundleWeight,
|
||||
isBundle: item.isBundle,
|
||||
bundleItems: this.sanitizeBundleItems(item.bundleItems),
|
||||
displayNameDetailed,
|
||||
signals: signalData.signals,
|
||||
offerText: item.offerText,
|
||||
isOffer: this.isOfferItem(item, signals.hasCampaignPattern),
|
||||
offerLimitText,
|
||||
@@ -145,6 +180,8 @@ export class FlyerImportService {
|
||||
matchReasonsDetailed: this.describeMatchReasons(match.reasons),
|
||||
};
|
||||
});
|
||||
|
||||
this.logImportMetrics(items);
|
||||
|
||||
const persistedItems = await this.persistSessionWithItems(userId, parsed.retailer, items, file);
|
||||
|
||||
@@ -385,36 +422,40 @@ export class FlyerImportService {
|
||||
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,
|
||||
brand: item.brand,
|
||||
categoryHint: item.category,
|
||||
categoryId: item.categoryId,
|
||||
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,
|
||||
weight: item.weight,
|
||||
bundleWeight: item.bundleWeight,
|
||||
isBundle: item.isBundle,
|
||||
bundleItems: item.bundleItems,
|
||||
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 },
|
||||
});
|
||||
const savedItems: FlyerImportItem[] = [];
|
||||
for (const item of items) {
|
||||
const createData: Prisma.FlyerItemUncheckedCreateInput = {
|
||||
sessionId: session.id,
|
||||
rawName: item.rawName,
|
||||
normalizedName: item.normalizedName,
|
||||
brand: item.brand,
|
||||
categoryHint: item.category,
|
||||
categoryId: item.categoryId,
|
||||
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,
|
||||
weight: item.weight,
|
||||
bundleWeight: item.bundleWeight,
|
||||
isBundle: item.isBundle,
|
||||
bundleItems: item.bundleItems,
|
||||
displayNameDetailed: item.displayNameDetailed,
|
||||
signals: item.signals as Prisma.InputJsonValue,
|
||||
offerText: item.offerText,
|
||||
parseConfidence: item.parseConfidence,
|
||||
parseReasons: item.parseReasons,
|
||||
matchedProductId: item.matchedProductId,
|
||||
matchedProductName: item.matchedProductName,
|
||||
matchedVia: item.matchedVia,
|
||||
matchConfidence: item.matchConfidence,
|
||||
matchReasons: item.matchReasons,
|
||||
};
|
||||
|
||||
const created = await this.prisma.flyerItem.create({
|
||||
data: createData,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
savedItems.push({ ...item, flyerItemId: created.id });
|
||||
}
|
||||
@@ -431,21 +472,23 @@ export class FlyerImportService {
|
||||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private matchItem(
|
||||
item: FlyerParseItem,
|
||||
products: ProductLite[],
|
||||
aliasToProduct: Map<string, number>,
|
||||
productById: Map<number, ProductLite>,
|
||||
): {
|
||||
private matchItem(
|
||||
item: FlyerParseItem,
|
||||
normalizedMatchName: string,
|
||||
itemSignals: ImportedItemSignals,
|
||||
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 normalized = normalizedMatchName || normalizeName(item.normalizedName || item.rawName);
|
||||
if (!normalized) {
|
||||
return { product: null, via: 'none', confidence: 0, reasons: ['empty_name'] };
|
||||
}
|
||||
|
||||
const aliasedProductId = aliasToProduct.get(normalized);
|
||||
if (aliasedProductId) {
|
||||
@@ -471,17 +514,30 @@ export class FlyerImportService {
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
let best: { product: ProductLite; confidence: number; overlap: number } | null = null;
|
||||
const itemTokens = this.tokenize(normalized);
|
||||
for (const product of products) {
|
||||
const productTokens = this.tokenize(product.canonicalName ?? product.name);
|
||||
const overlap = this.tokenOverlap(itemTokens, productTokens);
|
||||
if (overlap <= 0) continue;
|
||||
|
||||
let confidence = Math.min(0.93, 0.48 + overlap * 0.42);
|
||||
if (this.hasBrandSignal(item.brand, product)) {
|
||||
confidence += 0.04;
|
||||
}
|
||||
if (this.hasWeightSignal(item.weight, product)) {
|
||||
confidence += 0.03;
|
||||
}
|
||||
if (this.hasQualitySignal(itemSignals, product)) {
|
||||
confidence += 0.03;
|
||||
}
|
||||
|
||||
confidence = Math.min(0.95, confidence);
|
||||
|
||||
if (!best || confidence > best.confidence) {
|
||||
best = { product, confidence, overlap };
|
||||
}
|
||||
}
|
||||
|
||||
if (best && best.confidence >= 0.66) {
|
||||
return {
|
||||
@@ -508,7 +564,7 @@ export class FlyerImportService {
|
||||
.filter((part) => part.length >= 3);
|
||||
}
|
||||
|
||||
private tokenOverlap(a: string[], b: string[]): number {
|
||||
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);
|
||||
@@ -518,8 +574,32 @@ export class FlyerImportService {
|
||||
}
|
||||
const union = new Set([...as, ...bs]).size;
|
||||
if (union === 0) return 0;
|
||||
return intersection / union;
|
||||
}
|
||||
return intersection / union;
|
||||
}
|
||||
|
||||
private hasBrandSignal(brand: string | null, product: ProductLite): boolean {
|
||||
if (!brand) return false;
|
||||
const normalizedBrand = normalizeName(brand);
|
||||
if (!normalizedBrand) return false;
|
||||
|
||||
const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`);
|
||||
return normalizedProduct.includes(normalizedBrand);
|
||||
}
|
||||
|
||||
private hasWeightSignal(weight: string | null, product: ProductLite): boolean {
|
||||
if (!weight) return false;
|
||||
const normalizedWeight = normalizeName(weight);
|
||||
if (!normalizedWeight) return false;
|
||||
|
||||
const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`);
|
||||
return normalizedProduct.includes(normalizedWeight);
|
||||
}
|
||||
|
||||
private hasQualitySignal(signals: ImportedItemSignals, product: ProductLite): boolean {
|
||||
if (!signals.qualityFlags.includes('eco')) return false;
|
||||
const normalizedProduct = normalizeName(`${product.name} ${product.canonicalName ?? ''}`);
|
||||
return /\beko\b|\bekolog/i.test(normalizedProduct);
|
||||
}
|
||||
|
||||
private isOfferItem(item: FlyerParseItem, hasCampaignPattern: boolean): boolean {
|
||||
return (
|
||||
@@ -745,6 +825,8 @@ export class FlyerImportService {
|
||||
bundleWeight: string | null;
|
||||
isBundle: boolean;
|
||||
bundleItems: Prisma.JsonValue | null;
|
||||
displayNameDetailed?: string | null;
|
||||
signals?: Prisma.JsonValue | null;
|
||||
offerText: string | null;
|
||||
parseConfidence: number;
|
||||
parseReasons: Prisma.JsonValue | null;
|
||||
@@ -759,6 +841,24 @@ export class FlyerImportService {
|
||||
return value.map((entry) => String(entry));
|
||||
};
|
||||
|
||||
const toSignals = (value: Prisma.JsonValue | null | undefined): ImportedItemSignals | null => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const toArray = (key: string): string[] => {
|
||||
const maybeArray = record[key];
|
||||
if (!Array.isArray(maybeArray)) return [];
|
||||
return maybeArray.map((entry) => String(entry));
|
||||
};
|
||||
|
||||
return {
|
||||
originCountries: toArray('originCountries'),
|
||||
labels: toArray('labels'),
|
||||
qualityFlags: toArray('qualityFlags'),
|
||||
variant: typeof record.variant === 'string' ? record.variant : null,
|
||||
packaging: typeof record.packaging === 'string' ? record.packaging : null,
|
||||
};
|
||||
};
|
||||
|
||||
const normalizedMatchVia =
|
||||
item.matchedVia === 'alias' || item.matchedVia === 'exact' || item.matchedVia === 'token'
|
||||
? item.matchedVia
|
||||
@@ -784,6 +884,14 @@ export class FlyerImportService {
|
||||
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
|
||||
@@ -858,6 +966,8 @@ export class FlyerImportService {
|
||||
bundleWeight: string | null;
|
||||
isBundle: boolean;
|
||||
bundleItems: Prisma.JsonValue | null;
|
||||
displayNameDetailed?: string | null;
|
||||
signals?: Prisma.JsonValue | null;
|
||||
offerText: string | null;
|
||||
parseConfidence: number;
|
||||
parseReasons: Prisma.JsonValue | null;
|
||||
@@ -918,4 +1028,17 @@ export class FlyerImportService {
|
||||
.slice(0, this.MAX_BUNDLE_ITEMS)
|
||||
.map((entry) => entry.slice(0, this.MAX_BUNDLE_ITEM_LENGTH));
|
||||
}
|
||||
|
||||
private logImportMetrics(items: FlyerImportItem[]): void {
|
||||
if (items.length === 0) return;
|
||||
|
||||
const noMatchCount = items.filter((item) => item.matchReasons.includes('no_match')).length;
|
||||
const categoryAssignedCount = items.filter((item) => item.categoryId != null).length;
|
||||
const noMatchRatio = (noMatchCount / items.length) * 100;
|
||||
const categoryAssignedRatio = (categoryAssignedCount / items.length) * 100;
|
||||
|
||||
this.logger.log(
|
||||
`Flyer import metrics: no_match=${noMatchCount}/${items.length} (${noMatchRatio.toFixed(1)}%), category_id=${categoryAssignedCount}/${items.length} (${categoryAssignedRatio.toFixed(1)}%)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { CategoryResolverService } from './category-resolver.service';
|
||||
|
||||
describe('CategoryResolverService', () => {
|
||||
const service = new CategoryResolverService();
|
||||
|
||||
const categories = [
|
||||
{ id: 1, name: 'Kött, chark & fågel', path: 'Kött, chark & fågel' },
|
||||
{ id: 2, name: 'Kött', path: 'Kött, chark & fågel > Kött' },
|
||||
{ id: 3, name: 'Fläsk', path: 'Kött, chark & fågel > Kött > Fläsk' },
|
||||
{ id: 4, name: 'Bröd', path: 'Bröd & kakor > Bröd' },
|
||||
];
|
||||
|
||||
it('resolves Fläskytterfilé to pork category', () => {
|
||||
const categoryId = service.resolveForFlyer({
|
||||
categories,
|
||||
signalText: 'Fläskytterfilé Sverige',
|
||||
categoryHint: null,
|
||||
matchedProductCategoryId: null,
|
||||
matchConfidence: 0,
|
||||
});
|
||||
|
||||
expect(categoryId).toBe(3);
|
||||
});
|
||||
|
||||
it('prefers matched product category when confidence is high', () => {
|
||||
const categoryId = service.resolveForFlyer({
|
||||
categories,
|
||||
signalText: 'Något annat',
|
||||
categoryHint: 'Bröd',
|
||||
matchedProductCategoryId: 99,
|
||||
matchConfidence: 0.95,
|
||||
});
|
||||
|
||||
expect(categoryId).toBe(99);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { FlatCategory } from '../categories/categories.service';
|
||||
|
||||
type ResolveFlyerCategoryParams = {
|
||||
categories: FlatCategory[];
|
||||
signalText: string;
|
||||
categoryHint: string | null;
|
||||
matchedProductCategoryId: number | null;
|
||||
matchConfidence: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CategoryResolverService {
|
||||
resolveForFlyer(params: ResolveFlyerCategoryParams): number | null {
|
||||
if (params.matchedProductCategoryId != null && params.matchConfidence >= 0.9) {
|
||||
return params.matchedProductCategoryId;
|
||||
}
|
||||
|
||||
const normalizedSignal = normalizeForRules(params.signalText);
|
||||
|
||||
if (hasPorkLikeSignal(normalizedSignal)) {
|
||||
const pork = this.resolvePorkCategory(params.categories);
|
||||
if (pork) return pork.id;
|
||||
}
|
||||
|
||||
if (hasBreadLikeSignal(normalizedSignal)) {
|
||||
const bread = this.resolveBreadCategory(params.categories);
|
||||
if (bread) return bread.id;
|
||||
}
|
||||
|
||||
if (!params.categoryHint) return null;
|
||||
return this.resolveByHint(params.categories, params.categoryHint)?.id ?? null;
|
||||
}
|
||||
|
||||
private resolveByHint(categories: FlatCategory[], categoryHint: string): FlatCategory | undefined {
|
||||
const normalizedHint = normalizeForRules(categoryHint);
|
||||
|
||||
return categories.find((category) => {
|
||||
const normalizedName = normalizeForRules(category.name);
|
||||
const normalizedPath = normalizeForRules(category.path);
|
||||
return normalizedName === normalizedHint || normalizedPath === normalizedHint;
|
||||
});
|
||||
}
|
||||
|
||||
private resolvePorkCategory(categories: FlatCategory[]): FlatCategory | undefined {
|
||||
return (
|
||||
categories.find(
|
||||
(category) =>
|
||||
category.name.toLowerCase() === 'fläsk' &&
|
||||
category.path.toLowerCase().startsWith('kött, chark & fågel > kött > '),
|
||||
) ||
|
||||
categories.find(
|
||||
(category) =>
|
||||
category.name.toLowerCase() === 'kött' &&
|
||||
category.path.toLowerCase() === 'kött, chark & fågel > kött',
|
||||
) ||
|
||||
categories.find((category) => category.path.toLowerCase() === 'kött, chark & fågel')
|
||||
);
|
||||
}
|
||||
|
||||
private resolveBreadCategory(categories: FlatCategory[]): FlatCategory | undefined {
|
||||
return (
|
||||
categories.find(
|
||||
(category) =>
|
||||
category.name.toLowerCase() === 'rostbröd' &&
|
||||
category.path.toLowerCase().startsWith('bröd & kakor > bröd > '),
|
||||
) ||
|
||||
categories.find(
|
||||
(category) =>
|
||||
category.name.toLowerCase() === 'bröd' &&
|
||||
category.path.toLowerCase() === 'bröd & kakor > bröd',
|
||||
) ||
|
||||
categories.find((category) => category.path.toLowerCase() === 'bröd & kakor')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeForRules(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasPorkLikeSignal(normalized: string): boolean {
|
||||
return (
|
||||
normalized.includes('bacon') ||
|
||||
normalized.includes('sidflask') ||
|
||||
normalized.includes('pancetta') ||
|
||||
normalized.includes('flask') ||
|
||||
normalized.includes('flaskytterfile') ||
|
||||
normalized.includes('ytterfile') ||
|
||||
normalized.includes('karre') ||
|
||||
normalized.includes('kotlett')
|
||||
);
|
||||
}
|
||||
|
||||
function hasBreadLikeSignal(normalized: string): boolean {
|
||||
return (
|
||||
/\brostbrod\b/.test(normalized) ||
|
||||
/\brost\s*n\s*toast\b/.test(normalized) ||
|
||||
/\broast\s*n\s*toast\b/.test(normalized) ||
|
||||
/\btoastbrod\b/.test(normalized) ||
|
||||
/\bformbrod\b/.test(normalized) ||
|
||||
/\blantbrod\b/.test(normalized) ||
|
||||
/\bfullkornsbrod\b/.test(normalized) ||
|
||||
/\bfranska\b/.test(normalized) ||
|
||||
/\blimpa\b/.test(normalized) ||
|
||||
/\bbrod\b/.test(normalized) ||
|
||||
/\btoast\b/.test(normalized)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function buildDisplayNameDetailed(params: {
|
||||
rawName: string;
|
||||
isBundle: boolean;
|
||||
bundleItems: string[];
|
||||
}): string {
|
||||
const rawName = params.rawName.trim();
|
||||
if (!params.isBundle) return rawName;
|
||||
|
||||
const items = params.bundleItems
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
|
||||
if (items.length === 0) return rawName;
|
||||
return `${rawName} (${items.join(' + ')})`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ImportedItemSignals = {
|
||||
originCountries: string[];
|
||||
labels: string[];
|
||||
qualityFlags: string[];
|
||||
variant: string | null;
|
||||
packaging: string | null;
|
||||
};
|
||||
|
||||
export type ImportedItemCandidate = {
|
||||
rawName: string;
|
||||
normalizedName: string;
|
||||
brand: string | null;
|
||||
weight: string | null;
|
||||
bundleWeight: string | null;
|
||||
isBundle: boolean;
|
||||
bundleItems: string[];
|
||||
price: number | null;
|
||||
priceUnit: string | null;
|
||||
comparisonPrice: number | null;
|
||||
comparisonUnit: string | null;
|
||||
categoryHint: string | null;
|
||||
categoryId: number | null;
|
||||
matchedProductId: number | null;
|
||||
matchedProductName: string | null;
|
||||
matchedVia: string;
|
||||
matchConfidence: number;
|
||||
matchReasons: string[];
|
||||
signals: ImportedItemSignals | null;
|
||||
displayNameDetailed: string | null;
|
||||
};
|
||||
|
||||
export const EMPTY_IMPORTED_SIGNALS: ImportedItemSignals = {
|
||||
originCountries: [],
|
||||
labels: [],
|
||||
qualityFlags: [],
|
||||
variant: null,
|
||||
packaging: null,
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { buildDisplayNameDetailed } from './import-display-name.util';
|
||||
import { extractImportSignals } from './import-signals.util';
|
||||
|
||||
describe('import signals utilities', () => {
|
||||
it('extracts deterministic origin and eco labels', () => {
|
||||
const result = extractImportSignals({
|
||||
rawName: 'Fläskytterfilé (Sverige) EKO',
|
||||
brand: 'Garant',
|
||||
offerText: 'Ekologiskt kött från Sverige',
|
||||
});
|
||||
|
||||
expect(result.signals.originCountries).toEqual(['Sverige']);
|
||||
expect(result.signals.labels).toContain('Ekologisk');
|
||||
expect(result.signals.qualityFlags).toContain('eco');
|
||||
expect(result.normalizedMatchName).toBe('flaskytterfile');
|
||||
});
|
||||
|
||||
it('extracts Germany and keeps labels deterministic', () => {
|
||||
const result = extractImportSignals({
|
||||
rawName: 'Korv från Tyskland',
|
||||
offerText: 'Tysk kvalitet',
|
||||
});
|
||||
|
||||
expect(result.signals.originCountries).toEqual(['Tyskland']);
|
||||
expect(result.signals.labels).toEqual([]);
|
||||
});
|
||||
|
||||
it('builds detailed display name for bundle rows', () => {
|
||||
expect(
|
||||
buildDisplayNameDetailed({
|
||||
rawName: 'Kaptenens Favoriter',
|
||||
isBundle: true,
|
||||
bundleItems: ['Chumlax 3x100g', 'Alaska pollock 3x100g'],
|
||||
}),
|
||||
).toBe('Kaptenens Favoriter (Chumlax 3x100g + Alaska pollock 3x100g)');
|
||||
});
|
||||
|
||||
it('extracts storpack packaging signal', () => {
|
||||
const result = extractImportSignals({
|
||||
rawName: 'Kycklingfilé storpack',
|
||||
});
|
||||
|
||||
expect(result.signals.packaging).toBe('storpack');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { normalizeName } from '../common/utils/normalize-name';
|
||||
import {
|
||||
EMPTY_IMPORTED_SIGNALS,
|
||||
ImportedItemSignals,
|
||||
} from './import-item.types';
|
||||
|
||||
type SignalExtractionInput = {
|
||||
rawName: string;
|
||||
brand?: string | null;
|
||||
offerText?: string | null;
|
||||
};
|
||||
|
||||
const ORIGIN_COUNTRY_PATTERNS: Array<{ label: string; regex: RegExp }> = [
|
||||
{ label: 'Sverige', regex: /\b(sverige|svensk(t|a)?|sweden)\b/i },
|
||||
{ label: 'Tyskland', regex: /\b(tyskland|tysk(t|a)?|germany|deutschland)\b/i },
|
||||
{ label: 'Norge', regex: /\b(norge|norsk(t|a)?)\b/i },
|
||||
{ label: 'Danmark', regex: /\b(danmark|dansk(t|a)?)\b/i },
|
||||
{ label: 'Finland', regex: /\b(finland|finsk(t|a)?)\b/i },
|
||||
];
|
||||
|
||||
const LABEL_PATTERNS: Array<{ label: string; qualityFlag: string | null; regex: RegExp }> = [
|
||||
{ label: 'Ekologisk', qualityFlag: 'eco', regex: /\b(eko|ekologisk(t|a)?|organic)\b/i },
|
||||
{ label: 'Laktosfri', qualityFlag: 'lactose_free', regex: /\b(laktosfri(tt|a)?|lactose\s*free)\b/i },
|
||||
{ label: 'Glutenfri', qualityFlag: 'gluten_free', regex: /\b(glutenfri(tt|a)?|gluten\s*free)\b/i },
|
||||
{ label: 'Vegansk', qualityFlag: 'vegan', regex: /\b(vegansk(t|a)?|vegan)\b/i },
|
||||
{ label: 'Vegetarisk', qualityFlag: 'vegetarian', regex: /\b(vegetarisk(t|a)?|vegetarian)\b/i },
|
||||
];
|
||||
|
||||
export type SignalExtractionResult = {
|
||||
signals: ImportedItemSignals;
|
||||
normalizedMatchName: string;
|
||||
};
|
||||
|
||||
export function extractImportSignals(input: SignalExtractionInput): SignalExtractionResult {
|
||||
const text = [input.rawName, input.brand ?? '', input.offerText ?? '']
|
||||
.filter((part) => part.trim().length > 0)
|
||||
.join(' ');
|
||||
|
||||
const origins = ORIGIN_COUNTRY_PATTERNS
|
||||
.filter((pattern) => pattern.regex.test(text))
|
||||
.map((pattern) => pattern.label);
|
||||
|
||||
const labels = LABEL_PATTERNS
|
||||
.filter((pattern) => pattern.regex.test(text))
|
||||
.map((pattern) => pattern.label);
|
||||
|
||||
const qualityFlags = LABEL_PATTERNS
|
||||
.filter((pattern) => pattern.qualityFlag && pattern.regex.test(text))
|
||||
.map((pattern) => pattern.qualityFlag as string);
|
||||
|
||||
const packaging = resolvePackaging(text);
|
||||
const variant = extractVariant(input.rawName);
|
||||
|
||||
const signals: ImportedItemSignals = {
|
||||
...EMPTY_IMPORTED_SIGNALS,
|
||||
originCountries: Array.from(new Set(origins)),
|
||||
labels: Array.from(new Set(labels)),
|
||||
qualityFlags: Array.from(new Set(qualityFlags)),
|
||||
variant,
|
||||
packaging,
|
||||
};
|
||||
|
||||
const normalizedMatchName = normalizeForMatching(input.rawName);
|
||||
|
||||
return { signals, normalizedMatchName };
|
||||
}
|
||||
|
||||
export function normalizeForMatching(rawName: string): string {
|
||||
let cleaned = rawName;
|
||||
|
||||
for (const pattern of ORIGIN_COUNTRY_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern.regex, ' ');
|
||||
}
|
||||
|
||||
for (const pattern of LABEL_PATTERNS) {
|
||||
cleaned = cleaned.replace(pattern.regex, ' ');
|
||||
}
|
||||
|
||||
cleaned = cleaned.replace(/[()\[\]]/g, ' ');
|
||||
cleaned = cleaned.replace(/\s+/g, ' ').trim();
|
||||
return normalizeName(cleaned) || normalizeName(rawName);
|
||||
}
|
||||
|
||||
function resolvePackaging(text: string): string | null {
|
||||
const normalized = text.toLowerCase();
|
||||
if (/\b\d+\s*[x×]\s*\d+\s*(g|kg|ml|cl|dl|l)\b/.test(normalized)) {
|
||||
return 'multipack';
|
||||
}
|
||||
if (/\bstorpack\b/.test(normalized)) {
|
||||
return 'storpack';
|
||||
}
|
||||
if (/\b(2-pack|3-pack|4-pack|5-pack|6-pack|pack)\b/.test(normalized)) {
|
||||
return 'pack';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractVariant(rawName: string): string | null {
|
||||
const variantMatch = rawName.match(/\(([^)]+)\)/);
|
||||
if (!variantMatch) return null;
|
||||
const value = variantMatch[1].trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
Reference in New Issue
Block a user