feat: Add bulk delete and merge functionality for inventory items with DTOs and API endpoints
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 09:36:15 +02:00
parent 8e6e0e96b8
commit d4a7983afb
9 changed files with 591 additions and 19 deletions
@@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsInt, Min } from 'class-validator';
export class BulkDeleteInventoryDto {
@IsArray()
@ArrayMinSize(1)
@Type(() => Number)
@IsInt({ each: true })
@Min(1, { each: true })
ids!: number[];
}
@@ -0,0 +1,15 @@
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsInt, IsOptional, IsString, Min } from 'class-validator';
export class MergeManyInventoryDto {
@IsArray()
@ArrayMinSize(2)
@Type(() => Number)
@IsInt({ each: true })
@Min(1, { each: true })
ids!: number[];
@IsOptional()
@IsString()
targetUnit?: string;
}
@@ -18,6 +18,8 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator';
import { MergeInventoryDto } from './dto/merge-inventory.dto';
import { CreateAdminInventoryDto } from './dto/create-admin-inventory.dto';
import { BulkDeleteInventoryDto } from './dto/bulk-delete-inventory.dto';
import { MergeManyInventoryDto } from './dto/merge-many-inventory.dto';
@Controller('inventory')
export class InventoryController {
@@ -97,6 +99,22 @@ export class InventoryController {
targetInventoryId,
);
}
@Post('merge-many')
mergeMany(
@CurrentUser() user: { userId: number },
@Body() body: MergeManyInventoryDto,
) {
return this.inventoryService.mergeMany(user.userId, body.ids, body.targetUnit);
}
@Post('bulk-delete')
bulkDelete(
@CurrentUser() user: { userId: number },
@Body() body: BulkDeleteInventoryDto,
) {
return this.inventoryService.bulkDelete(user.userId, body.ids);
}
@Post(':id/consume')
consume(
+127
View File
@@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateInventoryDto } from './dto/create-inventory.dto';
import { UpdateInventoryDto } from './dto/update-inventory.dto';
import { convertUnit, normalizeUnit } from '../common/utils/units';
type InventoryQuery = {
location?: string;
@@ -348,6 +349,132 @@ export class InventoryService {
return this.prisma.inventoryItem.delete({ where: { id } });
}
async bulkDelete(userId: number, ids: number[]) {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length === 0) {
throw new BadRequestException('No inventory ids supplied');
}
const items = await this.prisma.inventoryItem.findMany({
where: { id: { in: uniqueIds }, userId },
select: { id: true },
});
if (items.length !== uniqueIds.length) {
throw new ForbiddenException('One or more inventory items are missing or do not belong to current user');
}
const result = await this.prisma.inventoryItem.deleteMany({
where: { id: { in: uniqueIds }, userId },
});
return { deletedCount: result.count };
}
async mergeMany(userId: number, ids: number[], targetUnit?: string) {
const uniqueIds = [...new Set(ids)];
if (uniqueIds.length < 2) {
throw new BadRequestException('At least two inventory items are required to merge');
}
const items = await this.prisma.inventoryItem.findMany({
where: { id: { in: uniqueIds }, userId },
include: {
product: this.productWithCategoryInclude,
},
orderBy: { createdAt: 'asc' },
});
if (items.length !== uniqueIds.length) {
throw new ForbiddenException('One or more inventory items are missing or do not belong to current user');
}
const firstProductId = items[0].productId;
if (items.some((item) => item.productId !== firstProductId)) {
throw new BadRequestException('Selected inventory items must belong to the same product');
}
const normalizedUnits = new Set(items.map((item) => normalizeUnit(item.unit)));
const resolvedTargetUnitRaw = targetUnit?.trim();
if (!resolvedTargetUnitRaw && normalizedUnits.size > 1) {
throw new BadRequestException('targetUnit is required when merging different units');
}
const resolvedTargetUnit = normalizeUnit(
resolvedTargetUnitRaw && resolvedTargetUnitRaw.length > 0
? resolvedTargetUnitRaw
: items[0].unit,
);
if (!items.some((item) => normalizeUnit(item.unit) === resolvedTargetUnit)) {
throw new BadRequestException('targetUnit must match one of the selected item units');
}
let mergedQuantity = 0;
for (const item of items) {
const quantity = Number(item.quantity);
try {
mergedQuantity += convertUnit(quantity, item.unit, resolvedTargetUnit);
} catch {
throw new BadRequestException(
`Cannot merge item ${item.id}: incompatible unit "${item.unit}" for target unit "${resolvedTargetUnit}"`,
);
}
}
const target =
items.find((item) => normalizeUnit(item.unit) === resolvedTargetUnit) ??
items[0];
const sourceItems = items.filter((item) => item.id !== target.id);
const firstNonNull = <T>(values: (T | null | undefined)[]): T | null => {
for (const value of values) {
if (value !== null && value !== undefined) {
return value;
}
}
return null;
};
return this.prisma.$transaction(async (tx) => {
const updated = await tx.inventoryItem.update({
where: { id: target.id },
data: {
quantity: new Prisma.Decimal(mergedQuantity),
unit: resolvedTargetUnit,
location: target.location ?? firstNonNull(sourceItems.map((s) => s.location)),
brand: target.brand ?? firstNonNull(sourceItems.map((s) => s.brand)),
origin: target.origin ?? firstNonNull(sourceItems.map((s) => s.origin)),
receiptName: target.receiptName ?? firstNonNull(sourceItems.map((s) => s.receiptName)),
purchaseDate: target.purchaseDate ?? firstNonNull(sourceItems.map((s) => s.purchaseDate)),
opened: target.opened,
suitableFor: target.suitableFor ?? firstNonNull(sourceItems.map((s) => s.suitableFor)),
bestBeforeDate: target.bestBeforeDate ?? firstNonNull(sourceItems.map((s) => s.bestBeforeDate)),
comment: target.comment ?? firstNonNull(sourceItems.map((s) => s.comment)),
},
include: {
product: this.productWithCategoryInclude,
},
});
const sourceIds = sourceItems.map((item) => item.id);
if (sourceIds.length > 0) {
await tx.inventoryConsumption.updateMany({
where: { inventoryItemId: { in: sourceIds } },
data: { inventoryItemId: target.id },
});
await tx.inventoryItem.deleteMany({
where: { id: { in: sourceIds }, userId },
});
}
return updated;
});
}
private async moveInventoryItemToPantryCore(item: {
id: number;
userId: number;