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
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user