471 lines
12 KiB
TypeScript
471 lines
12 KiB
TypeScript
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { normalizeName } from '../common/utils/normalize-name';
|
|
import { CreateProductDto } from './dto/create-product.dto';
|
|
import { UpdateProductDto } from './dto/update-product.dto';
|
|
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
|
|
|
|
@Injectable()
|
|
export class ProductsService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async findAll(filters?: { tag?: string; subcategory?: string }) {
|
|
return this.prisma.product.findMany({
|
|
where: {
|
|
isActive: true,
|
|
...(filters?.subcategory ? { subcategory: filters.subcategory } : {}),
|
|
...(filters?.tag
|
|
? { tags: { some: { tag: { name: filters.tag } } } }
|
|
: {}),
|
|
},
|
|
include: {
|
|
tags: { include: { tag: true } },
|
|
nutrition: true,
|
|
categoryRef: { include: { parent: { include: { parent: true } } } },
|
|
},
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
}
|
|
|
|
async findDuplicateCandidates() {
|
|
const products = await this.prisma.product.findMany({
|
|
where: {
|
|
isActive: true,
|
|
},
|
|
orderBy: {
|
|
name: 'asc',
|
|
},
|
|
});
|
|
|
|
const grouped = new Map<string, typeof products>();
|
|
|
|
for (const product of products) {
|
|
const key = product.normalizedName;
|
|
|
|
if (!grouped.has(key)) {
|
|
grouped.set(key, []);
|
|
}
|
|
|
|
grouped.get(key)!.push(product);
|
|
}
|
|
|
|
return Array.from(grouped.entries())
|
|
.filter(([, items]) => items.length > 1)
|
|
.map(([normalizedName, items]) => ({
|
|
normalizedName,
|
|
count: items.length,
|
|
products: items,
|
|
}));
|
|
}
|
|
|
|
async findOne(id: number) {
|
|
const product = await this.prisma.product.findUnique({
|
|
where: { id },
|
|
});
|
|
|
|
if (!product) {
|
|
throw new NotFoundException(`Product with id ${id} not found`);
|
|
}
|
|
|
|
return product;
|
|
}
|
|
|
|
async create(data: CreateProductDto) {
|
|
const name = data.name.trim();
|
|
const normalizedName = normalizeName(name);
|
|
|
|
const existing = await this.prisma.product.findUnique({
|
|
where: { normalizedName },
|
|
});
|
|
|
|
if (existing) {
|
|
if (!existing.isActive) {
|
|
return this.prisma.product.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
name,
|
|
canonicalName: name,
|
|
},
|
|
});
|
|
}
|
|
|
|
return existing;
|
|
}
|
|
|
|
return this.prisma.product.create({
|
|
data: {
|
|
name,
|
|
normalizedName,
|
|
canonicalName: name,
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
async update(id: number, data: UpdateProductDto) {
|
|
await this.findOne(id);
|
|
|
|
const updateData: {
|
|
name?: string;
|
|
normalizedName?: string;
|
|
canonicalName?: string;
|
|
category?: string | null;
|
|
subcategory?: string | null;
|
|
brand?: string | null;
|
|
categoryId?: number | null;
|
|
} = {};
|
|
|
|
if (typeof data.name === 'string') {
|
|
const name = data.name.trim();
|
|
const normalizedName = normalizeName(name);
|
|
|
|
const existing = await this.prisma.product.findUnique({
|
|
where: { normalizedName },
|
|
});
|
|
|
|
if (existing && existing.id !== id) {
|
|
// Om en annan produkt har samma namn, returnera ett tydligt fel
|
|
throw new Error('Det finns redan en annan produkt med detta namn. Välj ett unikt namn.');
|
|
}
|
|
|
|
updateData.name = name;
|
|
updateData.normalizedName = normalizedName;
|
|
}
|
|
|
|
if (typeof data.canonicalName === 'string') {
|
|
updateData.canonicalName = data.canonicalName.trim() || undefined;
|
|
}
|
|
|
|
if (typeof data.category === 'string') {
|
|
updateData.category = data.category.trim() || null;
|
|
}
|
|
|
|
if (typeof data.subcategory === 'string') {
|
|
updateData.subcategory = data.subcategory.trim() || null;
|
|
}
|
|
|
|
if (typeof data.brand === 'string') {
|
|
updateData.brand = data.brand.trim() || null;
|
|
}
|
|
|
|
if ('categoryId' in data) {
|
|
updateData.categoryId = data.categoryId ?? null;
|
|
}
|
|
|
|
return this.prisma.product.update({
|
|
where: { id },
|
|
data: updateData,
|
|
include: { tags: { include: { tag: true } }, nutrition: true },
|
|
});
|
|
}
|
|
|
|
async updateCanonicalName(id: number, canonicalName: string) {
|
|
await this.findOne(id);
|
|
|
|
const cleaned = canonicalName.trim();
|
|
|
|
return this.prisma.product.update({
|
|
where: { id },
|
|
data: {
|
|
canonicalName: cleaned,
|
|
},
|
|
});
|
|
}
|
|
|
|
async findDeleted() {
|
|
return this.prisma.product.findMany({
|
|
where: { isActive: false },
|
|
orderBy: { deletedAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
async remove(id: number) {
|
|
await this.findOne(id);
|
|
|
|
return this.prisma.product.update({
|
|
where: { id },
|
|
data: {
|
|
isActive: false,
|
|
deletedAt: new Date(),
|
|
},
|
|
});
|
|
}
|
|
|
|
async permanentDelete(id: number) {
|
|
const product = await this.prisma.product.findUnique({ where: { id } });
|
|
if (!product) {
|
|
throw new NotFoundException(`Product with id ${id} not found`);
|
|
}
|
|
// Ta bort beroenden först
|
|
await this.prisma.productTag.deleteMany({ where: { productId: id } });
|
|
await this.prisma.userProduct.deleteMany({ where: { productId: id } });
|
|
return this.prisma.product.delete({ where: { id } });
|
|
}
|
|
|
|
async restore(id: number) {
|
|
const product = await this.findOne(id);
|
|
|
|
if (product.isActive) {
|
|
return product;
|
|
}
|
|
|
|
return this.prisma.product.update({
|
|
where: { id },
|
|
data: {
|
|
isActive: true,
|
|
deletedAt: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
async merge(sourceProductId: number, targetProductId: number) {
|
|
if (sourceProductId === targetProductId) {
|
|
throw new Error('sourceProductId och targetProductId kan inte vara samma');
|
|
}
|
|
|
|
const source = await this.prisma.product.findUnique({
|
|
where: { id: sourceProductId },
|
|
});
|
|
|
|
if (!source) {
|
|
throw new NotFoundException(
|
|
`Source product with id ${sourceProductId} not found`,
|
|
);
|
|
}
|
|
|
|
const target = await this.prisma.product.findUnique({
|
|
where: { id: targetProductId },
|
|
});
|
|
|
|
if (!target) {
|
|
throw new NotFoundException(
|
|
`Target product with id ${targetProductId} not found`,
|
|
);
|
|
}
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const movedInventoryCount = await tx.inventoryItem.updateMany({
|
|
where: { productId: sourceProductId },
|
|
data: { productId: targetProductId },
|
|
});
|
|
|
|
const softDeletedSource = await tx.product.update({
|
|
where: { id: sourceProductId },
|
|
data: {
|
|
isActive: false,
|
|
deletedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return {
|
|
message: 'Products merged successfully',
|
|
sourceProductId,
|
|
targetProductId,
|
|
movedInventoryCount: movedInventoryCount.count,
|
|
softDeletedSource,
|
|
};
|
|
});
|
|
}
|
|
|
|
async previewMerge(sourceProductId: number, targetProductId: number) {
|
|
if (sourceProductId === targetProductId) {
|
|
throw new Error('sourceProductId och targetProductId kan inte vara samma');
|
|
}
|
|
|
|
const [source, target, sourceInventoryCount, targetInventoryCount] =
|
|
await Promise.all([
|
|
this.prisma.product.findUnique({
|
|
where: { id: sourceProductId },
|
|
}),
|
|
this.prisma.product.findUnique({
|
|
where: { id: targetProductId },
|
|
}),
|
|
this.prisma.inventoryItem.count({
|
|
where: { productId: sourceProductId },
|
|
}),
|
|
this.prisma.inventoryItem.count({
|
|
where: { productId: targetProductId },
|
|
}),
|
|
]);
|
|
|
|
if (!source) {
|
|
throw new NotFoundException(
|
|
`Source product with id ${sourceProductId} not found`,
|
|
);
|
|
}
|
|
|
|
if (!target) {
|
|
throw new NotFoundException(
|
|
`Target product with id ${targetProductId} not found`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
source: {
|
|
...source,
|
|
inventoryCount: sourceInventoryCount,
|
|
},
|
|
target: {
|
|
...target,
|
|
inventoryCount: targetInventoryCount,
|
|
},
|
|
outcome: {
|
|
inventoryItemsToMove: sourceInventoryCount,
|
|
sourceWillBeSoftDeleted: true,
|
|
targetWillRemainActive: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
async backfillCanonicalNames() {
|
|
const products = await this.prisma.product.findMany({
|
|
where: {
|
|
canonicalName: null,
|
|
},
|
|
});
|
|
|
|
const results = await this.prisma.$transaction(
|
|
products.map((product) =>
|
|
this.prisma.product.update({
|
|
where: { id: product.id },
|
|
data: {
|
|
canonicalName: product.name,
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
return {
|
|
message: 'Canonical names backfilled successfully',
|
|
updatedCount: results.length,
|
|
products: results,
|
|
};
|
|
}
|
|
|
|
async setTags(productId: number, tagNames: string[]) {
|
|
await this.findOne(productId);
|
|
|
|
// Skapa taggar som inte finns och hämta ID för alla
|
|
const tags = await this.prisma.$transaction(
|
|
tagNames.map((name) =>
|
|
this.prisma.tag.upsert({
|
|
where: { name },
|
|
create: { name },
|
|
update: {},
|
|
}),
|
|
),
|
|
);
|
|
|
|
// Ersätt alla taggkopplingar för produkten
|
|
await this.prisma.productTag.deleteMany({ where: { productId } });
|
|
|
|
if (tags.length > 0) {
|
|
await this.prisma.productTag.createMany({
|
|
data: tags.map((tag) => ({ productId, tagId: tag.id })),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
|
|
return this.prisma.product.findUnique({
|
|
where: { id: productId },
|
|
include: { tags: { include: { tag: true } }, nutrition: true },
|
|
});
|
|
}
|
|
|
|
async upsertNutrition(productId: number, data: UpsertNutritionDto) {
|
|
await this.findOne(productId);
|
|
|
|
return this.prisma.nutrition.upsert({
|
|
where: { productId },
|
|
create: { productId, ...data },
|
|
update: { ...data },
|
|
});
|
|
}
|
|
|
|
async findAllTags() {
|
|
return this.prisma.tag.findMany({ orderBy: { name: 'asc' } });
|
|
}
|
|
|
|
async resetAll() {
|
|
await this.prisma.$transaction([
|
|
this.prisma.receiptAlias.deleteMany(),
|
|
this.prisma.inventoryConsumption.deleteMany(),
|
|
this.prisma.inventoryItem.deleteMany(),
|
|
this.prisma.pantryItem.deleteMany(),
|
|
this.prisma.nutrition.deleteMany(),
|
|
this.prisma.productTag.deleteMany(),
|
|
this.prisma.tag.deleteMany(),
|
|
this.prisma.userProduct.deleteMany(),
|
|
this.prisma.recipeIngredient.deleteMany(),
|
|
this.prisma.product.deleteMany(),
|
|
]);
|
|
return { ok: true };
|
|
}
|
|
|
|
async bulkUpdate(ids: number[], data: { categoryId?: number | null }) {
|
|
const updateData: Record<string, any> = {};
|
|
if ('categoryId' in data) {
|
|
updateData.categoryId = data.categoryId;
|
|
}
|
|
if (Object.keys(updateData).length === 0) return { updated: 0 };
|
|
await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData });
|
|
return { updated: ids.length };
|
|
}
|
|
|
|
async findUncategorized(): Promise<{ id: number; name: string; canonicalName: string | null }[]> {
|
|
return this.prisma.product.findMany({
|
|
where: { isActive: true, categoryId: null, status: 'active' },
|
|
select: { id: true, name: true, canonicalName: true },
|
|
orderBy: { name: 'asc' },
|
|
});
|
|
}
|
|
|
|
async findPending() {
|
|
return this.prisma.product.findMany({
|
|
where: { status: 'pending' },
|
|
include: {
|
|
categoryRef: { include: { parent: true } },
|
|
owner: { select: { id: true, username: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
async createPending(data: CreateProductDto, userId: number) {
|
|
const name = data.name.trim();
|
|
const normalizedName = normalizeName(name);
|
|
|
|
const existing = await this.prisma.product.findUnique({
|
|
where: { normalizedName },
|
|
});
|
|
|
|
if (existing) {
|
|
// Om produkten redan finns (aktiv), returnera den direkt
|
|
if (existing.isActive && existing.status === 'active') {
|
|
return existing;
|
|
}
|
|
// Om det redan finns ett pending-förslag, returnera det
|
|
if (existing.status === 'pending') {
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
return this.prisma.product.create({
|
|
data: {
|
|
name,
|
|
normalizedName,
|
|
canonicalName: name,
|
|
isActive: false,
|
|
status: 'pending',
|
|
ownerId: userId,
|
|
},
|
|
});
|
|
}
|
|
|
|
setStatus(id: number, status: string) {
|
|
return this.prisma.product.update({ where: { id }, data: { status } });
|
|
}
|
|
} |