diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index a87140b8..619a260d 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { EmailModule } from './email/email.module'; +import { SuppliersModule } from './suppliers/suppliers.module'; import { APP_GUARD } from '@nestjs/core'; import { JwtAuthGuard } from './auth/guards/jwt.guard'; import { AssetDepreciationModule } from './asset-depreciation/asset-depreciation.module'; @@ -33,10 +34,10 @@ import { AssetDepreciationModule } from './asset-depreciation/asset-depreciation : false, }), }), - AuthModule, - UsersModule, - EmailModule, - AssetDepreciationModule, + AuthModule, + UsersModule, + EmailModule, + SuppliersModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/assets/assets.entity.ts b/backend/src/assets/assets.entity.ts new file mode 100644 index 00000000..323f3402 --- /dev/null +++ b/backend/src/assets/assets.entity.ts @@ -0,0 +1,17 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne } from 'typeorm'; +import { Supplier } from '../suppliers/suppliers.entity'; + +@Entity('assets') +export class Asset { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column({ nullable: true }) + description: string; + + @ManyToOne(() => Supplier, supplier => supplier.assets) + supplier: Supplier; +} diff --git a/backend/src/suppliers/dto/assign-asset.dto.ts b/backend/src/suppliers/dto/assign-asset.dto.ts new file mode 100644 index 00000000..7c0874d5 --- /dev/null +++ b/backend/src/suppliers/dto/assign-asset.dto.ts @@ -0,0 +1,9 @@ +import { IsInt } from 'class-validator'; + +export class AssignAssetDto { + @IsInt() + supplierId: number; + + @IsInt() + assetId: number; +} diff --git a/backend/src/suppliers/dto/create-supplier.dto.ts b/backend/src/suppliers/dto/create-supplier.dto.ts new file mode 100644 index 00000000..9a2e0974 --- /dev/null +++ b/backend/src/suppliers/dto/create-supplier.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsOptional, IsEmail, IsNotEmpty } from 'class-validator'; + +export class CreateSupplierDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsOptional() + contactInfo?: string; + + @IsString() + @IsOptional() + address?: string; + + @IsEmail() + @IsNotEmpty() + email: string; + + @IsString() + @IsOptional() + phone?: string; +} diff --git a/backend/src/suppliers/dto/query-supplier.dto.ts b/backend/src/suppliers/dto/query-supplier.dto.ts new file mode 100644 index 00000000..87d201b6 --- /dev/null +++ b/backend/src/suppliers/dto/query-supplier.dto.ts @@ -0,0 +1,17 @@ +import { IsOptional, IsString, IsEmail } from 'class-validator'; + +export class QuerySupplierDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} diff --git a/backend/src/suppliers/dto/update-supplier.dto.ts b/backend/src/suppliers/dto/update-supplier.dto.ts new file mode 100644 index 00000000..40a72182 --- /dev/null +++ b/backend/src/suppliers/dto/update-supplier.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateSupplierDto } from './create-supplier.dto'; + +export class UpdateSupplierDto extends PartialType(CreateSupplierDto) {} diff --git a/backend/src/suppliers/suppliers.controller.spec.ts b/backend/src/suppliers/suppliers.controller.spec.ts new file mode 100644 index 00000000..b8320fda --- /dev/null +++ b/backend/src/suppliers/suppliers.controller.spec.ts @@ -0,0 +1,70 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SuppliersController } from './suppliers.controller'; +import { SuppliersService } from './suppliers.service'; + +const supplier = { id: 1, name: 'Supplier1', email: 's1@email.com', isActive: true, assets: [] }; + +describe('SuppliersController', () => { + let controller: SuppliersController; + let service: SuppliersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SuppliersController], + providers: [ + { + provide: SuppliersService, + useValue: { + create: jest.fn().mockResolvedValue(supplier), + findAll: jest.fn().mockResolvedValue({ data: [supplier], total: 1 }), + findOne: jest.fn().mockResolvedValue(supplier), + update: jest.fn().mockResolvedValue({ ...supplier, name: 'Updated' }), + remove: jest.fn().mockResolvedValue(undefined), + assignAsset: jest.fn().mockResolvedValue(supplier), + unassignAsset: jest.fn().mockResolvedValue(supplier), + toggleStatus: jest.fn().mockResolvedValue({ ...supplier, isActive: false }), + }, + }, + ], + }).compile(); + + controller = module.get(SuppliersController); + service = module.get(SuppliersService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should create a supplier', async () => { + expect(await controller.create({ name: 'Supplier1', email: 's1@email.com' })).toEqual(supplier); + }); + + it('should get all suppliers', async () => { + expect(await controller.findAll({})).toEqual({ data: [supplier], total: 1 }); + }); + + it('should get one supplier', async () => { + expect(await controller.findOne('1')).toEqual(supplier); + }); + + it('should update a supplier', async () => { + expect(await controller.update('1', { name: 'Updated' })).toHaveProperty('name', 'Updated'); + }); + + it('should remove a supplier', async () => { + await expect(controller.remove('1')).resolves.toBeUndefined(); + }); + + it('should assign asset', async () => { + expect(await controller.assignAsset({ supplierId: 1, assetId: 2 })).toEqual(supplier); + }); + + it('should unassign asset', async () => { + expect(await controller.unassignAsset({ supplierId: 1, assetId: 2 })).toEqual(supplier); + }); + + it('should toggle status', async () => { + expect(await controller.toggleStatus('1')).toHaveProperty('isActive', false); + }); +}); diff --git a/backend/src/suppliers/suppliers.controller.ts b/backend/src/suppliers/suppliers.controller.ts new file mode 100644 index 00000000..a3fed9e1 --- /dev/null +++ b/backend/src/suppliers/suppliers.controller.ts @@ -0,0 +1,47 @@ +// ...existing code... +// ...existing code... +import { Controller, Get, Post, Body, Patch, Param, Delete, Query } from '@nestjs/common'; +import { SuppliersService } from './suppliers.service'; +import { CreateSupplierDto } from './dto/create-supplier.dto'; +import { UpdateSupplierDto } from './dto/update-supplier.dto'; +import { QuerySupplierDto } from './dto/query-supplier.dto'; +import { AssignAssetDto } from './dto/assign-asset.dto'; + +@Controller('suppliers') +export class SuppliersController { + constructor(private readonly suppliersService: SuppliersService) {} + + @Post() + create(@Body() createSupplierDto: CreateSupplierDto) { + return this.suppliersService.create(createSupplierDto); + } + + @Get() + findAll(@Query() query: QuerySupplierDto) { + return this.suppliersService.findAll(query); + } + @Post('assign-asset') + assignAsset(@Body() assignAssetDto: AssignAssetDto) { + return this.suppliersService.assignAsset(assignAssetDto.supplierId, assignAssetDto.assetId); + } + + @Post('unassign-asset') + unassignAsset(@Body() assignAssetDto: AssignAssetDto) { + return this.suppliersService.unassignAsset(assignAssetDto.supplierId, assignAssetDto.assetId); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.suppliersService.findOne(+id); + } + + @Patch(':id') + update(@Param('id') id: string, @Body() updateSupplierDto: UpdateSupplierDto) { + return this.suppliersService.update(+id, updateSupplierDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.suppliersService.remove(+id); + } +} diff --git a/backend/src/suppliers/suppliers.entity.ts b/backend/src/suppliers/suppliers.entity.ts new file mode 100644 index 00000000..1a271d9e --- /dev/null +++ b/backend/src/suppliers/suppliers.entity.ts @@ -0,0 +1,38 @@ +import { Entity, PrimaryGeneratedColumn, Column, OneToMany, CreateDateColumn, UpdateDateColumn, DeleteDateColumn } from 'typeorm'; +import { Asset } from '../assets/assets.entity'; + +@Entity('suppliers') +export class Supplier { + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @Column({ nullable: true }) + contactInfo: string; + + @Column({ nullable: true }) + address: string; + + @Column({ unique: true }) + email: string; + + @Column({ nullable: true }) + phone: string; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; + + @OneToMany(() => Asset, asset => asset.supplier) + assets: Asset[]; +} diff --git a/backend/src/suppliers/suppliers.module.ts b/backend/src/suppliers/suppliers.module.ts new file mode 100644 index 00000000..a51c9d6c --- /dev/null +++ b/backend/src/suppliers/suppliers.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SuppliersService } from './suppliers.service'; +import { SuppliersController } from './suppliers.controller'; +import { Supplier } from './suppliers.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Supplier])], + controllers: [SuppliersController], + providers: [SuppliersService], +}) +export class SuppliersModule {} diff --git a/backend/src/suppliers/suppliers.service.ts b/backend/src/suppliers/suppliers.service.ts new file mode 100644 index 00000000..6b67aeea --- /dev/null +++ b/backend/src/suppliers/suppliers.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Supplier } from './suppliers.entity'; +import { CreateSupplierDto } from './dto/create-supplier.dto'; +import { UpdateSupplierDto } from './dto/update-supplier.dto'; +import { QuerySupplierDto } from './dto/query-supplier.dto'; +import { Asset } from '../assets/assets.entity'; + +@Injectable() +export class SuppliersService { + constructor( + @InjectRepository(Supplier) + private suppliersRepository: Repository, + ) {} + + create(createSupplierDto: CreateSupplierDto): Promise { + const supplier = this.suppliersRepository.create(createSupplierDto); + return this.suppliersRepository.save(supplier); + } + + async findAll(query: QuerySupplierDto): Promise<{ data: Supplier[]; total: number }> { + const { name, email, page = 1, limit = 10 } = query; + const qb = this.suppliersRepository.createQueryBuilder('supplier').leftJoinAndSelect('supplier.assets', 'asset'); + if (name) qb.andWhere('supplier.name ILIKE :name', { name: `%${name}%` }); + if (email) qb.andWhere('supplier.email = :email', { email }); + const [data, total] = await qb.skip((page - 1) * limit).take(limit).getManyAndCount(); + return { data, total }; + } + + async toggleStatus(id: number): Promise { + const supplier = await this.findOne(id); + supplier.isActive = !supplier.isActive; + return this.suppliersRepository.save(supplier); + } + async findOne(id: number): Promise { + const supplier = await this.suppliersRepository.findOne({ where: { id }, relations: ['assets'] }); + if (!supplier) throw new NotFoundException('Supplier not found'); + return supplier; + } + + async update(id: number, updateSupplierDto: UpdateSupplierDto): Promise { + const supplier = await this.findOne(id); + Object.assign(supplier, updateSupplierDto); + return this.suppliersRepository.save(supplier); + } + + async remove(id: number): Promise { + await this.suppliersRepository.softDelete(id); + } + + async assignAsset(supplierId: number, assetId: number): Promise { + const supplier = await this.findOne(supplierId); + const assetRepo = this.suppliersRepository.manager.getRepository(Asset); + const asset = await assetRepo.findOne({ where: { id: assetId } }); + if (!asset) throw new NotFoundException('Asset not found'); + asset.supplier = supplier; + await assetRepo.save(asset); + return this.findOne(supplierId); + } + + async unassignAsset(supplierId: number, assetId: number): Promise { + const supplier = await this.findOne(supplierId); + const assetRepo = this.suppliersRepository.manager.getRepository(Asset); + const asset = await assetRepo.findOne({ where: { id: assetId, supplier: { id: supplierId } } }); + if (!asset) throw new NotFoundException('Asset not found for this supplier'); + asset.supplier = null; + await assetRepo.save(asset); + return this.findOne(supplierId); + } +} diff --git a/backend/src/suppliers/suppliers.spec.ts b/backend/src/suppliers/suppliers.spec.ts new file mode 100644 index 00000000..1bfa65a7 --- /dev/null +++ b/backend/src/suppliers/suppliers.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SuppliersService } from './suppliers.service'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Supplier } from './suppliers.entity'; +import { Repository } from 'typeorm'; + +const supplierArray = [ + { id: 1, name: 'Supplier1', email: 's1@email.com', contactInfo: '', address: '', phone: '', assets: [] }, + { id: 2, name: 'Supplier2', email: 's2@email.com', contactInfo: '', address: '', phone: '', assets: [] }, +]; + +describe('SuppliersService', () => { + let service: SuppliersService; + let repo: Repository; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SuppliersService, + { + provide: getRepositoryToken(Supplier), + useValue: { + find: jest.fn().mockResolvedValue(supplierArray), + findOne: jest.fn().mockImplementation(({ where }) => supplierArray.find(s => s.id === where.id)), + create: jest.fn().mockImplementation(dto => dto), + save: jest.fn().mockImplementation(supplier => ({ ...supplier, id: 3 })), + update: jest.fn(), + delete: jest.fn(), + remove: jest.fn(), + manager: { getRepository: jest.fn() }, + createQueryBuilder: jest.fn().mockReturnValue({ + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue([supplierArray, supplierArray.length]), + }), + }, + }, + ], + }).compile(); + + service = module.get(SuppliersService); + repo = module.get>(getRepositoryToken(Supplier)); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should create a supplier', async () => { + const dto = { name: 'New', email: 'new@email.com' }; + expect(await service.create(dto as any)).toHaveProperty('id'); + }); + + it('should find all suppliers', async () => { + const result = await service.findAll({}); + expect(result.data.length).toBeGreaterThan(0); + }); + + it('should find one supplier', async () => { + expect(await service.findOne(1)).toHaveProperty('id', 1); + }); + + it('should update a supplier', async () => { + jest.spyOn(service, 'findOne').mockResolvedValueOnce(supplierArray[0] as any); + expect(await service.update(1, { name: 'Updated' } as any)).toHaveProperty('name', 'Updated'); + }); + + it('should remove a supplier', async () => { + jest.spyOn(service, 'findOne').mockResolvedValueOnce(supplierArray[0] as any); + await expect(service.remove(1)).resolves.toBeUndefined(); + }); +});