27d622bfe6
- Added `originCountries` field to `InventoryItem` model for multi-country origin support - Updated `CreateInventoryDto` and `UpdateInventoryDto` with `originCountries` array field - Modified `InventoryService` to handle `originCountries` in create and update operations - Added `origin` field to `FlyerImportItem` response type for consistency - Added `categoryId` field to `ParsedReceiptItem` DTO for improved receipt parsing - Created database migration `20260524_add_origin_countries` for schema changes
674 lines
20 KiB
TypeScript
674 lines
20 KiB
TypeScript
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
|
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
|
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;
|
|
sort?: string;
|
|
};
|
|
|
|
type AdminInventoryQuery = {
|
|
userId?: number;
|
|
sort?: string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class InventoryService {
|
|
constructor(private prisma: PrismaService) {}
|
|
|
|
private readonly productWithCategoryInclude = {
|
|
include: {
|
|
categoryRef: {
|
|
include: {
|
|
parent: {
|
|
include: {
|
|
parent: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
private throwInventoryItemNotFound(id: number): never {
|
|
throw new NotFoundException(`Inventory item with id ${id} not found`);
|
|
}
|
|
|
|
private async findInventoryItemAnyByIdOrThrow(id: number) {
|
|
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
|
if (!existing) {
|
|
this.throwInventoryItemNotFound(id);
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
private async findInventoryItemByIdOrThrow(id: number, userId: number) {
|
|
const existing = await this.prisma.inventoryItem.findUnique({ where: { id } });
|
|
if (!existing) {
|
|
this.throwInventoryItemNotFound(id);
|
|
}
|
|
if (existing.userId !== userId) {
|
|
throw new ForbiddenException(`Inventory item with id ${id} does not belong to current user`);
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
private async ensureProductExists(productId: number, userId: number) {
|
|
const product = await this.prisma.product.findFirst({ where: { id: productId, ownerId: userId } });
|
|
if (!product) {
|
|
throw new NotFoundException('Product not found for current user');
|
|
}
|
|
return product;
|
|
}
|
|
|
|
private async ensureProductExistsAny(productId: number) {
|
|
const product = await this.prisma.product.findUnique({ where: { id: productId } });
|
|
if (!product) {
|
|
throw new NotFoundException('Product not found');
|
|
}
|
|
return product;
|
|
}
|
|
|
|
private async ensureUserExists(userId: number) {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { id: true },
|
|
});
|
|
if (!user) {
|
|
throw new NotFoundException(`User with id ${userId} not found`);
|
|
}
|
|
}
|
|
|
|
private buildCreateData(userId: number, data: CreateInventoryDto): Prisma.InventoryItemUncheckedCreateInput {
|
|
return {
|
|
...data,
|
|
userId,
|
|
quantity: new Prisma.Decimal(data.quantity),
|
|
location: data.location?.trim() || undefined,
|
|
brand: data.brand?.trim() || undefined,
|
|
origin: data.origin?.trim() || undefined,
|
|
originCountries: data.originCountries || undefined,
|
|
receiptName: data.receiptName?.trim() || undefined,
|
|
suitableFor: data.suitableFor?.trim() || undefined,
|
|
comment: data.comment?.trim() || undefined,
|
|
purchaseDate: data.purchaseDate
|
|
? new Date(data.purchaseDate)
|
|
: undefined,
|
|
bestBeforeDate: data.bestBeforeDate
|
|
? new Date(data.bestBeforeDate)
|
|
: undefined,
|
|
};
|
|
}
|
|
|
|
private buildUpdateData(data: UpdateInventoryDto): Prisma.InventoryItemUpdateInput {
|
|
const updateData: Prisma.InventoryItemUpdateInput = {};
|
|
|
|
if (typeof data.productId === 'number') {
|
|
updateData.product = {
|
|
connect: { id: data.productId },
|
|
};
|
|
}
|
|
|
|
if (typeof data.quantity === 'number') {
|
|
updateData.quantity = new Prisma.Decimal(data.quantity);
|
|
}
|
|
|
|
if (typeof data.unit === 'string') {
|
|
updateData.unit = data.unit.trim();
|
|
}
|
|
|
|
if (typeof data.location === 'string') {
|
|
updateData.location = data.location.trim();
|
|
}
|
|
|
|
if (typeof data.brand === 'string') {
|
|
updateData.brand = data.brand.trim();
|
|
}
|
|
|
|
if (typeof data.origin === 'string') {
|
|
updateData.origin = data.origin.trim();
|
|
}
|
|
|
|
if (Array.isArray(data.originCountries)) {
|
|
updateData.originCountries = data.originCountries;
|
|
}
|
|
|
|
if (typeof data.receiptName === 'string') {
|
|
updateData.receiptName = data.receiptName.trim();
|
|
}
|
|
|
|
if (typeof data.purchaseDate === 'string') {
|
|
updateData.purchaseDate = data.purchaseDate
|
|
? new Date(data.purchaseDate)
|
|
: null;
|
|
}
|
|
|
|
if (typeof data.bestBeforeDate === 'string') {
|
|
updateData.bestBeforeDate = data.bestBeforeDate
|
|
? new Date(data.bestBeforeDate)
|
|
: null;
|
|
}
|
|
|
|
if (typeof data.opened === 'boolean') {
|
|
updateData.opened = data.opened;
|
|
}
|
|
|
|
if (typeof data.suitableFor === 'string') {
|
|
updateData.suitableFor = data.suitableFor.trim();
|
|
}
|
|
|
|
if (typeof data.comment === 'string') {
|
|
updateData.comment = data.comment.trim();
|
|
}
|
|
|
|
return updateData;
|
|
}
|
|
|
|
async findAll(userId: number, query?: InventoryQuery) {
|
|
const where: Prisma.InventoryItemWhereInput = { userId };
|
|
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
|
|
|
if (query?.location) {
|
|
where.location = query.location;
|
|
}
|
|
|
|
if (query?.sort === 'bestBeforeAsc') {
|
|
orderBy.push({ bestBeforeDate: 'asc' });
|
|
} else if (query?.sort === 'bestBeforeDesc') {
|
|
orderBy.push({ bestBeforeDate: 'desc' });
|
|
} else if (query?.sort === 'nameAsc') {
|
|
orderBy.push({ product: { name: 'asc' } } as any);
|
|
} else if (query?.sort === 'purchaseDateAsc') {
|
|
orderBy.push({ purchaseDate: 'asc' });
|
|
} else if (query?.sort === 'purchaseDateDesc') {
|
|
orderBy.push({ purchaseDate: 'desc' });
|
|
} else {
|
|
orderBy.push({ createdAt: 'desc' });
|
|
}
|
|
|
|
return this.prisma.inventoryItem.findMany({
|
|
where,
|
|
include: {
|
|
product: this.productWithCategoryInclude,
|
|
},
|
|
orderBy,
|
|
});
|
|
}
|
|
|
|
async findAllAdmin(query?: AdminInventoryQuery) {
|
|
const where: Prisma.InventoryItemWhereInput = {};
|
|
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
|
|
|
|
if (typeof query?.userId === 'number' && Number.isFinite(query.userId)) {
|
|
where.userId = query.userId;
|
|
}
|
|
|
|
if (query?.sort === 'nameAsc') {
|
|
orderBy.push({ product: { name: 'asc' } } as any);
|
|
} else if (query?.sort === 'nameDesc') {
|
|
orderBy.push({ product: { name: 'desc' } } as any);
|
|
} else if (query?.sort === 'quantityDesc') {
|
|
orderBy.push({ quantity: 'desc' });
|
|
} else if (query?.sort === 'quantityAsc') {
|
|
orderBy.push({ quantity: 'asc' });
|
|
} else {
|
|
orderBy.push({ createdAt: 'desc' });
|
|
}
|
|
|
|
return this.prisma.inventoryItem.findMany({
|
|
where,
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
email: true,
|
|
},
|
|
},
|
|
product: this.productWithCategoryInclude,
|
|
},
|
|
orderBy,
|
|
});
|
|
}
|
|
|
|
async consume(id: number, userId: number, data: ConsumeInventoryDto) {
|
|
const existing = await this.findInventoryItemByIdOrThrow(id, userId);
|
|
|
|
const currentQuantity = Number(existing.quantity);
|
|
const newQuantity = Math.max(0, currentQuantity - data.amountUsed);
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const updatedItem = await tx.inventoryItem.update({
|
|
where: { id },
|
|
data: {
|
|
quantity: new Prisma.Decimal(newQuantity),
|
|
},
|
|
include: {
|
|
product: this.productWithCategoryInclude,
|
|
},
|
|
});
|
|
|
|
await tx.inventoryConsumption.create({
|
|
data: {
|
|
inventoryItemId: id,
|
|
amountUsed: new Prisma.Decimal(data.amountUsed),
|
|
comment: data.comment?.trim() || null,
|
|
},
|
|
});
|
|
|
|
return updatedItem;
|
|
});
|
|
}
|
|
|
|
async findConsumptionHistory(id: number, userId: number) {
|
|
await this.findInventoryItemByIdOrThrow(id, userId);
|
|
|
|
return this.prisma.inventoryConsumption.findMany({
|
|
where: {
|
|
inventoryItemId: id,
|
|
},
|
|
select: {
|
|
id: true,
|
|
inventoryItemId: true,
|
|
amountUsed: true,
|
|
comment: true,
|
|
createdAt: true,
|
|
inventoryItem: {
|
|
select: { unit: true },
|
|
},
|
|
},
|
|
orderBy: {
|
|
createdAt: 'desc',
|
|
},
|
|
});
|
|
}
|
|
async findExpiring(userId: number) {
|
|
const now = new Date();
|
|
|
|
return this.prisma.inventoryItem.findMany({
|
|
where: {
|
|
userId,
|
|
bestBeforeDate: {
|
|
not: null,
|
|
gte: now,
|
|
},
|
|
},
|
|
include: {
|
|
product: true,
|
|
},
|
|
orderBy: [{ bestBeforeDate: 'asc' }, { createdAt: 'desc' }],
|
|
});
|
|
}
|
|
|
|
async create(userId: number, data: CreateInventoryDto) {
|
|
await this.ensureProductExists(data.productId, userId);
|
|
|
|
return this.prisma.inventoryItem.create({
|
|
data: this.buildCreateData(userId, data),
|
|
include: {
|
|
product: this.productWithCategoryInclude,
|
|
},
|
|
});
|
|
}
|
|
|
|
async createAdmin(adminUserId: number, data: CreateInventoryDto, targetUserId?: number) {
|
|
const effectiveUserId = typeof targetUserId === 'number' ? targetUserId : adminUserId;
|
|
await this.ensureUserExists(effectiveUserId);
|
|
await this.ensureProductExistsAny(data.productId);
|
|
|
|
return this.prisma.inventoryItem.create({
|
|
data: this.buildCreateData(effectiveUserId, data),
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
email: true,
|
|
},
|
|
},
|
|
product: this.productWithCategoryInclude,
|
|
},
|
|
});
|
|
}
|
|
|
|
async update(id: number, userId: number, data: UpdateInventoryDto) {
|
|
await this.findInventoryItemByIdOrThrow(id, userId);
|
|
|
|
if (typeof data.productId === 'number') {
|
|
await this.ensureProductExists(data.productId, userId);
|
|
}
|
|
|
|
const updateData = this.buildUpdateData(data);
|
|
|
|
return this.prisma.inventoryItem.update({
|
|
where: { id },
|
|
data: updateData,
|
|
include: {
|
|
product: this.productWithCategoryInclude,
|
|
},
|
|
});
|
|
}
|
|
|
|
async remove(id: number, userId: number) {
|
|
await this.findInventoryItemByIdOrThrow(id, userId);
|
|
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;
|
|
productId: number;
|
|
location: string | null;
|
|
}) {
|
|
const existingPantryItem = await this.prisma.pantryItem.findUnique({
|
|
where: {
|
|
userId_productId: {
|
|
userId: item.userId,
|
|
productId: item.productId,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (existingPantryItem) {
|
|
throw new BadRequestException('Produkten finns redan i baslagret');
|
|
}
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const pantryItem = await tx.pantryItem.create({
|
|
data: {
|
|
userId: item.userId,
|
|
productId: item.productId,
|
|
location: item.location?.trim() || null,
|
|
},
|
|
include: { product: true },
|
|
});
|
|
|
|
await tx.inventoryItem.delete({ where: { id: item.id } });
|
|
|
|
return pantryItem;
|
|
});
|
|
}
|
|
|
|
async moveToPantry(id: number, userId: number) {
|
|
const item = await this.findInventoryItemByIdOrThrow(id, userId);
|
|
return this.moveInventoryItemToPantryCore(item);
|
|
}
|
|
|
|
async moveToPantryAdmin(id: number) {
|
|
const item = await this.findInventoryItemAnyByIdOrThrow(id);
|
|
return this.moveInventoryItemToPantryCore(item);
|
|
}
|
|
|
|
async updateAdmin(id: number, data: UpdateInventoryDto) {
|
|
await this.findInventoryItemAnyByIdOrThrow(id);
|
|
|
|
if (typeof data.productId === 'number') {
|
|
await this.ensureProductExistsAny(data.productId);
|
|
}
|
|
|
|
const updateData = this.buildUpdateData(data);
|
|
|
|
return this.prisma.inventoryItem.update({
|
|
where: { id },
|
|
data: updateData,
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
email: true,
|
|
},
|
|
},
|
|
product: this.productWithCategoryInclude,
|
|
},
|
|
});
|
|
}
|
|
|
|
async removeAdmin(id: number) {
|
|
await this.findInventoryItemAnyByIdOrThrow(id);
|
|
return this.prisma.inventoryItem.delete({ where: { id } });
|
|
}
|
|
|
|
private validateAdminMergeEligibility(
|
|
source: { id: number; userId: number; productId: number; unit: string },
|
|
target: { id: number; userId: number; productId: number; unit: string },
|
|
): string | null {
|
|
if (source.userId !== target.userId) {
|
|
return 'Cannot merge inventory items from different users';
|
|
}
|
|
|
|
if (source.productId !== target.productId) {
|
|
return 'Cannot merge inventory items with different products';
|
|
}
|
|
|
|
if (source.unit.trim().toLowerCase() !== target.unit.trim().toLowerCase()) {
|
|
return 'Cannot merge inventory items with different units';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async previewMergeAdmin(sourceInventoryId: number, targetInventoryId: number) {
|
|
if (sourceInventoryId === targetInventoryId) {
|
|
return {
|
|
canMerge: false,
|
|
reason: 'sourceInventoryId and targetInventoryId cannot be the same',
|
|
};
|
|
}
|
|
|
|
const [source, target] = await Promise.all([
|
|
this.findInventoryItemAnyByIdOrThrow(sourceInventoryId),
|
|
this.findInventoryItemAnyByIdOrThrow(targetInventoryId),
|
|
]);
|
|
|
|
const reason = this.validateAdminMergeEligibility(source, target);
|
|
const mergedQuantity = Number(source.quantity) + Number(target.quantity);
|
|
|
|
return {
|
|
canMerge: reason == null,
|
|
reason,
|
|
source: {
|
|
id: source.id,
|
|
userId: source.userId,
|
|
productId: source.productId,
|
|
quantity: Number(source.quantity),
|
|
unit: source.unit,
|
|
},
|
|
target: {
|
|
id: target.id,
|
|
userId: target.userId,
|
|
productId: target.productId,
|
|
quantity: Number(target.quantity),
|
|
unit: target.unit,
|
|
},
|
|
outcome: {
|
|
mergedQuantity,
|
|
mergedUnit: target.unit,
|
|
},
|
|
};
|
|
}
|
|
|
|
async mergeAdmin(sourceInventoryId: number, targetInventoryId: number) {
|
|
if (sourceInventoryId === targetInventoryId) {
|
|
throw new BadRequestException('sourceInventoryId and targetInventoryId cannot be the same');
|
|
}
|
|
|
|
const [source, target] = await Promise.all([
|
|
this.findInventoryItemAnyByIdOrThrow(sourceInventoryId),
|
|
this.findInventoryItemAnyByIdOrThrow(targetInventoryId),
|
|
]);
|
|
|
|
const reason = this.validateAdminMergeEligibility(source, target);
|
|
if (reason) {
|
|
throw new BadRequestException(reason);
|
|
}
|
|
|
|
const mergedQuantity = Number(source.quantity) + Number(target.quantity);
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const updated = await tx.inventoryItem.update({
|
|
where: { id: target.id },
|
|
data: {
|
|
quantity: new Prisma.Decimal(mergedQuantity),
|
|
location: target.location ?? source.location,
|
|
brand: target.brand ?? source.brand,
|
|
origin: target.origin ?? source.origin,
|
|
receiptName: target.receiptName ?? source.receiptName,
|
|
purchaseDate: target.purchaseDate ?? source.purchaseDate,
|
|
opened: target.opened ?? source.opened,
|
|
suitableFor: target.suitableFor ?? source.suitableFor,
|
|
bestBeforeDate: target.bestBeforeDate ?? source.bestBeforeDate,
|
|
comment: target.comment ?? source.comment,
|
|
},
|
|
include: {
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
username: true,
|
|
email: true,
|
|
},
|
|
},
|
|
product: this.productWithCategoryInclude,
|
|
},
|
|
});
|
|
|
|
await tx.inventoryConsumption.updateMany({
|
|
where: { inventoryItemId: source.id },
|
|
data: { inventoryItemId: target.id },
|
|
});
|
|
|
|
await tx.inventoryItem.delete({ where: { id: source.id } });
|
|
return updated;
|
|
});
|
|
}
|
|
} |