diff --git a/src/modules/inventory/controllers/product.controller.ts b/src/modules/inventory/controllers/product.controller.ts new file mode 100644 index 0000000..e4bb23b --- /dev/null +++ b/src/modules/inventory/controllers/product.controller.ts @@ -0,0 +1,71 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + UseGuards, + HttpCode, + HttpStatus, + Inject, +} from '@nestjs/common'; +import { PRODUCT_SERVICE, IProductService } from '../services/product.service.interface'; +import { Product, ProductWithStockSummary } from '../interfaces/product.interface'; +import { CreateProductDto } from '../dto/create-product.dto'; +import { UpdateProductDto } from '../dto/update-product.dto'; +import { QueryProductDto } from '../dto/query-product.dto'; +import { PaginatedResult } from '../../../shared/interfaces/index'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; + +@Controller('products') +@UseGuards(JwtAuthGuard) +export class ProductController { + constructor( + @Inject(PRODUCT_SERVICE) private readonly productService: IProductService, + ) {} + + @Get() + async findAll(@Query() query: QueryProductDto): Promise> { + return this.productService.findAll(query); + } + + @Get(':id') + async findById(@Param('id') id: string): Promise { + return this.productService.findById(id); + } + + @Get('sku/:sku') + async findBySku(@Param('sku') sku: string): Promise { + return this.productService.findBySku(sku); + } + + @Post() + @UseGuards(RolesGuard) + @Roles('admin', 'manager') + async create(@Body() dto: CreateProductDto): Promise { + return this.productService.create(dto); + } + + @Patch(':id') + @UseGuards(RolesGuard) + @Roles('admin', 'manager') + async update( + @Param('id') id: string, + @Body() dto: UpdateProductDto, + ): Promise { + return this.productService.update(id, dto); + } + + @Delete(':id') + @UseGuards(RolesGuard) + @Roles('admin') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id') id: string): Promise { + return this.productService.softDelete(id); + } +} \ No newline at end of file diff --git a/src/modules/inventory/dto/create-product.dto.ts b/src/modules/inventory/dto/create-product.dto.ts new file mode 100644 index 0000000..dcbe2f6 --- /dev/null +++ b/src/modules/inventory/dto/create-product.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsNotEmpty, IsOptional, IsNumber, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateProductDto { + @IsString() + @IsNotEmpty() + sku!: string; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + category?: string; + + @IsString() + @IsNotEmpty() + unitOfMeasure!: string; + + @IsNumber() + @IsOptional() + @Type(() => Number) + unitPrice?: number; + + @IsNumber() + @IsOptional() + @Type(() => Number) + @Min(0) + reorderPoint?: number; + + @IsString() + @IsOptional() + imageUrl?: string; +} \ No newline at end of file diff --git a/src/modules/inventory/dto/query-product.dto.ts b/src/modules/inventory/dto/query-product.dto.ts new file mode 100644 index 0000000..bc8c5b3 --- /dev/null +++ b/src/modules/inventory/dto/query-product.dto.ts @@ -0,0 +1,38 @@ +import { IsString, IsOptional, IsNumber, IsBoolean, Min, Max, IsIn } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class QueryProductDto { + @IsNumber() + @IsOptional() + @Type(() => Number) + @Min(1) + page?: number = 1; + + @IsNumber() + @IsOptional() + @Type(() => Number) + @Min(1) + @Max(100) + limit?: number = 20; + + @IsString() + @IsOptional() + search?: string; + + @IsString() + @IsOptional() + category?: string; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsString() + @IsOptional() + sortBy?: string; + + @IsString() + @IsOptional() + @IsIn(['asc', 'desc']) + sortOrder?: 'asc' | 'desc'; +} \ No newline at end of file diff --git a/src/modules/inventory/dto/update-product.dto.ts b/src/modules/inventory/dto/update-product.dto.ts new file mode 100644 index 0000000..73ec3f1 --- /dev/null +++ b/src/modules/inventory/dto/update-product.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsOptional, IsNumber, IsBoolean, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateProductDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsString() + @IsOptional() + category?: string; + + @IsString() + @IsOptional() + unitOfMeasure?: string; + + @IsNumber() + @IsOptional() + @Type(() => Number) + unitPrice?: number; + + @IsNumber() + @IsOptional() + @Type(() => Number) + @Min(0) + reorderPoint?: number; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsString() + @IsOptional() + imageUrl?: string; +} \ No newline at end of file diff --git a/src/modules/inventory/inventory.module.ts b/src/modules/inventory/inventory.module.ts index aeca3fa..60779ee 100644 --- a/src/modules/inventory/inventory.module.ts +++ b/src/modules/inventory/inventory.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { ProductController } from './controllers/product.controller'; import { ProductService } from './services/product.service'; import { LocationService } from './services/location.service'; import { MovementService } from './services/movement.service'; @@ -9,15 +10,18 @@ import { LOCATION_SERVICE } from './services/location.service.interface'; import { MOVEMENT_SERVICE } from './services/movement.service.interface'; import { STOCK_SERVICE } from './services/stock.service.interface'; import { ALERT_SERVICE } from './services/alert.service.interface'; +import { PRODUCT_REPOSITORY } from './repositories/product.repository.interface'; +import { FirestoreProductRepository } from './repositories/product.repository'; @Module({ - controllers: [], // Controllers will be added when implemented + controllers: [ProductController], providers: [ { provide: PRODUCT_SERVICE, useClass: ProductService }, { provide: LOCATION_SERVICE, useClass: LocationService }, { provide: MOVEMENT_SERVICE, useClass: MovementService }, { provide: STOCK_SERVICE, useClass: StockService }, { provide: ALERT_SERVICE, useClass: AlertService }, + { provide: PRODUCT_REPOSITORY, useClass: FirestoreProductRepository }, ], exports: [PRODUCT_SERVICE, LOCATION_SERVICE, MOVEMENT_SERVICE, STOCK_SERVICE, ALERT_SERVICE], }) diff --git a/src/modules/inventory/repositories/product.repository.ts b/src/modules/inventory/repositories/product.repository.ts new file mode 100644 index 0000000..065d8a1 --- /dev/null +++ b/src/modules/inventory/repositories/product.repository.ts @@ -0,0 +1,184 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Timestamp } from 'firebase-admin/firestore'; +import { FirebaseService } from '../../../shared/firebase/firebase.service'; +import { IProductRepository, CreateProductDto, UpdateProductDto, QueryProductDto } from './product.repository.interface'; +import { Product } from '../interfaces/product.interface'; +import { PaginatedResult, CategoryCount } from '../../../shared/interfaces/index'; +import { PaginationHelper } from '../../../shared/utils/pagination.helper'; + +const COLLECTION = 'products'; + +@Injectable() +export class FirestoreProductRepository implements IProductRepository { + private readonly logger = new Logger(FirestoreProductRepository.name); + + constructor( + private readonly firebase: FirebaseService, + private readonly paginationHelper: PaginationHelper, + ) {} + + async findById(id: string): Promise { + const doc = await this.firebase.collectionRef(COLLECTION).doc(id).get(); + if (!doc.exists) return null; + return this.documentToProduct(doc.id, doc.data()!); + } + + async findBySku(sku: string): Promise { + const snapshot = await this.firebase + .collectionRef(COLLECTION) + .where('sku', '==', sku) + .limit(1) + .get(); + if (snapshot.empty) return null; + const doc = snapshot.docs[0]; + return this.documentToProduct(doc.id, doc.data()); + } + + async findAll(query: QueryProductDto): Promise> { + let ref: FirebaseFirestore.Query = this.firebase.collectionRef(COLLECTION); + + // Build filters + const filters: Array<{ field: string; operator: FirebaseFirestore.WhereFilterOp; value: unknown }> = []; + + if (query.search) { + // Firestore doesn't support text search natively. + // For simple prefix search, we can use >= and < range queries + const searchLower = query.search.toLowerCase(); + const searchUpper = searchLower.replace(/.$/, (c) => + String.fromCharCode(c.charCodeAt(0) + 1), + ); + // Apply to name field + ref = ref.where('nameLower', '>=', searchLower).where('nameLower', '<', searchUpper); + } + + if (query.category) { + ref = ref.where('category', '==', query.category); + } + + if (query.isActive !== undefined) { + ref = ref.where('isActive', '==', query.isActive); + } + + // Determine sort order + const sortField = query.sortBy || 'createdAt'; + const sortOrder = query.sortOrder === 'asc' ? 'asc' : 'desc'; + ref = ref.orderBy(sortField, sortOrder); + + // Pagination + const { offset, limit } = this.paginationHelper.getPaginationParams(query.page, query.limit); + + // For offset-based pagination, we need to get total count first + const countSnapshot = await this.firebase.collectionRef(COLLECTION).count().get(); + const total = countSnapshot.data().count || 0; + + // Apply pagination + const snapshot = await ref.limit(limit).offset(offset).get(); + + const products = snapshot.docs.map((doc) => + this.documentToProduct(doc.id, doc.data()), + ); + + return this.paginationHelper.buildResult(products, total, query.page || 1, limit); + } + + async create(data: CreateProductDto): Promise { + const docRef = this.firebase.collectionRef(COLLECTION).doc(); + const now = Timestamp.now(); + + const productData: Record = { + sku: data.sku, + name: data.name, + nameLower: data.name.toLowerCase(), + description: data.description || null, + category: data.category || null, + unitOfMeasure: data.unitOfMeasure || 'pcs', + unitPrice: data.unitPrice || null, + reorderPoint: data.reorderPoint ?? 0, + isActive: true, + imageUrl: data.imageUrl || null, + createdAt: now, + updatedAt: now, + }; + + await docRef.set(productData); + return this.documentToProduct(docRef.id, productData); + } + + async update(id: string, data: Partial): Promise { + const updateData: Record = { + updatedAt: Timestamp.now(), + }; + + if (data.name !== undefined) { + updateData.name = data.name; + updateData.nameLower = data.name.toLowerCase(); + } + if (data.description !== undefined) updateData.description = data.description; + if (data.category !== undefined) updateData.category = data.category; + if (data.unitOfMeasure !== undefined) updateData.unitOfMeasure = data.unitOfMeasure; + if (data.unitPrice !== undefined) updateData.unitPrice = data.unitPrice; + if (data.reorderPoint !== undefined) updateData.reorderPoint = data.reorderPoint; + if (data.isActive !== undefined) updateData.isActive = data.isActive; + if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl; + + await this.firebase.collectionRef(COLLECTION).doc(id).update(updateData); + + const updated = await this.findById(id); + if (!updated) throw new Error('Product not found after update'); + return updated; + } + + async softDelete(id: string): Promise { + await this.firebase.collectionRef(COLLECTION).doc(id).update({ + isActive: false, + updatedAt: Timestamp.now(), + }); + } + + async existsBySku(sku: string): Promise { + const snapshot = await this.firebase + .collectionRef(COLLECTION) + .where('sku', '==', sku) + .limit(1) + .get(); + return !snapshot.empty; + } + + async countByCategory(): Promise { + const snapshot = await this.firebase.collectionRef(COLLECTION).get(); + const categoryMap = new Map(); + + snapshot.docs.forEach((doc) => { + const category = doc.data().category || 'uncategorized'; + categoryMap.set(category, (categoryMap.get(category) || 0) + 1); + }); + + return Array.from(categoryMap.entries()).map(([category, count]) => ({ + category, + count, + })); + } + + private documentToProduct(id: string, data: Record): Product { + return { + id, + sku: data.sku as string, + name: data.name as string, + description: (data.description as string) || undefined, + category: (data.category as string) || undefined, + unitOfMeasure: (data.unitOfMeasure as string) || 'pcs', + unitPrice: data.unitPrice != null ? Number(data.unitPrice) : undefined, + reorderPoint: (data.reorderPoint as number) ?? 0, + isActive: data.isActive !== false, + imageUrl: (data.imageUrl as string) || undefined, + createdAt: this.toDate(data.createdAt), + updatedAt: this.toDate(data.updatedAt), + }; + } + + private toDate(value: unknown): Date { + if (value instanceof Timestamp) return value.toDate(); + if (value instanceof Date) return value; + return new Date(value as string); + } +} \ No newline at end of file diff --git a/src/modules/inventory/services/product.service.ts b/src/modules/inventory/services/product.service.ts index a66bfb8..809098d 100644 --- a/src/modules/inventory/services/product.service.ts +++ b/src/modules/inventory/services/product.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject } from '@nestjs/common'; +import { Injectable, NotFoundException, ConflictException, Inject } from '@nestjs/common'; import { IProductService } from './product.service.interface'; import { IProductRepository, PRODUCT_REPOSITORY, QueryProductDto, CreateProductDto, UpdateProductDto } from '../repositories/product.repository.interface'; import { Product, ProductWithStockSummary } from '../interfaces/product.interface'; @@ -16,25 +16,31 @@ export class ProductService implements IProductService { async findById(id: string): Promise { const product = await this.productRepo.findById(id); - if (!product) throw new Error('Product not found'); + if (!product) throw new NotFoundException('Product not found'); return { ...product, totalOnHand: 0, totalCommitted: 0, totalAvailable: 0, locationCount: 0 }; } async findBySku(sku: string): Promise { const product = await this.productRepo.findBySku(sku); - if (!product) throw new Error('Product not found'); + if (!product) throw new NotFoundException('Product not found'); return { ...product, totalOnHand: 0, totalCommitted: 0, totalAvailable: 0, locationCount: 0 }; } async create(dto: CreateProductDto): Promise { + const exists = await this.productRepo.existsBySku(dto.sku); + if (exists) throw new ConflictException('SKU already exists'); return this.productRepo.create(dto); } async update(id: string, dto: UpdateProductDto): Promise { + const product = await this.productRepo.findById(id); + if (!product) throw new NotFoundException('Product not found'); return this.productRepo.update(id, dto); } async softDelete(id: string): Promise { + const product = await this.productRepo.findById(id); + if (!product) throw new NotFoundException('Product not found'); return this.productRepo.softDelete(id); } } \ No newline at end of file diff --git a/tests/product.service.spec.ts b/tests/product.service.spec.ts new file mode 100644 index 0000000..0852065 --- /dev/null +++ b/tests/product.service.spec.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NotFoundException, ConflictException } from '@nestjs/common'; +import { ProductService } from '../src/modules/inventory/services/product.service'; + +describe('ProductService', () => { + let productService: ProductService; + let mockRepo: Record>; + + const mockProduct = { + id: 'prod-123', + sku: 'LAP-001', + name: 'Laptop Pro 15"', + description: 'High performance laptop', + category: 'Electronics', + unitOfMeasure: 'pcs', + unitPrice: 1499.99, + reorderPoint: 5, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeEach(() => { + mockRepo = { + findById: vi.fn(), + findBySku: vi.fn(), + findAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + softDelete: vi.fn(), + existsBySku: vi.fn(), + countByCategory: vi.fn(), + }; + productService = new ProductService(mockRepo as any); + }); + + describe('findAll', () => { + it('should return paginated products', async () => { + const result = { data: [mockProduct], meta: { total: 1, page: 1, limit: 20, totalPages: 1 } }; + mockRepo.findAll.mockResolvedValue(result); + + const res = await productService.findAll({ page: 1, limit: 20 }); + expect(res).toEqual(result); + expect(mockRepo.findAll).toHaveBeenCalledWith({ page: 1, limit: 20 }); + }); + }); + + describe('findById', () => { + it('should return product with stock summary', async () => { + mockRepo.findById.mockResolvedValue(mockProduct); + + const result = await productService.findById('prod-123'); + expect(result.id).toBe('prod-123'); + expect(result.sku).toBe('LAP-001'); + expect(result.totalOnHand).toBe(0); + expect(result.locationCount).toBe(0); + }); + + it('should throw NotFoundException if product not found', async () => { + mockRepo.findById.mockResolvedValue(null); + await expect(productService.findById('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findBySku', () => { + it('should return product when found by SKU', async () => { + mockRepo.findBySku.mockResolvedValue(mockProduct); + + const result = await productService.findBySku('LAP-001'); + expect(result.id).toBe('prod-123'); + }); + + it('should throw NotFoundException if sku not found', async () => { + mockRepo.findBySku.mockResolvedValue(null); + await expect(productService.findBySku('NONEXISTENT')).rejects.toThrow(NotFoundException); + }); + }); + + describe('create', () => { + it('should create a new product', async () => { + const dto = { + sku: 'LAP-001', + name: 'Laptop Pro 15"', + unitOfMeasure: 'pcs', + category: 'Electronics', + unitPrice: 1499.99, + reorderPoint: 5, + }; + mockRepo.existsBySku.mockResolvedValue(false); + mockRepo.create.mockResolvedValue(mockProduct); + + const result = await productService.create(dto); + expect(result).toEqual(mockProduct); + }); + + it('should throw ConflictException if SKU already exists', async () => { + mockRepo.existsBySku.mockResolvedValue(true); + + await expect( + productService.create({ sku: 'LAP-001', name: 'Test', unitOfMeasure: 'pcs' }), + ).rejects.toThrow(ConflictException); + }); + }); + + describe('update', () => { + it('should update existing product', async () => { + const updates = { name: 'Updated Laptop', unitPrice: 1299.99 }; + const updated = { ...mockProduct, ...updates }; + mockRepo.findById.mockResolvedValue(mockProduct); + mockRepo.update.mockResolvedValue(updated); + + const result = await productService.update('prod-123', updates); + expect(result.name).toBe('Updated Laptop'); + expect(result.unitPrice).toBe(1299.99); + }); + + it('should throw NotFoundException if product not found', async () => { + mockRepo.findById.mockResolvedValue(null); + await expect(productService.update('nonexistent', { name: 'Test' })).rejects.toThrow(NotFoundException); + }); + }); + + describe('softDelete', () => { + it('should soft delete existing product', async () => { + mockRepo.findById.mockResolvedValue(mockProduct); + mockRepo.softDelete.mockResolvedValue(undefined); + + await productService.softDelete('prod-123'); + expect(mockRepo.softDelete).toHaveBeenCalledWith('prod-123'); + }); + + it('should throw NotFoundException if product not found', async () => { + mockRepo.findById.mockResolvedValue(null); + await expect(productService.softDelete('nonexistent')).rejects.toThrow(NotFoundException); + }); + }); +}); \ No newline at end of file