diff --git a/backend/src/asset-depreciation/README.md b/backend/src/asset-depreciation/README.md new file mode 100644 index 0000000..c1dafaf --- /dev/null +++ b/backend/src/asset-depreciation/README.md @@ -0,0 +1,165 @@ +# Asset Depreciation Module + +This module implements asset depreciation functionality using straight-line depreciation method to help companies estimate the current value of their assets. + +## Features + +- **Straight-line Depreciation**: Calculates depreciation using the formula: `(Purchase Price - Salvage Value) / Useful Life Years` +- **Automatic Calculation**: Current depreciated values are calculated automatically based on time elapsed +- **Comprehensive API**: Full CRUD operations plus specialized endpoints for depreciation data +- **Validation**: Input validation for purchase dates, salvage values, and useful life +- **Filtering**: Advanced filtering options for retrieving assets by depreciation status, method, and value ranges + +## API Endpoints + +### Basic CRUD Operations + +- `POST /asset-depreciation` - Create a new asset depreciation record +- `GET /asset-depreciation` - Get all asset depreciation records with optional filters +- `GET /asset-depreciation/:id` - Get a specific asset by ID +- `PATCH /asset-depreciation/:id` - Update an asset depreciation record +- `DELETE /asset-depreciation/:id` - Delete an asset depreciation record + +### Specialized Depreciation Endpoints + +- `GET /asset-depreciation/current-values` - Get current depreciated values for all assets +- `GET /asset-depreciation/:id/current-value` - Get current depreciated value for a specific asset +- `GET /asset-depreciation/summary` - Get depreciation summary statistics +- `GET /asset-depreciation/fully-depreciated` - Get all fully depreciated assets +- `GET /asset-depreciation/nearing-end-of-life?threshold=1` - Get assets nearing end of useful life +- `GET /asset-depreciation/:id/projected-value?date=2025-12-31` - Get projected value at a future date + +## Data Transfer Objects (DTOs) + +### CreateAssetDepreciationDto +```typescript +{ + assetName: string; // Required, max 255 chars + description?: string; // Optional description + purchasePrice: number; // Required, positive number with max 2 decimal places + purchaseDate: string; // Required, ISO date string (YYYY-MM-DD) + usefulLifeYears: number; // Required, 1-100 years + depreciationMethod?: DepreciationMethod; // Optional, defaults to STRAIGHT_LINE + salvageValue?: number; // Optional, must be less than purchase price +} +``` + +### UpdateAssetDepreciationDto +All fields are optional versions of CreateAssetDepreciationDto fields. + +### DepreciatedValueResponseDto +```typescript +{ + id: number; + assetName: string; + description?: string; + purchasePrice: number; + purchaseDate: string; + usefulLifeYears: number; + depreciationMethod: DepreciationMethod; + salvageValue?: number; + currentDepreciatedValue: number; // Calculated field + annualDepreciation: number; // Calculated field + totalDepreciationToDate: number; // Calculated field + remainingUsefulLife: number; // Calculated field + isFullyDepreciated: boolean; // Calculated field + createdAt: Date; + updatedAt: Date; +} +``` + +## Entity Methods + +The `AssetDepreciation` entity provides several calculation methods: + +- `getCurrentDepreciatedValue()`: Returns current value after depreciation +- `getAnnualDepreciation()`: Returns annual depreciation amount +- `getTotalDepreciationToDate()`: Returns total depreciation to current date +- `getRemainingUsefulLife()`: Returns remaining years of useful life +- `isFullyDepreciated()`: Returns true if asset is fully depreciated + +## Usage Examples + +### Creating an Asset +```typescript +POST /asset-depreciation +{ + "assetName": "Dell Laptop", + "description": "Development laptop for engineering team", + "purchasePrice": 15000, + "purchaseDate": "2023-01-01", + "usefulLifeYears": 5, + "salvageValue": 2000 +} +``` + +### Getting Current Depreciated Value +```typescript +GET /asset-depreciation/1/current-value + +Response: +{ + "id": 1, + "assetName": "Dell Laptop", + "purchasePrice": 15000, + "currentDepreciatedValue": 12400, + "annualDepreciation": 2600, + "totalDepreciationToDate": 2600, + "remainingUsefulLife": 4, + "isFullyDepreciated": false, + // ... other fields +} +``` + +### Getting Assets with Filters +```typescript +GET /asset-depreciation?isFullyDepreciated=false&minValue=10000&maxValue=50000 +``` + +### Getting Summary Statistics +```typescript +GET /asset-depreciation/summary + +Response: +{ + "totalAssets": 10, + "totalPurchaseValue": 150000, + "totalCurrentValue": 85000, + "totalDepreciation": 65000, + "fullyDepreciatedAssets": 2, + "averageAge": 2.5 +} +``` + +## Validation Rules + +- Purchase date cannot be in the future +- Salvage value must be less than purchase price +- Useful life must be between 1 and 100 years +- Purchase price must be positive +- Asset names must be unique (database constraint) + +## Depreciation Formula + +The straight-line depreciation uses this formula: + +``` +Annual Depreciation = (Purchase Price - Salvage Value) / Useful Life Years +Current Value = Purchase Price - (Annual Depreciation × Years Elapsed) +``` + +The current value will never go below the salvage value, ensuring realistic depreciation calculations. + +## Testing + +Run tests with: +```bash +npm test -- asset-depreciation.service.spec.ts +``` + +The test suite covers: +- CRUD operations with validation +- Depreciation calculations +- Edge cases (fully depreciated assets, zero salvage value) +- Error handling and exceptions +- Service business logic diff --git a/backend/src/asset-depreciation/asset-depreciation.controller.ts b/backend/src/asset-depreciation/asset-depreciation.controller.ts new file mode 100644 index 0000000..966fa04 --- /dev/null +++ b/backend/src/asset-depreciation/asset-depreciation.controller.ts @@ -0,0 +1,196 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseIntPipe, + HttpCode, + HttpStatus, + Query, + ValidationPipe, + BadRequestException, +} from '@nestjs/common'; +import { AssetDepreciationService } from './asset-depreciation.service'; +import { + CreateAssetDepreciationDto, + UpdateAssetDepreciationDto, + DepreciatedValueResponseDto, + AssetDepreciationSummaryDto, +} from './dto/asset-depreciation.dto'; +import { AssetDepreciation } from './entities/asset-depreciation.entity'; + +@Controller('asset-depreciation') +export class AssetDepreciationController { + constructor(private readonly assetDepreciationService: AssetDepreciationService) {} + + /** + * Create a new asset depreciation record + * POST /asset-depreciation + */ + @Post() + @HttpCode(HttpStatus.CREATED) + create( + @Body(ValidationPipe) createAssetDepreciationDto: CreateAssetDepreciationDto, + ): Promise { + return this.assetDepreciationService.create(createAssetDepreciationDto); + } + + /** + * Get all asset depreciation records with optional filtering + * GET /asset-depreciation?isFullyDepreciated=true&depreciationMethod=straight_line&minValue=1000&maxValue=50000 + */ + @Get() + findAll( + @Query('isFullyDepreciated') isFullyDepreciated?: string, + @Query('depreciationMethod') depreciationMethod?: string, + @Query('minValue') minValue?: string, + @Query('maxValue') maxValue?: string, + ): Promise { + const filters: any = {}; + + if (isFullyDepreciated !== undefined) { + if (isFullyDepreciated === 'true') { + filters.isFullyDepreciated = true; + } else if (isFullyDepreciated === 'false') { + filters.isFullyDepreciated = false; + } + } + + if (depreciationMethod) { + filters.depreciationMethod = depreciationMethod; + } + + if (minValue) { + const minVal = parseFloat(minValue); + if (isNaN(minVal) || minVal < 0) { + throw new BadRequestException('minValue must be a positive number'); + } + filters.minValue = minVal; + } + + if (maxValue) { + const maxVal = parseFloat(maxValue); + if (isNaN(maxVal) || maxVal < 0) { + throw new BadRequestException('maxValue must be a positive number'); + } + filters.maxValue = maxVal; + } + + return this.assetDepreciationService.findAll(filters); + } + + /** + * Get depreciation summary statistics + * GET /asset-depreciation/summary + */ + @Get('summary') + getSummary(): Promise { + return this.assetDepreciationService.getSummary(); + } + + /** + * Get all current depreciated values + * GET /asset-depreciation/current-values + */ + @Get('current-values') + getAllCurrentValues(): Promise { + return this.assetDepreciationService.getAllCurrentValues(); + } + + /** + * Get fully depreciated assets + * GET /asset-depreciation/fully-depreciated + */ + @Get('fully-depreciated') + getFullyDepreciatedAssets(): Promise { + return this.assetDepreciationService.getFullyDepreciatedAssets(); + } + + /** + * Get assets nearing end of useful life + * GET /asset-depreciation/nearing-end-of-life?threshold=1 + */ + @Get('nearing-end-of-life') + getAssetsNearingEndOfLife( + @Query('threshold') threshold?: string, + ): Promise { + let thresholdYears = 1; // default + if (threshold) { + const parsedThreshold = parseFloat(threshold); + if (isNaN(parsedThreshold) || parsedThreshold <= 0) { + throw new BadRequestException('Threshold must be a positive number'); + } + thresholdYears = parsedThreshold; + } + return this.assetDepreciationService.getAssetsNearingEndOfLife(thresholdYears); + } + + /** + * Get specific asset depreciation record by ID + * GET /asset-depreciation/:id + */ + @Get(':id') + findOne(@Param('id', ParseIntPipe) id: number): Promise { + return this.assetDepreciationService.findOne(id); + } + + /** + * Get current depreciated value of a specific asset + * GET /asset-depreciation/:id/current-value + */ + @Get(':id/current-value') + getCurrentValue(@Param('id', ParseIntPipe) id: number): Promise { + return this.assetDepreciationService.getCurrentValue(id); + } + + /** + * Get projected value of asset at future date + * GET /asset-depreciation/:id/projected-value?date=2025-12-31 + */ + @Get(':id/projected-value') + async getProjectedValue( + @Param('id', ParseIntPipe) id: number, + @Query('date') date: string, + ): Promise<{ + assetName: string; + currentValue: number; + projectedValue: number; + depreciationBetween: number; + }> { + if (!date) { + throw new BadRequestException('Date query parameter is required (format: YYYY-MM-DD)'); + } + + const futureDate = new Date(date); + if (isNaN(futureDate.getTime())) { + throw new BadRequestException('Invalid date format. Use YYYY-MM-DD'); + } + + return this.assetDepreciationService.getProjectedValue(id, futureDate); + } + + /** + * Update an asset depreciation record + * PATCH /asset-depreciation/:id + */ + @Patch(':id') + update( + @Param('id', ParseIntPipe) id: number, + @Body(ValidationPipe) updateAssetDepreciationDto: UpdateAssetDepreciationDto, + ): Promise { + return this.assetDepreciationService.update(id, updateAssetDepreciationDto); + } + + /** + * Delete an asset depreciation record + * DELETE /asset-depreciation/:id + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.assetDepreciationService.remove(id); + } +} diff --git a/backend/src/asset-depreciation/asset-depreciation.module.ts b/backend/src/asset-depreciation/asset-depreciation.module.ts new file mode 100644 index 0000000..e331820 --- /dev/null +++ b/backend/src/asset-depreciation/asset-depreciation.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetDepreciationService } from './asset-depreciation.service'; +import { AssetDepreciationController } from './asset-depreciation.controller'; +import { AssetDepreciation } from './entities/asset-depreciation.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetDepreciation])], + controllers: [AssetDepreciationController], + providers: [AssetDepreciationService], + exports: [AssetDepreciationService], +}) +export class AssetDepreciationModule {} diff --git a/backend/src/asset-depreciation/asset-depreciation.service.spec.ts b/backend/src/asset-depreciation/asset-depreciation.service.spec.ts new file mode 100644 index 0000000..7b8ac72 --- /dev/null +++ b/backend/src/asset-depreciation/asset-depreciation.service.spec.ts @@ -0,0 +1,381 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { AssetDepreciationService } from './asset-depreciation.service'; +import { AssetDepreciation, DepreciationMethod } from './entities/asset-depreciation.entity'; +import { CreateAssetDepreciationDto, UpdateAssetDepreciationDto } from './dto/asset-depreciation.dto'; + +describe('AssetDepreciationService', () => { + let service: AssetDepreciationService; + let repository: jest.Mocked>; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockQueryBuilder = { + orderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssetDepreciationService, + { + provide: getRepositoryToken(AssetDepreciation), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(AssetDepreciationService); + repository = module.get(getRepositoryToken(AssetDepreciation)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + const createDto: CreateAssetDepreciationDto = { + assetName: 'Test Laptop', + description: 'Dell Laptop for testing', + purchasePrice: 10000, + purchaseDate: '2023-01-01', + usefulLifeYears: 5, + depreciationMethod: DepreciationMethod.STRAIGHT_LINE, + salvageValue: 1000, + }; + + it('should create an asset depreciation record successfully', async () => { + const mockAsset = { + id: 1, + ...createDto, + purchaseDate: new Date('2023-01-01'), + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockRepository.create.mockReturnValue(mockAsset); + mockRepository.save.mockResolvedValue(mockAsset); + + const result = await service.create(createDto); + + expect(mockRepository.create).toHaveBeenCalledWith({ + ...createDto, + purchaseDate: new Date('2023-01-01'), + }); + expect(mockRepository.save).toHaveBeenCalledWith(mockAsset); + expect(result).toEqual(mockAsset); + }); + + it('should throw BadRequestException for future purchase date', async () => { + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + const invalidDto = { + ...createDto, + purchaseDate: futureDate.toISOString().split('T')[0], + }; + + await expect(service.create(invalidDto)).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when salvage value >= purchase price', async () => { + const invalidDto = { + ...createDto, + salvageValue: 10000, // Equal to purchase price + }; + + await expect(service.create(invalidDto)).rejects.toThrow(BadRequestException); + }); + + it('should throw ConflictException on database unique constraint violation', async () => { + const dbError = { code: '23505' }; + mockRepository.create.mockReturnValue(createDto); + mockRepository.save.mockRejectedValue(dbError); + + await expect(service.create(createDto)).rejects.toThrow(ConflictException); + }); + }); + + describe('findAll', () => { + beforeEach(() => { + mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + }); + + it('should return all assets without filters', async () => { + const mockAssets = [ + createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5), + createMockAsset(2, 'Desktop', 8000, '2022-01-01', 4), + ]; + + mockQueryBuilder.getMany.mockResolvedValue(mockAssets); + + const result = await service.findAll(); + + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('asset.createdAt', 'DESC'); + expect(result).toEqual(mockAssets); + }); + + it('should apply depreciation method filter', async () => { + const mockAssets = [createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5)]; + mockQueryBuilder.getMany.mockResolvedValue(mockAssets); + + await service.findAll({ depreciationMethod: 'straight_line' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'asset.depreciationMethod = :method', + { method: 'straight_line' }, + ); + }); + + it('should apply value range filters', async () => { + const mockAssets = [createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5)]; + mockQueryBuilder.getMany.mockResolvedValue(mockAssets); + + await service.findAll({ minValue: 5000, maxValue: 15000 }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'asset.purchasePrice >= :minValue', + { minValue: 5000 }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'asset.purchasePrice <= :maxValue', + { maxValue: 15000 }, + ); + }); + }); + + describe('findOne', () => { + it('should return an asset by ID', async () => { + const mockAsset = createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5); + mockRepository.findOne.mockResolvedValue(mockAsset); + + const result = await service.findOne(1); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(result).toEqual(mockAsset); + }); + + it('should throw NotFoundException when asset not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getCurrentValue', () => { + it('should return depreciated value response DTO', async () => { + const mockAsset = createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5, 1000); + mockRepository.findOne.mockResolvedValue(mockAsset); + + const result = await service.getCurrentValue(1); + + expect(result.id).toBe(1); + expect(result.assetName).toBe('Laptop'); + expect(result.purchasePrice).toBe(10000); + expect(typeof result.currentDepreciatedValue).toBe('number'); + expect(typeof result.annualDepreciation).toBe('number'); + }); + }); + + describe('update', () => { + const updateDto: UpdateAssetDepreciationDto = { + assetName: 'Updated Laptop', + purchasePrice: 12000, + }; + + it('should update an asset successfully', async () => { + const existingAsset = createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5); + const updatedAsset = { ...existingAsset, ...updateDto }; + + mockRepository.findOne.mockResolvedValue(existingAsset); + mockRepository.save.mockResolvedValue(updatedAsset); + + const result = await service.update(1, updateDto); + + expect(result).toEqual(updatedAsset); + }); + + it('should throw BadRequestException for invalid purchase date update', async () => { + const existingAsset = createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5); + const futureDate = new Date(); + futureDate.setFullYear(futureDate.getFullYear() + 1); + const invalidUpdateDto = { + purchaseDate: futureDate.toISOString().split('T')[0], + }; + + mockRepository.findOne.mockResolvedValue(existingAsset); + + await expect(service.update(1, invalidUpdateDto)).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for invalid salvage value update', async () => { + const existingAsset = createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5); + const invalidUpdateDto = { + salvageValue: 15000, // Greater than purchase price + }; + + mockRepository.findOne.mockResolvedValue(existingAsset); + + await expect(service.update(1, invalidUpdateDto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('remove', () => { + it('should remove an asset successfully', async () => { + const mockAsset = createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5); + mockRepository.findOne.mockResolvedValue(mockAsset); + mockRepository.remove.mockResolvedValue(mockAsset); + + await service.remove(1); + + expect(mockRepository.remove).toHaveBeenCalledWith(mockAsset); + }); + + it('should throw NotFoundException when trying to remove non-existent asset', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.remove(999)).rejects.toThrow(NotFoundException); + }); + }); + + describe('getSummary', () => { + it('should return summary statistics', async () => { + const mockAssets = [ + createMockAsset(1, 'Laptop', 10000, '2022-01-01', 5, 1000), + createMockAsset(2, 'Desktop', 8000, '2021-01-01', 4, 500), + ]; + + mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.getMany.mockResolvedValue(mockAssets); + + const result = await service.getSummary(); + + expect(result.totalAssets).toBe(2); + expect(result.totalPurchaseValue).toBe(18000); + expect(typeof result.totalCurrentValue).toBe('number'); + expect(typeof result.averageAge).toBe('number'); + }); + + it('should return empty summary for no assets', async () => { + mockRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const result = await service.getSummary(); + + expect(result.totalAssets).toBe(0); + expect(result.totalPurchaseValue).toBe(0); + expect(result.totalCurrentValue).toBe(0); + }); + }); + + describe('getProjectedValue', () => { + it('should calculate projected value correctly', async () => { + const mockAsset = createMockAsset(1, 'Laptop', 10000, '2020-01-01', 5, 1000); + mockRepository.findOne.mockResolvedValue(mockAsset); + + const futureDate = new Date('2030-01-01'); // Use a more distant future date + const result = await service.getProjectedValue(1, futureDate); + + expect(result.assetName).toBe('Laptop'); + expect(typeof result.currentValue).toBe('number'); + expect(typeof result.projectedValue).toBe('number'); + expect(typeof result.depreciationBetween).toBe('number'); + }); + + it('should throw BadRequestException for past date', async () => { + const mockAsset = createMockAsset(1, 'Laptop', 10000, '2023-01-01', 5, 1000); + mockRepository.findOne.mockResolvedValue(mockAsset); + + const pastDate = new Date('2022-01-01'); + await expect(service.getProjectedValue(1, pastDate)).rejects.toThrow(BadRequestException); + }); + }); + + describe('Depreciation calculations', () => { + it('should calculate annual depreciation correctly with salvage value', () => { + const asset = new AssetDepreciation(); + asset.purchasePrice = 10000; + asset.usefulLifeYears = 5; + asset.salvageValue = 1000; + + const annualDepreciation = asset.getAnnualDepreciation(); + expect(annualDepreciation).toBe(1800); // (10000 - 1000) / 5 + }); + + it('should calculate annual depreciation correctly without salvage value', () => { + const asset = new AssetDepreciation(); + asset.purchasePrice = 10000; + asset.usefulLifeYears = 5; + asset.salvageValue = null; + + const annualDepreciation = asset.getAnnualDepreciation(); + expect(annualDepreciation).toBe(2000); // 10000 / 5 + }); + + it('should calculate current value correctly for recent asset', () => { + const asset = new AssetDepreciation(); + asset.purchasePrice = 10000; + asset.purchaseDate = new Date(); // Today + asset.usefulLifeYears = 5; + asset.salvageValue = 1000; + + const currentValue = asset.getCurrentDepreciatedValue(); + // Should be close to purchase price for very new asset + expect(currentValue).toBeGreaterThan(9800); + expect(currentValue).toBeLessThanOrEqual(10000); + }); + + it('should calculate remaining useful life correctly', () => { + const asset = new AssetDepreciation(); + asset.purchaseDate = new Date(); // Today + asset.usefulLifeYears = 5; + + const remainingLife = asset.getRemainingUsefulLife(); + // Should be close to 5 years for new asset + expect(remainingLife).toBeGreaterThan(4.9); + expect(remainingLife).toBeLessThanOrEqual(5); + }); + + it('should determine if asset is not fully depreciated when new', () => { + const asset = new AssetDepreciation(); + asset.purchasePrice = 10000; + asset.purchaseDate = new Date(); // Today + asset.usefulLifeYears = 5; + asset.salvageValue = 1000; + + expect(asset.isFullyDepreciated()).toBe(false); + }); + }); + + // Helper function to create mock asset + function createMockAsset( + id: number, + name: string, + price: number, + purchaseDate: string, + usefulLife: number, + salvageValue?: number, + ): AssetDepreciation { + const asset = new AssetDepreciation(); + asset.id = id; + asset.assetName = name; + asset.purchasePrice = price; + asset.purchaseDate = new Date(purchaseDate); + asset.usefulLifeYears = usefulLife; + asset.depreciationMethod = DepreciationMethod.STRAIGHT_LINE; + asset.salvageValue = salvageValue || null; + asset.createdAt = new Date(); + asset.updatedAt = new Date(); + return asset; + } +}); diff --git a/backend/src/asset-depreciation/asset-depreciation.service.ts b/backend/src/asset-depreciation/asset-depreciation.service.ts new file mode 100644 index 0000000..e5561e4 --- /dev/null +++ b/backend/src/asset-depreciation/asset-depreciation.service.ts @@ -0,0 +1,276 @@ +import { + Injectable, + NotFoundException, + ConflictException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { AssetDepreciation } from './entities/asset-depreciation.entity'; +import { + CreateAssetDepreciationDto, + UpdateAssetDepreciationDto, + DepreciatedValueResponseDto, + AssetDepreciationSummaryDto, +} from './dto/asset-depreciation.dto'; + +@Injectable() +export class AssetDepreciationService { + constructor( + @InjectRepository(AssetDepreciation) + private readonly assetDepreciationRepository: Repository, + ) {} + + /** + * Create a new asset depreciation record + */ + async create(createAssetDepreciationDto: CreateAssetDepreciationDto): Promise { + // Validate purchase date is not in the future + const purchaseDate = new Date(createAssetDepreciationDto.purchaseDate); + if (purchaseDate > new Date()) { + throw new BadRequestException('Purchase date cannot be in the future'); + } + + // Validate salvage value is not greater than purchase price + if ( + createAssetDepreciationDto.salvageValue && + createAssetDepreciationDto.salvageValue >= createAssetDepreciationDto.purchasePrice + ) { + throw new BadRequestException('Salvage value cannot be greater than or equal to purchase price'); + } + + try { + const assetDepreciation = this.assetDepreciationRepository.create({ + ...createAssetDepreciationDto, + purchaseDate, + }); + + return await this.assetDepreciationRepository.save(assetDepreciation); + } catch (error) { + if (error.code === '23505') { // PostgreSQL unique violation error code + throw new ConflictException('Asset with this name already exists'); + } + throw error; + } + } + + /** + * Find all asset depreciation records with optional filtering + */ + async findAll(filters?: { + isFullyDepreciated?: boolean; + depreciationMethod?: string; + minValue?: number; + maxValue?: number; + }): Promise { + let query = this.assetDepreciationRepository + .createQueryBuilder('asset') + .orderBy('asset.createdAt', 'DESC'); + + // Apply filters if provided + if (filters?.depreciationMethod) { + query = query.andWhere('asset.depreciationMethod = :method', { + method: filters.depreciationMethod, + }); + } + + if (filters?.minValue !== undefined) { + query = query.andWhere('asset.purchasePrice >= :minValue', { + minValue: filters.minValue, + }); + } + + if (filters?.maxValue !== undefined) { + query = query.andWhere('asset.purchasePrice <= :maxValue', { + maxValue: filters.maxValue, + }); + } + + const assets = await query.getMany(); + + // Filter by depreciation status if requested (requires calculation) + if (filters?.isFullyDepreciated !== undefined) { + return assets.filter(asset => + asset.isFullyDepreciated() === filters.isFullyDepreciated + ); + } + + return assets; + } + + /** + * Find one asset depreciation record by ID + */ + async findOne(id: number): Promise { + const assetDepreciation = await this.assetDepreciationRepository.findOne({ + where: { id } as FindOptionsWhere, + }); + + if (!assetDepreciation) { + throw new NotFoundException(`Asset depreciation record with ID ${id} not found`); + } + + return assetDepreciation; + } + + /** + * Get current depreciated value of an asset + */ + async getCurrentValue(id: number): Promise { + const assetDepreciation = await this.findOne(id); + return new DepreciatedValueResponseDto(assetDepreciation); + } + + /** + * Get current depreciated values of all assets + */ + async getAllCurrentValues(): Promise { + const assets = await this.findAll(); + return assets.map(asset => new DepreciatedValueResponseDto(asset)); + } + + /** + * Update an asset depreciation record + */ + async update( + id: number, + updateAssetDepreciationDto: UpdateAssetDepreciationDto, + ): Promise { + const assetDepreciation = await this.findOne(id); + + // Validate purchase date if being updated + if (updateAssetDepreciationDto.purchaseDate) { + const purchaseDate = new Date(updateAssetDepreciationDto.purchaseDate); + if (purchaseDate > new Date()) { + throw new BadRequestException('Purchase date cannot be in the future'); + } + updateAssetDepreciationDto.purchaseDate = purchaseDate.toISOString().split('T')[0]; + } + + // Validate salvage value if being updated + const newPurchasePrice = updateAssetDepreciationDto.purchasePrice || Number(assetDepreciation.purchasePrice); + const newSalvageValue = updateAssetDepreciationDto.salvageValue !== undefined + ? updateAssetDepreciationDto.salvageValue + : assetDepreciation.salvageValue; + + if (newSalvageValue && newSalvageValue >= newPurchasePrice) { + throw new BadRequestException('Salvage value cannot be greater than or equal to purchase price'); + } + + try { + Object.assign(assetDepreciation, updateAssetDepreciationDto); + return await this.assetDepreciationRepository.save(assetDepreciation); + } catch (error) { + if (error.code === '23505') { // PostgreSQL unique violation error code + throw new ConflictException('Asset with this name already exists'); + } + throw error; + } + } + + /** + * Remove an asset depreciation record + */ + async remove(id: number): Promise { + const assetDepreciation = await this.findOne(id); + await this.assetDepreciationRepository.remove(assetDepreciation); + } + + /** + * Get depreciation summary statistics + */ + async getSummary(): Promise { + const assets = await this.findAll(); + + if (assets.length === 0) { + return new AssetDepreciationSummaryDto({ + totalAssets: 0, + totalPurchaseValue: 0, + totalCurrentValue: 0, + totalDepreciation: 0, + fullyDepreciatedAssets: 0, + averageAge: 0, + }); + } + + const totalPurchaseValue = assets.reduce((sum, asset) => sum + Number(asset.purchasePrice), 0); + const totalCurrentValue = assets.reduce((sum, asset) => sum + asset.getCurrentDepreciatedValue(), 0); + const totalDepreciation = totalPurchaseValue - totalCurrentValue; + const fullyDepreciatedAssets = assets.filter(asset => asset.isFullyDepreciated()).length; + + // Calculate average age in years + const currentDate = new Date(); + const totalAgeInYears = assets.reduce((sum, asset) => { + const purchaseDate = new Date(asset.purchaseDate); + const ageInYears = (currentDate.getTime() - purchaseDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + return sum + ageInYears; + }, 0); + const averageAge = totalAgeInYears / assets.length; + + return new AssetDepreciationSummaryDto({ + totalAssets: assets.length, + totalPurchaseValue: Number(totalPurchaseValue.toFixed(2)), + totalCurrentValue: Number(totalCurrentValue.toFixed(2)), + totalDepreciation: Number(totalDepreciation.toFixed(2)), + fullyDepreciatedAssets, + averageAge: Number(averageAge.toFixed(2)), + }); + } + + /** + * Get assets that are fully depreciated + */ + async getFullyDepreciatedAssets(): Promise { + const assets = await this.findAll({ isFullyDepreciated: true }); + return assets.map(asset => new DepreciatedValueResponseDto(asset)); + } + + /** + * Get assets that need attention (e.g., near end of useful life) + */ + async getAssetsNearingEndOfLife(yearsThreshold: number = 1): Promise { + const assets = await this.findAll(); + const assetsNearingEnd = assets.filter(asset => { + const remainingLife = asset.getRemainingUsefulLife(); + return remainingLife > 0 && remainingLife <= yearsThreshold; + }); + + return assetsNearingEnd.map(asset => new DepreciatedValueResponseDto(asset)); + } + + /** + * Calculate projected value at a future date + */ + async getProjectedValue(id: number, futureDate: Date): Promise<{ + assetName: string; + currentValue: number; + projectedValue: number; + depreciationBetween: number; + }> { + const asset = await this.findOne(id); + const currentValue = asset.getCurrentDepreciatedValue(); + + // Calculate years between now and future date + const currentDate = new Date(); + const yearsToFuture = (futureDate.getTime() - currentDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + + if (yearsToFuture <= 0) { + throw new BadRequestException('Future date must be later than current date'); + } + + const annualDepreciation = asset.getAnnualDepreciation(); + const additionalDepreciation = Math.min( + annualDepreciation * yearsToFuture, + currentValue - (asset.salvageValue || 0) + ); + + const projectedValue = Math.max(currentValue - additionalDepreciation, asset.salvageValue || 0); + + return { + assetName: asset.assetName, + currentValue: Number(currentValue.toFixed(2)), + projectedValue: Number(projectedValue.toFixed(2)), + depreciationBetween: Number(additionalDepreciation.toFixed(2)), + }; + } +} diff --git a/backend/src/asset-depreciation/dto/asset-depreciation.dto.ts b/backend/src/asset-depreciation/dto/asset-depreciation.dto.ts new file mode 100644 index 0000000..c316f06 --- /dev/null +++ b/backend/src/asset-depreciation/dto/asset-depreciation.dto.ts @@ -0,0 +1,136 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsNumber, + IsPositive, + IsEnum, + IsDateString, + Min, + Max, + MaxLength, +} from 'class-validator'; +import { Transform } from 'class-transformer'; +import { DepreciationMethod } from '../entities/asset-depreciation.entity'; + +export class CreateAssetDepreciationDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + assetName: string; + + @IsString() + @IsOptional() + description?: string; + + @IsNumber({ maxDecimalPlaces: 2 }) + @IsPositive() + @Transform(({ value }) => parseFloat(value)) + purchasePrice: number; + + @IsDateString() + purchaseDate: string; + + @IsNumber() + @Min(1) + @Max(100) + @Transform(({ value }) => parseInt(value)) + usefulLifeYears: number; + + @IsEnum(DepreciationMethod) + @IsOptional() + depreciationMethod?: DepreciationMethod; + + @IsNumber({ maxDecimalPlaces: 2 }) + @IsOptional() + @Min(0) + @Transform(({ value }) => value ? parseFloat(value) : undefined) + salvageValue?: number; +} + +export class UpdateAssetDepreciationDto { + @IsString() + @IsOptional() + @MaxLength(255) + assetName?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsNumber({ maxDecimalPlaces: 2 }) + @IsPositive() + @IsOptional() + @Transform(({ value }) => value ? parseFloat(value) : undefined) + purchasePrice?: number; + + @IsDateString() + @IsOptional() + purchaseDate?: string; + + @IsNumber() + @Min(1) + @Max(100) + @IsOptional() + @Transform(({ value }) => value ? parseInt(value) : undefined) + usefulLifeYears?: number; + + @IsEnum(DepreciationMethod) + @IsOptional() + depreciationMethod?: DepreciationMethod; + + @IsNumber({ maxDecimalPlaces: 2 }) + @IsOptional() + @Min(0) + @Transform(({ value }) => value ? parseFloat(value) : undefined) + salvageValue?: number; +} + +export class DepreciatedValueResponseDto { + id: number; + assetName: string; + description?: string; + purchasePrice: number; + purchaseDate: string; + usefulLifeYears: number; + depreciationMethod: DepreciationMethod; + salvageValue?: number; + currentDepreciatedValue: number; + annualDepreciation: number; + totalDepreciationToDate: number; + remainingUsefulLife: number; + isFullyDepreciated: boolean; + createdAt: Date; + updatedAt: Date; + + constructor(assetDepreciation: any) { + this.id = assetDepreciation.id; + this.assetName = assetDepreciation.assetName; + this.description = assetDepreciation.description; + this.purchasePrice = Number(assetDepreciation.purchasePrice); + this.purchaseDate = assetDepreciation.purchaseDate; + this.usefulLifeYears = assetDepreciation.usefulLifeYears; + this.depreciationMethod = assetDepreciation.depreciationMethod; + this.salvageValue = assetDepreciation.salvageValue ? Number(assetDepreciation.salvageValue) : undefined; + this.currentDepreciatedValue = Number(assetDepreciation.getCurrentDepreciatedValue().toFixed(2)); + this.annualDepreciation = Number(assetDepreciation.getAnnualDepreciation().toFixed(2)); + this.totalDepreciationToDate = Number(assetDepreciation.getTotalDepreciationToDate().toFixed(2)); + this.remainingUsefulLife = Number(assetDepreciation.getRemainingUsefulLife().toFixed(2)); + this.isFullyDepreciated = assetDepreciation.isFullyDepreciated(); + this.createdAt = assetDepreciation.createdAt; + this.updatedAt = assetDepreciation.updatedAt; + } +} + +export class AssetDepreciationSummaryDto { + totalAssets: number; + totalPurchaseValue: number; + totalCurrentValue: number; + totalDepreciation: number; + fullyDepreciatedAssets: number; + averageAge: number; + + constructor(data: Partial) { + Object.assign(this, data); + } +} diff --git a/backend/src/asset-depreciation/entities/asset-depreciation.entity.ts b/backend/src/asset-depreciation/entities/asset-depreciation.entity.ts new file mode 100644 index 0000000..c2ea10d --- /dev/null +++ b/backend/src/asset-depreciation/entities/asset-depreciation.entity.ts @@ -0,0 +1,115 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum DepreciationMethod { + STRAIGHT_LINE = 'straight_line', + // Future methods can be added here + // DECLINING_BALANCE = 'declining_balance', + // UNITS_OF_PRODUCTION = 'units_of_production', +} + +@Entity('asset_depreciations') +export class AssetDepreciation { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 255 }) + assetName: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + purchasePrice: number; + + @Column({ type: 'date' }) + purchaseDate: Date; + + @Column({ type: 'int' }) + usefulLifeYears: number; + + @Column({ + type: 'enum', + enum: DepreciationMethod, + default: DepreciationMethod.STRAIGHT_LINE, + }) + depreciationMethod: DepreciationMethod; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + salvageValue: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + /** + * Calculate current depreciated value using straight-line method + * Formula: Current Value = Purchase Price - ((Purchase Price - Salvage Value) * (Years Elapsed / Useful Life)) + */ + getCurrentDepreciatedValue(): number { + const currentDate = new Date(); + const purchaseDate = new Date(this.purchaseDate); + const yearsElapsed = (currentDate.getTime() - purchaseDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + + const salvage = this.salvageValue || 0; + const depreciableAmount = Number(this.purchasePrice) - salvage; + const annualDepreciation = depreciableAmount / this.usefulLifeYears; + const totalDepreciation = Math.min(annualDepreciation * yearsElapsed, depreciableAmount); + + const currentValue = Number(this.purchasePrice) - totalDepreciation; + + // Ensure the value doesn't go below salvage value + return Math.max(currentValue, salvage); + } + + /** + * Calculate annual depreciation amount + */ + getAnnualDepreciation(): number { + const salvage = this.salvageValue || 0; + const depreciableAmount = Number(this.purchasePrice) - salvage; + return depreciableAmount / this.usefulLifeYears; + } + + /** + * Calculate total depreciation to date + */ + getTotalDepreciationToDate(): number { + const currentDate = new Date(); + const purchaseDate = new Date(this.purchaseDate); + const yearsElapsed = (currentDate.getTime() - purchaseDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + + const salvage = this.salvageValue || 0; + const depreciableAmount = Number(this.purchasePrice) - salvage; + const annualDepreciation = depreciableAmount / this.usefulLifeYears; + + return Math.min(annualDepreciation * yearsElapsed, depreciableAmount); + } + + /** + * Calculate remaining years of useful life + */ + getRemainingUsefulLife(): number { + const currentDate = new Date(); + const purchaseDate = new Date(this.purchaseDate); + const yearsElapsed = (currentDate.getTime() - purchaseDate.getTime()) / (1000 * 60 * 60 * 24 * 365.25); + + return Math.max(0, this.usefulLifeYears - yearsElapsed); + } + + /** + * Check if asset is fully depreciated + */ + isFullyDepreciated(): boolean { + const currentValue = this.getCurrentDepreciatedValue(); + const salvage = this.salvageValue || 0; + return currentValue <= salvage; + } +} diff --git a/backend/src/procurement/dto/procurement.dto.ts b/backend/src/procurement/dto/procurement.dto.ts new file mode 100644 index 0000000..a9db116 --- /dev/null +++ b/backend/src/procurement/dto/procurement.dto.ts @@ -0,0 +1,185 @@ +import { + IsString, + IsNotEmpty, + IsNumber, + IsPositive, + IsOptional, + MaxLength, + Min, + Max, + IsEnum, +} from 'class-validator'; +import { Transform } from 'class-transformer'; +import { ProcurementStatus } from '../entities/procurement-request.entity'; +import { AssetStatus } from '../entities/asset-registration.entity'; + +export class CreateProcurementRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + itemName: string; + + @IsNumber() + @IsPositive() + @Min(1) + @Max(9999) + @Transform(({ value }) => parseInt(value)) + quantity: number; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + requestedBy: string; + + @IsString() + @IsOptional() + notes?: string; +} + +export class ApproveProcurementRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + decidedBy: string; + + @IsString() + @IsOptional() + notes?: string; + + // Asset registration data + @IsString() + @IsOptional() + @MaxLength(255) + description?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + serialNumber?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + model?: string; + + @IsString() + @IsOptional() + @MaxLength(255) + manufacturer?: string; + + @IsNumber({ maxDecimalPlaces: 2 }) + @IsOptional() + @IsPositive() + @Transform(({ value }) => value ? parseFloat(value) : undefined) + cost?: number; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + assignedTo: string; + + @IsString() + @IsOptional() + @MaxLength(255) + location?: string; +} + +export class RejectProcurementRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + decidedBy: string; + + @IsString() + @IsOptional() + notes?: string; +} + +export class UpdateProcurementRequestDto { + @IsString() + @IsOptional() + @MaxLength(255) + itemName?: string; + + @IsNumber() + @IsPositive() + @IsOptional() + @Min(1) + @Max(9999) + @Transform(({ value }) => value ? parseInt(value) : undefined) + quantity?: number; + + @IsString() + @IsOptional() + notes?: string; +} + +export class ProcurementRequestResponseDto { + id: number; + itemName: string; + quantity: number; + requestedBy: string; + status: ProcurementStatus; + notes: string | null; + requestedAt: Date; + decidedAt: Date | null; + decidedBy: string | null; + assetRegistrationId: number | null; + + constructor(procurementRequest: any) { + this.id = procurementRequest.id; + this.itemName = procurementRequest.itemName; + this.quantity = procurementRequest.quantity; + this.requestedBy = procurementRequest.requestedBy; + this.status = procurementRequest.status; + this.notes = procurementRequest.notes; + this.requestedAt = procurementRequest.requestedAt; + this.decidedAt = procurementRequest.decidedAt; + this.decidedBy = procurementRequest.decidedBy; + this.assetRegistrationId = procurementRequest.assetRegistrationId; + } +} + +export class AssetRegistrationResponseDto { + id: number; + assetId: string; + assetName: string; + description: string | null; + serialNumber: string | null; + model: string | null; + manufacturer: string | null; + cost: number | null; + status: AssetStatus; + assignedTo: string; + location: string | null; + createdAt: Date; + updatedAt: Date; + + constructor(assetRegistration: any) { + this.id = assetRegistration.id; + this.assetId = assetRegistration.assetId; + this.assetName = assetRegistration.assetName; + this.description = assetRegistration.description; + this.serialNumber = assetRegistration.serialNumber; + this.model = assetRegistration.model; + this.manufacturer = assetRegistration.manufacturer; + this.cost = assetRegistration.cost ? Number(assetRegistration.cost) : null; + this.status = assetRegistration.status; + this.assignedTo = assetRegistration.assignedTo; + this.location = assetRegistration.location; + this.createdAt = assetRegistration.createdAt; + this.updatedAt = assetRegistration.updatedAt; + } +} + +export class ProcurementSummaryDto { + totalRequests: number; + pendingRequests: number; + approvedRequests: number; + rejectedRequests: number; + totalAssetsCreated: number; + + constructor(data: Partial) { + Object.assign(this, data); + } +} diff --git a/backend/src/procurement/entities/asset-registration.entity.ts b/backend/src/procurement/entities/asset-registration.entity.ts new file mode 100644 index 0000000..1b6ab95 --- /dev/null +++ b/backend/src/procurement/entities/asset-registration.entity.ts @@ -0,0 +1,70 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, +} from 'typeorm'; +import { ProcurementRequest } from './procurement-request.entity'; + +export enum AssetStatus { + PENDING = 'pending', + ACTIVE = 'active', + MAINTENANCE = 'maintenance', + RETIRED = 'retired', +} + +@Entity('asset_registrations') +export class AssetRegistration { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 255, unique: true }) + assetId: string; + + @Column({ type: 'varchar', length: 255 }) + assetName: string; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + serialNumber: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + model: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + manufacturer: string | null; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + cost: number | null; + + @Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.PENDING }) + status: AssetStatus; + + @Column({ type: 'varchar', length: 255 }) + assignedTo: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + location: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @OneToOne(() => ProcurementRequest, (pr) => pr.assetRegistration) + procurementRequest: ProcurementRequest; + + /** + * Generate a unique asset ID based on prefix and ID + */ + generateAssetId(): string { + const prefix = 'AST'; + const paddedId = this.id.toString().padStart(6, '0'); + return `${prefix}-${paddedId}`; + } +} diff --git a/backend/src/procurement/entities/procurement-entities.spec.ts b/backend/src/procurement/entities/procurement-entities.spec.ts new file mode 100644 index 0000000..47779c6 --- /dev/null +++ b/backend/src/procurement/entities/procurement-entities.spec.ts @@ -0,0 +1,231 @@ +import { ProcurementRequest, ProcurementStatus } from './procurement-request.entity'; +import { AssetRegistration, AssetStatus } from './asset-registration.entity'; + +describe('Procurement Entities', () => { + describe('ProcurementRequest Entity', () => { + let procurementRequest: ProcurementRequest; + + beforeEach(() => { + procurementRequest = new ProcurementRequest(); + }); + + it('should be defined', () => { + expect(procurementRequest).toBeDefined(); + }); + + it('should have correct default values', () => { + procurementRequest.itemName = 'Test Item'; + procurementRequest.quantity = 1; + procurementRequest.requestedBy = 'test.user'; + procurementRequest.status = ProcurementStatus.PENDING; + + expect(procurementRequest.status).toBe(ProcurementStatus.PENDING); + expect(procurementRequest.itemName).toBe('Test Item'); + expect(procurementRequest.quantity).toBe(1); + expect(procurementRequest.requestedBy).toBe('test.user'); + }); + + it('should allow setting optional fields', () => { + procurementRequest.notes = 'Test notes'; + procurementRequest.decidedBy = 'manager'; + procurementRequest.decidedAt = new Date(); + + expect(procurementRequest.notes).toBe('Test notes'); + expect(procurementRequest.decidedBy).toBe('manager'); + expect(procurementRequest.decidedAt).toBeInstanceOf(Date); + }); + + it('should support all procurement statuses', () => { + // Test PENDING status + procurementRequest.status = ProcurementStatus.PENDING; + expect(procurementRequest.status).toBe('pending'); + + // Test APPROVED status + procurementRequest.status = ProcurementStatus.APPROVED; + expect(procurementRequest.status).toBe('approved'); + + // Test REJECTED status + procurementRequest.status = ProcurementStatus.REJECTED; + expect(procurementRequest.status).toBe('rejected'); + }); + + it('should handle asset registration relationship', () => { + const assetRegistration = new AssetRegistration(); + assetRegistration.id = 1; + assetRegistration.assetId = 'AST-000001'; + + procurementRequest.assetRegistration = assetRegistration; + procurementRequest.assetRegistrationId = assetRegistration.id; + + expect(procurementRequest.assetRegistration).toBe(assetRegistration); + expect(procurementRequest.assetRegistrationId).toBe(1); + }); + }); + + describe('AssetRegistration Entity', () => { + let assetRegistration: AssetRegistration; + + beforeEach(() => { + assetRegistration = new AssetRegistration(); + }); + + it('should be defined', () => { + expect(assetRegistration).toBeDefined(); + }); + + it('should have correct default values', () => { + assetRegistration.assetName = 'Test Asset'; + assetRegistration.assignedTo = 'test.user'; + assetRegistration.status = AssetStatus.PENDING; + + expect(assetRegistration.status).toBe(AssetStatus.PENDING); + expect(assetRegistration.assetName).toBe('Test Asset'); + expect(assetRegistration.assignedTo).toBe('test.user'); + }); + + it('should allow setting optional fields', () => { + assetRegistration.description = 'Test description'; + assetRegistration.serialNumber = 'SN123456'; + assetRegistration.model = 'Model X'; + assetRegistration.manufacturer = 'Test Manufacturer'; + assetRegistration.cost = 1500.99; + assetRegistration.location = 'Office A'; + + expect(assetRegistration.description).toBe('Test description'); + expect(assetRegistration.serialNumber).toBe('SN123456'); + expect(assetRegistration.model).toBe('Model X'); + expect(assetRegistration.manufacturer).toBe('Test Manufacturer'); + expect(assetRegistration.cost).toBe(1500.99); + expect(assetRegistration.location).toBe('Office A'); + }); + + it('should support all asset statuses', () => { + // Test PENDING status + assetRegistration.status = AssetStatus.PENDING; + expect(assetRegistration.status).toBe('pending'); + + // Test ACTIVE status + assetRegistration.status = AssetStatus.ACTIVE; + expect(assetRegistration.status).toBe('active'); + + // Test MAINTENANCE status + assetRegistration.status = AssetStatus.MAINTENANCE; + expect(assetRegistration.status).toBe('maintenance'); + + // Test RETIRED status + assetRegistration.status = AssetStatus.RETIRED; + expect(assetRegistration.status).toBe('retired'); + }); + + it('should generate correct asset ID', () => { + assetRegistration.id = 1; + const generatedId = assetRegistration.generateAssetId(); + expect(generatedId).toBe('AST-000001'); + + assetRegistration.id = 123; + const generatedId2 = assetRegistration.generateAssetId(); + expect(generatedId2).toBe('AST-000123'); + + assetRegistration.id = 999999; + const generatedId3 = assetRegistration.generateAssetId(); + expect(generatedId3).toBe('AST-999999'); + }); + + it('should handle procurement request relationship', () => { + const procurementRequest = new ProcurementRequest(); + procurementRequest.id = 1; + procurementRequest.itemName = 'Test Item'; + + assetRegistration.procurementRequest = procurementRequest; + + expect(assetRegistration.procurementRequest).toBe(procurementRequest); + expect(assetRegistration.procurementRequest.itemName).toBe('Test Item'); + }); + }); + + describe('Entity Relationships', () => { + it('should establish bidirectional relationship between procurement request and asset registration', () => { + const procurementRequest = new ProcurementRequest(); + procurementRequest.id = 1; + procurementRequest.itemName = 'Laptop'; + procurementRequest.status = ProcurementStatus.APPROVED; + + const assetRegistration = new AssetRegistration(); + assetRegistration.id = 1; + assetRegistration.assetId = 'AST-000001'; + assetRegistration.assetName = 'Laptop'; + assetRegistration.status = AssetStatus.PENDING; + + // Set up relationship + procurementRequest.assetRegistration = assetRegistration; + procurementRequest.assetRegistrationId = assetRegistration.id; + assetRegistration.procurementRequest = procurementRequest; + + // Verify relationship + expect(procurementRequest.assetRegistration).toBe(assetRegistration); + expect(assetRegistration.procurementRequest).toBe(procurementRequest); + expect(procurementRequest.assetRegistrationId).toBe(assetRegistration.id); + + // Verify data consistency + expect(procurementRequest.itemName).toBe(assetRegistration.assetName); + }); + + it('should handle null relationships correctly', () => { + const procurementRequest = new ProcurementRequest(); + procurementRequest.status = ProcurementStatus.PENDING; + + expect(procurementRequest.assetRegistration).toBeUndefined(); + expect(procurementRequest.assetRegistrationId).toBeUndefined(); + + // This represents a pending request that hasn't been approved yet + expect(procurementRequest.status).toBe(ProcurementStatus.PENDING); + }); + }); + + describe('Entity Validation', () => { + describe('ProcurementRequest validation scenarios', () => { + it('should handle required fields correctly', () => { + const procurementRequest = new ProcurementRequest(); + + // These would be validated by class-validator decorators + procurementRequest.itemName = 'Valid Item Name'; + procurementRequest.quantity = 5; + procurementRequest.requestedBy = 'valid.user'; + + expect(procurementRequest.itemName.length).toBeGreaterThan(0); + expect(procurementRequest.itemName.length).toBeLessThanOrEqual(255); + expect(procurementRequest.quantity).toBeGreaterThan(0); + expect(procurementRequest.quantity).toBeLessThanOrEqual(9999); + expect(procurementRequest.requestedBy.length).toBeGreaterThan(0); + expect(procurementRequest.requestedBy.length).toBeLessThanOrEqual(255); + }); + }); + + describe('AssetRegistration validation scenarios', () => { + it('should handle required fields correctly', () => { + const assetRegistration = new AssetRegistration(); + + assetRegistration.assetId = 'AST-000001'; + assetRegistration.assetName = 'Valid Asset Name'; + assetRegistration.assignedTo = 'valid.user'; + + expect(assetRegistration.assetId.length).toBeGreaterThan(0); + expect(assetRegistration.assetName.length).toBeGreaterThan(0); + expect(assetRegistration.assignedTo.length).toBeGreaterThan(0); + }); + + it('should handle optional cost field correctly', () => { + const assetRegistration = new AssetRegistration(); + + // Cost can be null + assetRegistration.cost = null; + expect(assetRegistration.cost).toBeNull(); + + // Cost can be a positive number + assetRegistration.cost = 1500.99; + expect(assetRegistration.cost).toBe(1500.99); + expect(assetRegistration.cost).toBeGreaterThan(0); + }); + }); + }); +}); diff --git a/backend/src/procurement/entities/procurement-request.entity.ts b/backend/src/procurement/entities/procurement-request.entity.ts new file mode 100644 index 0000000..60f9299 --- /dev/null +++ b/backend/src/procurement/entities/procurement-request.entity.ts @@ -0,0 +1,57 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { AssetRegistration } from './asset-registration.entity'; + +export enum ProcurementStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', +} + +@Entity('procurement_requests') +@Index(['status']) +@Index(['requestedBy']) +export class ProcurementRequest { + @PrimaryGeneratedColumn() + id: number; + + @Column({ type: 'varchar', length: 255 }) + itemName: string; + + @Column({ type: 'int' }) + quantity: number; + + @Column({ type: 'varchar', length: 255 }) + requestedBy: string; + + @Column({ type: 'enum', enum: ProcurementStatus, default: ProcurementStatus.PENDING }) + status: ProcurementStatus; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn() + requestedAt: Date; + + @Column({ type: 'timestamp', nullable: true }) + decidedAt: Date | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + decidedBy: string | null; + + @OneToOne(() => AssetRegistration, (ar) => ar.procurementRequest, { nullable: true }) + @JoinColumn({ name: 'assetRegistrationId' }) + assetRegistration?: AssetRegistration | null; + + @Column({ type: 'int', nullable: true, unique: true }) + assetRegistrationId?: number | null; +} + diff --git a/backend/src/procurement/procurement.controller.spec.ts b/backend/src/procurement/procurement.controller.spec.ts new file mode 100644 index 0000000..7c9b999 --- /dev/null +++ b/backend/src/procurement/procurement.controller.spec.ts @@ -0,0 +1,532 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { ProcurementController } from './procurement.controller'; +import { ProcurementService } from './procurement.service'; +import { ProcurementStatus } from './entities/procurement-request.entity'; +import { AssetStatus } from './entities/asset-registration.entity'; +import { + CreateProcurementRequestDto, + ApproveProcurementRequestDto, + RejectProcurementRequestDto, + UpdateProcurementRequestDto, + ProcurementRequestResponseDto, + AssetRegistrationResponseDto, + ProcurementSummaryDto, +} from './dto/procurement.dto'; + +describe('ProcurementController', () => { + let controller: ProcurementController; + let service: ProcurementService; + + const mockProcurementService = { + create: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + approve: jest.fn(), + reject: jest.fn(), + remove: jest.fn(), + getSummary: jest.fn(), + getPendingRequestsByUser: jest.fn(), + getAssetsByAssignee: jest.fn(), + getAllAssets: jest.fn(), + getAssetByAssetId: jest.fn(), + updateAssetStatus: jest.fn(), + getAssetRegistration: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ProcurementController], + providers: [ + { + provide: ProcurementService, + useValue: mockProcurementService, + }, + ], + }).compile(); + + controller = module.get(ProcurementController); + service = module.get(ProcurementService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('create', () => { + it('should create a new procurement request', async () => { + const createDto: CreateProcurementRequestDto = { + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + notes: 'Urgent requirement', + }; + + const mockRequest = { + id: 1, + ...createDto, + status: ProcurementStatus.PENDING, + requestedAt: new Date(), + decidedAt: null, + decidedBy: null, + assetRegistrationId: null, + }; + + mockProcurementService.create.mockResolvedValue(mockRequest); + + const result = await controller.create(createDto); + + expect(mockProcurementService.create).toHaveBeenCalledWith(createDto); + expect(result).toBeInstanceOf(ProcurementRequestResponseDto); + expect(result.id).toBe(1); + expect(result.itemName).toBe(createDto.itemName); + }); + }); + + describe('findAll', () => { + it('should return all procurement requests without filters', async () => { + const mockRequests = [ + { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + notes: null, + requestedAt: new Date(), + decidedAt: null, + decidedBy: null, + assetRegistrationId: null, + }, + ]; + + mockProcurementService.findAll.mockResolvedValue(mockRequests); + + const result = await controller.findAll(); + + expect(mockProcurementService.findAll).toHaveBeenCalledWith({}); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(ProcurementRequestResponseDto); + }); + + it('should return filtered procurement requests', async () => { + const mockRequests = [ + { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + notes: null, + requestedAt: new Date(), + decidedAt: null, + decidedBy: null, + assetRegistrationId: null, + }, + ]; + + mockProcurementService.findAll.mockResolvedValue(mockRequests); + + const result = await controller.findAll('pending', 'john.doe', 'Laptop'); + + expect(mockProcurementService.findAll).toHaveBeenCalledWith({ + status: ProcurementStatus.PENDING, + requestedBy: 'john.doe', + itemName: 'Laptop', + }); + expect(result).toHaveLength(1); + }); + + it('should throw BadRequestException for invalid status', async () => { + await expect(controller.findAll('invalid_status')).rejects.toThrow(BadRequestException); + expect(mockProcurementService.findAll).not.toHaveBeenCalled(); + }); + }); + + describe('getSummary', () => { + it('should return procurement summary', async () => { + const mockSummary = new ProcurementSummaryDto({ + totalRequests: 10, + pendingRequests: 3, + approvedRequests: 5, + rejectedRequests: 2, + totalAssetsCreated: 5, + }); + + mockProcurementService.getSummary.mockResolvedValue(mockSummary); + + const result = await controller.getSummary(); + + expect(mockProcurementService.getSummary).toHaveBeenCalled(); + expect(result).toEqual(mockSummary); + }); + }); + + describe('getPendingByUser', () => { + it('should return pending requests for a user', async () => { + const mockRequests = [ + { + id: 1, + itemName: 'Laptop', + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + notes: null, + requestedAt: new Date(), + decidedAt: null, + decidedBy: null, + assetRegistrationId: null, + }, + ]; + + mockProcurementService.getPendingRequestsByUser.mockResolvedValue(mockRequests); + + const result = await controller.getPendingByUser('john.doe'); + + expect(mockProcurementService.getPendingRequestsByUser).toHaveBeenCalledWith('john.doe'); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(ProcurementRequestResponseDto); + }); + }); + + describe('getAssetsByAssignee', () => { + it('should return assets assigned to a user', async () => { + const mockAssets = [ + { + id: 1, + assetId: 'AST-000001', + assetName: 'Laptop', + assignedTo: 'john.doe', + status: AssetStatus.ACTIVE, + description: null, + serialNumber: null, + model: null, + manufacturer: null, + cost: null, + location: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockProcurementService.getAssetsByAssignee.mockResolvedValue(mockAssets); + + const result = await controller.getAssetsByAssignee('john.doe'); + + expect(mockProcurementService.getAssetsByAssignee).toHaveBeenCalledWith('john.doe'); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(AssetRegistrationResponseDto); + }); + }); + + describe('getAllAssets', () => { + it('should return all assets without filters', async () => { + const mockAssets = [ + { + id: 1, + assetId: 'AST-000001', + assetName: 'Laptop', + assignedTo: 'john.doe', + status: AssetStatus.ACTIVE, + description: null, + serialNumber: null, + model: null, + manufacturer: null, + cost: null, + location: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockProcurementService.getAllAssets.mockResolvedValue(mockAssets); + + const result = await controller.getAllAssets(); + + expect(mockProcurementService.getAllAssets).toHaveBeenCalledWith({}); + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(AssetRegistrationResponseDto); + }); + + it('should return filtered assets', async () => { + const mockAssets = [ + { + id: 1, + assetId: 'AST-000001', + assetName: 'Laptop', + assignedTo: 'john.doe', + status: AssetStatus.ACTIVE, + description: null, + serialNumber: null, + model: null, + manufacturer: null, + cost: null, + location: 'Office A', + createdAt: new Date(), + updatedAt: new Date(), + }, + ]; + + mockProcurementService.getAllAssets.mockResolvedValue(mockAssets); + + const result = await controller.getAllAssets('active', 'john.doe', 'Office A'); + + expect(mockProcurementService.getAllAssets).toHaveBeenCalledWith({ + status: AssetStatus.ACTIVE, + assignedTo: 'john.doe', + location: 'Office A', + }); + expect(result).toHaveLength(1); + }); + + it('should throw BadRequestException for invalid asset status', async () => { + await expect(controller.getAllAssets('invalid_status')).rejects.toThrow(BadRequestException); + expect(mockProcurementService.getAllAssets).not.toHaveBeenCalled(); + }); + }); + + describe('getAssetByAssetId', () => { + it('should return asset by asset ID', async () => { + const mockAsset = { + id: 1, + assetId: 'AST-000001', + assetName: 'Laptop', + assignedTo: 'john.doe', + status: AssetStatus.ACTIVE, + description: null, + serialNumber: null, + model: null, + manufacturer: null, + cost: null, + location: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockProcurementService.getAssetByAssetId.mockResolvedValue(mockAsset); + + const result = await controller.getAssetByAssetId('AST-000001'); + + expect(mockProcurementService.getAssetByAssetId).toHaveBeenCalledWith('AST-000001'); + expect(result).toBeInstanceOf(AssetRegistrationResponseDto); + expect(result.assetId).toBe('AST-000001'); + }); + }); + + describe('updateAssetStatus', () => { + it('should update asset status', async () => { + const mockAsset = { + id: 1, + assetId: 'AST-000001', + assetName: 'Laptop', + assignedTo: 'john.doe', + status: AssetStatus.MAINTENANCE, + description: null, + serialNumber: null, + model: null, + manufacturer: null, + cost: null, + location: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockProcurementService.updateAssetStatus.mockResolvedValue(mockAsset); + + const result = await controller.updateAssetStatus('AST-000001', AssetStatus.MAINTENANCE, 'admin'); + + expect(mockProcurementService.updateAssetStatus).toHaveBeenCalledWith( + 'AST-000001', + AssetStatus.MAINTENANCE, + 'admin', + ); + expect(result).toBeInstanceOf(AssetRegistrationResponseDto); + expect(result.status).toBe(AssetStatus.MAINTENANCE); + }); + + it('should throw BadRequestException for invalid asset status', async () => { + await expect( + controller.updateAssetStatus('AST-000001', 'invalid_status' as AssetStatus), + ).rejects.toThrow(BadRequestException); + expect(mockProcurementService.updateAssetStatus).not.toHaveBeenCalled(); + }); + }); + + describe('findOne', () => { + it('should return a procurement request by ID', async () => { + const mockRequest = { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + notes: null, + requestedAt: new Date(), + decidedAt: null, + decidedBy: null, + assetRegistrationId: null, + }; + + mockProcurementService.findOne.mockResolvedValue(mockRequest); + + const result = await controller.findOne(1); + + expect(mockProcurementService.findOne).toHaveBeenCalledWith(1); + expect(result).toBeInstanceOf(ProcurementRequestResponseDto); + expect(result.id).toBe(1); + }); + }); + + describe('getAssetRegistration', () => { + it('should return asset registration for procurement request', async () => { + const mockAsset = { + id: 1, + assetId: 'AST-000001', + assetName: 'Laptop', + assignedTo: 'john.doe', + status: AssetStatus.ACTIVE, + description: null, + serialNumber: null, + model: null, + manufacturer: null, + cost: null, + location: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockProcurementService.getAssetRegistration.mockResolvedValue(mockAsset); + + const result = await controller.getAssetRegistration(1); + + expect(mockProcurementService.getAssetRegistration).toHaveBeenCalledWith(1); + expect(result).toBeInstanceOf(AssetRegistrationResponseDto); + }); + }); + + describe('approve', () => { + it('should approve a procurement request', async () => { + const approveDto: ApproveProcurementRequestDto = { + decidedBy: 'manager.smith', + description: 'High-performance laptop', + cost: 1500.00, + assignedTo: 'john.doe', + location: 'Office A', + notes: 'Approved for immediate procurement', + }; + + const mockResult = { + procurementRequest: { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.APPROVED, + notes: approveDto.notes, + requestedAt: new Date(), + decidedAt: new Date(), + decidedBy: approveDto.decidedBy, + assetRegistrationId: 1, + }, + assetRegistration: { + id: 1, + assetId: 'AST-000001', + assetName: 'Laptop', + description: approveDto.description, + assignedTo: approveDto.assignedTo, + location: approveDto.location, + cost: approveDto.cost, + status: AssetStatus.PENDING, + serialNumber: null, + model: null, + manufacturer: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }; + + mockProcurementService.approve.mockResolvedValue(mockResult); + + const result = await controller.approve(1, approveDto); + + expect(mockProcurementService.approve).toHaveBeenCalledWith(1, approveDto); + expect(result.procurementRequest).toBeInstanceOf(ProcurementRequestResponseDto); + expect(result.assetRegistration).toBeInstanceOf(AssetRegistrationResponseDto); + }); + }); + + describe('reject', () => { + it('should reject a procurement request', async () => { + const rejectDto: RejectProcurementRequestDto = { + decidedBy: 'manager.smith', + notes: 'Budget constraints', + }; + + const mockRequest = { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.REJECTED, + notes: rejectDto.notes, + requestedAt: new Date(), + decidedAt: new Date(), + decidedBy: rejectDto.decidedBy, + assetRegistrationId: null, + }; + + mockProcurementService.reject.mockResolvedValue(mockRequest); + + const result = await controller.reject(1, rejectDto); + + expect(mockProcurementService.reject).toHaveBeenCalledWith(1, rejectDto); + expect(result).toBeInstanceOf(ProcurementRequestResponseDto); + expect(result.status).toBe(ProcurementStatus.REJECTED); + }); + }); + + describe('update', () => { + it('should update a procurement request', async () => { + const updateDto: UpdateProcurementRequestDto = { + itemName: 'Updated Laptop', + quantity: 3, + notes: 'Updated notes', + }; + + const mockRequest = { + id: 1, + itemName: updateDto.itemName, + quantity: updateDto.quantity, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + notes: updateDto.notes, + requestedAt: new Date(), + decidedAt: null, + decidedBy: null, + assetRegistrationId: null, + }; + + mockProcurementService.update.mockResolvedValue(mockRequest); + + const result = await controller.update(1, updateDto); + + expect(mockProcurementService.update).toHaveBeenCalledWith(1, updateDto); + expect(result).toBeInstanceOf(ProcurementRequestResponseDto); + expect(result.itemName).toBe(updateDto.itemName); + }); + }); + + describe('remove', () => { + it('should remove a procurement request', async () => { + mockProcurementService.remove.mockResolvedValue(undefined); + + await controller.remove(1); + + expect(mockProcurementService.remove).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/backend/src/procurement/procurement.controller.ts b/backend/src/procurement/procurement.controller.ts new file mode 100644 index 0000000..b24c4d0 --- /dev/null +++ b/backend/src/procurement/procurement.controller.ts @@ -0,0 +1,252 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + ParseIntPipe, + HttpCode, + HttpStatus, + Query, + ValidationPipe, + BadRequestException, +} from '@nestjs/common'; +import { ProcurementService } from './procurement.service'; +import { + CreateProcurementRequestDto, + ApproveProcurementRequestDto, + RejectProcurementRequestDto, + UpdateProcurementRequestDto, + ProcurementRequestResponseDto, + AssetRegistrationResponseDto, + ProcurementSummaryDto, +} from './dto/procurement.dto'; +import { ProcurementStatus } from './entities/procurement-request.entity'; +import { AssetStatus } from './entities/asset-registration.entity'; + +@Controller('procurement') +export class ProcurementController { + constructor(private readonly procurementService: ProcurementService) {} + + /** + * Create a new procurement request + * POST /procurement + */ + @Post() + @HttpCode(HttpStatus.CREATED) + async create( + @Body(ValidationPipe) createDto: CreateProcurementRequestDto, + ): Promise { + const procurementRequest = await this.procurementService.create(createDto); + return new ProcurementRequestResponseDto(procurementRequest); + } + + /** + * Get all procurement requests with optional filtering + * GET /procurement?status=pending&requestedBy=john&itemName=laptop + */ + @Get() + async findAll( + @Query('status') status?: string, + @Query('requestedBy') requestedBy?: string, + @Query('itemName') itemName?: string, + ): Promise { + const filters: any = {}; + + if (status) { + if (Object.values(ProcurementStatus).includes(status as ProcurementStatus)) { + filters.status = status as ProcurementStatus; + } else { + throw new BadRequestException(`Invalid status: ${status}`); + } + } + + if (requestedBy) { + filters.requestedBy = requestedBy; + } + + if (itemName) { + filters.itemName = itemName; + } + + const procurementRequests = await this.procurementService.findAll(filters); + return procurementRequests.map(pr => new ProcurementRequestResponseDto(pr)); + } + + /** + * Get procurement summary statistics + * GET /procurement/summary + */ + @Get('summary') + getSummary(): Promise { + return this.procurementService.getSummary(); + } + + /** + * Get pending requests by user + * GET /procurement/pending/:requestedBy + */ + @Get('pending/:requestedBy') + async getPendingByUser( + @Param('requestedBy') requestedBy: string, + ): Promise { + const requests = await this.procurementService.getPendingRequestsByUser(requestedBy); + return requests.map(pr => new ProcurementRequestResponseDto(pr)); + } + + /** + * Get assets assigned to a user + * GET /procurement/assets/assigned/:assignedTo + */ + @Get('assets/assigned/:assignedTo') + async getAssetsByAssignee( + @Param('assignedTo') assignedTo: string, + ): Promise { + const assets = await this.procurementService.getAssetsByAssignee(assignedTo); + return assets.map(asset => new AssetRegistrationResponseDto(asset)); + } + + /** + * Get all assets with filtering + * GET /procurement/assets?status=active&assignedTo=john&location=office + */ + @Get('assets') + async getAllAssets( + @Query('status') status?: string, + @Query('assignedTo') assignedTo?: string, + @Query('location') location?: string, + ): Promise { + const filters: any = {}; + + if (status) { + if (Object.values(AssetStatus).includes(status as AssetStatus)) { + filters.status = status as AssetStatus; + } else { + throw new BadRequestException(`Invalid asset status: ${status}`); + } + } + + if (assignedTo) { + filters.assignedTo = assignedTo; + } + + if (location) { + filters.location = location; + } + + const assets = await this.procurementService.getAllAssets(filters); + return assets.map(asset => new AssetRegistrationResponseDto(asset)); + } + + /** + * Get asset by asset ID + * GET /procurement/assets/:assetId + */ + @Get('assets/:assetId') + async getAssetByAssetId( + @Param('assetId') assetId: string, + ): Promise { + const asset = await this.procurementService.getAssetByAssetId(assetId); + return new AssetRegistrationResponseDto(asset); + } + + /** + * Update asset status + * PATCH /procurement/assets/:assetId/status + */ + @Patch('assets/:assetId/status') + async updateAssetStatus( + @Param('assetId') assetId: string, + @Body('status') status: AssetStatus, + @Body('updatedBy') updatedBy?: string, + ): Promise { + if (!Object.values(AssetStatus).includes(status)) { + throw new BadRequestException(`Invalid asset status: ${status}`); + } + + const asset = await this.procurementService.updateAssetStatus(assetId, status, updatedBy); + return new AssetRegistrationResponseDto(asset); + } + + /** + * Get specific procurement request by ID + * GET /procurement/:id + */ + @Get(':id') + async findOne(@Param('id', ParseIntPipe) id: number): Promise { + const procurementRequest = await this.procurementService.findOne(id); + return new ProcurementRequestResponseDto(procurementRequest); + } + + /** + * Get asset registration for a procurement request + * GET /procurement/:id/asset + */ + @Get(':id/asset') + async getAssetRegistration( + @Param('id', ParseIntPipe) id: number, + ): Promise { + const asset = await this.procurementService.getAssetRegistration(id); + return new AssetRegistrationResponseDto(asset); + } + + /** + * Approve a procurement request and create asset registration + * POST /procurement/:id/approve + */ + @Post(':id/approve') + @HttpCode(HttpStatus.OK) + async approve( + @Param('id', ParseIntPipe) id: number, + @Body(ValidationPipe) approveDto: ApproveProcurementRequestDto, + ): Promise<{ + procurementRequest: ProcurementRequestResponseDto; + assetRegistration: AssetRegistrationResponseDto; + }> { + const result = await this.procurementService.approve(id, approveDto); + + return { + procurementRequest: new ProcurementRequestResponseDto(result.procurementRequest), + assetRegistration: new AssetRegistrationResponseDto(result.assetRegistration), + }; + } + + /** + * Reject a procurement request + * POST /procurement/:id/reject + */ + @Post(':id/reject') + @HttpCode(HttpStatus.OK) + async reject( + @Param('id', ParseIntPipe) id: number, + @Body(ValidationPipe) rejectDto: RejectProcurementRequestDto, + ): Promise { + const procurementRequest = await this.procurementService.reject(id, rejectDto); + return new ProcurementRequestResponseDto(procurementRequest); + } + + /** + * Update a procurement request (only if pending) + * PATCH /procurement/:id + */ + @Patch(':id') + async update( + @Param('id', ParseIntPipe) id: number, + @Body(ValidationPipe) updateDto: UpdateProcurementRequestDto, + ): Promise { + const procurementRequest = await this.procurementService.update(id, updateDto); + return new ProcurementRequestResponseDto(procurementRequest); + } + + /** + * Delete a procurement request (only if pending) + * DELETE /procurement/:id + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.procurementService.remove(id); + } +} diff --git a/backend/src/procurement/procurement.module.ts b/backend/src/procurement/procurement.module.ts new file mode 100644 index 0000000..46f99b9 --- /dev/null +++ b/backend/src/procurement/procurement.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ProcurementService } from './procurement.service'; +import { ProcurementController } from './procurement.controller'; +import { ProcurementRequest } from './entities/procurement-request.entity'; +import { AssetRegistration } from './entities/asset-registration.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([ProcurementRequest, AssetRegistration])], + controllers: [ProcurementController], + providers: [ProcurementService], + exports: [ProcurementService], +}) +export class ProcurementModule {} diff --git a/backend/src/procurement/procurement.service.spec.ts b/backend/src/procurement/procurement.service.spec.ts new file mode 100644 index 0000000..21f6b29 --- /dev/null +++ b/backend/src/procurement/procurement.service.spec.ts @@ -0,0 +1,501 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { ProcurementService } from './procurement.service'; +import { ProcurementRequest, ProcurementStatus } from './entities/procurement-request.entity'; +import { AssetRegistration, AssetStatus } from './entities/asset-registration.entity'; +import { + CreateProcurementRequestDto, + ApproveProcurementRequestDto, + RejectProcurementRequestDto, + UpdateProcurementRequestDto, +} from './dto/procurement.dto'; + +describe('ProcurementService', () => { + let service: ProcurementService; + let procurementRequestRepository: Repository; + let assetRegistrationRepository: Repository; + + const mockProcurementRequestRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockAssetRegistrationRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + count: jest.fn(), + createQueryBuilder: jest.fn(), + }; + + const mockQueryBuilder = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProcurementService, + { + provide: getRepositoryToken(ProcurementRequest), + useValue: mockProcurementRequestRepository, + }, + { + provide: getRepositoryToken(AssetRegistration), + useValue: mockAssetRegistrationRepository, + }, + ], + }).compile(); + + service = module.get(ProcurementService); + procurementRequestRepository = module.get>( + getRepositoryToken(ProcurementRequest), + ); + assetRegistrationRepository = module.get>( + getRepositoryToken(AssetRegistration), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new procurement request', async () => { + const createDto: CreateProcurementRequestDto = { + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + notes: 'Urgent requirement', + }; + + const expectedRequest = { + id: 1, + ...createDto, + status: ProcurementStatus.PENDING, + requestedAt: new Date(), + decidedAt: null, + decidedBy: null, + assetRegistrationId: null, + }; + + mockProcurementRequestRepository.create.mockReturnValue(expectedRequest); + mockProcurementRequestRepository.save.mockResolvedValue(expectedRequest); + + const result = await service.create(createDto); + + expect(mockProcurementRequestRepository.create).toHaveBeenCalledWith({ + ...createDto, + status: ProcurementStatus.PENDING, + }); + expect(mockProcurementRequestRepository.save).toHaveBeenCalledWith(expectedRequest); + expect(result).toEqual(expectedRequest); + }); + }); + + describe('findAll', () => { + it('should return all procurement requests without filters', async () => { + const expectedRequests = [ + { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + }, + ]; + + mockProcurementRequestRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.getMany.mockResolvedValue(expectedRequests); + + const result = await service.findAll(); + + expect(mockProcurementRequestRepository.createQueryBuilder).toHaveBeenCalledWith('pr'); + expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('pr.assetRegistration', 'ar'); + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('pr.requestedAt', 'DESC'); + expect(result).toEqual(expectedRequests); + }); + + it('should return filtered procurement requests', async () => { + const filters = { + status: ProcurementStatus.PENDING, + requestedBy: 'john.doe', + itemName: 'Laptop', + }; + + const expectedRequests = [ + { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + }, + ]; + + mockProcurementRequestRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.getMany.mockResolvedValue(expectedRequests); + + const result = await service.findAll(filters); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('pr.status = :status', { + status: filters.status, + }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('pr.requestedBy = :requestedBy', { + requestedBy: filters.requestedBy, + }); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('LOWER(pr.itemName) LIKE LOWER(:itemName)', { + itemName: `%${filters.itemName}%`, + }); + expect(result).toEqual(expectedRequests); + }); + }); + + describe('findOne', () => { + it('should return a procurement request by id', async () => { + const expectedRequest = { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + }; + + mockProcurementRequestRepository.findOne.mockResolvedValue(expectedRequest); + + const result = await service.findOne(1); + + expect(mockProcurementRequestRepository.findOne).toHaveBeenCalledWith({ + where: { id: 1 }, + relations: ['assetRegistration'], + }); + expect(result).toEqual(expectedRequest); + }); + + it('should throw NotFoundException when procurement request not found', async () => { + mockProcurementRequestRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(999)).rejects.toThrow(NotFoundException); + expect(mockProcurementRequestRepository.findOne).toHaveBeenCalledWith({ + where: { id: 999 }, + relations: ['assetRegistration'], + }); + }); + }); + + describe('update', () => { + it('should update a pending procurement request', async () => { + const updateDto: UpdateProcurementRequestDto = { + itemName: 'Updated Laptop', + quantity: 3, + notes: 'Updated notes', + }; + + const existingRequest = { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + notes: 'Original notes', + }; + + const updatedRequest = { + ...existingRequest, + ...updateDto, + }; + + mockProcurementRequestRepository.findOne.mockResolvedValue(existingRequest); + mockProcurementRequestRepository.save.mockResolvedValue(updatedRequest); + + const result = await service.update(1, updateDto); + + expect(mockProcurementRequestRepository.save).toHaveBeenCalledWith(updatedRequest); + expect(result).toEqual(updatedRequest); + }); + + it('should throw BadRequestException when trying to update non-pending request', async () => { + const updateDto: UpdateProcurementRequestDto = { + itemName: 'Updated Laptop', + }; + + const existingRequest = { + id: 1, + status: ProcurementStatus.APPROVED, + }; + + mockProcurementRequestRepository.findOne.mockResolvedValue(existingRequest); + + await expect(service.update(1, updateDto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('approve', () => { + it('should approve a procurement request and create asset registration', async () => { + const approveDto: ApproveProcurementRequestDto = { + decidedBy: 'manager.smith', + description: 'High-performance laptop', + cost: 1500.00, + assignedTo: 'john.doe', + location: 'Office A', + notes: 'Approved for immediate procurement', + }; + + const existingRequest = { + id: 1, + itemName: 'Laptop', + quantity: 2, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + notes: 'Original notes', + }; + + const createdAssetRegistration = { + id: 1, + assetName: 'Laptop', + description: approveDto.description, + cost: approveDto.cost, + assignedTo: approveDto.assignedTo, + location: approveDto.location, + status: AssetStatus.PENDING, + assetId: '', + generateAssetId: jest.fn().mockReturnValue('AST-000001'), + }; + + const savedAssetRegistration = { + ...createdAssetRegistration, + assetId: 'AST-000001', + }; + + const approvedRequest = { + ...existingRequest, + status: ProcurementStatus.APPROVED, + decidedBy: approveDto.decidedBy, + decidedAt: expect.any(Date), + notes: approveDto.notes, + assetRegistrationId: 1, + }; + + mockProcurementRequestRepository.findOne.mockResolvedValue(existingRequest); + mockAssetRegistrationRepository.create.mockReturnValue(createdAssetRegistration); + mockAssetRegistrationRepository.save + .mockResolvedValueOnce(createdAssetRegistration) + .mockResolvedValueOnce(savedAssetRegistration); + mockProcurementRequestRepository.save.mockResolvedValue(approvedRequest); + + const result = await service.approve(1, approveDto); + + expect(mockAssetRegistrationRepository.create).toHaveBeenCalledWith({ + assetName: existingRequest.itemName, + description: approveDto.description, + serialNumber: undefined, + model: undefined, + manufacturer: undefined, + cost: approveDto.cost, + assignedTo: approveDto.assignedTo, + location: approveDto.location, + status: AssetStatus.PENDING, + assetId: '', + }); + expect(mockProcurementRequestRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: ProcurementStatus.APPROVED, + decidedBy: approveDto.decidedBy, + assetRegistrationId: 1, + }), + ); + expect(result).toEqual({ + procurementRequest: approvedRequest, + assetRegistration: savedAssetRegistration, + }); + }); + + it('should throw BadRequestException when trying to approve non-pending request', async () => { + const approveDto: ApproveProcurementRequestDto = { + decidedBy: 'manager.smith', + assignedTo: 'john.doe', + }; + + const existingRequest = { + id: 1, + status: ProcurementStatus.APPROVED, + }; + + mockProcurementRequestRepository.findOne.mockResolvedValue(existingRequest); + + await expect(service.approve(1, approveDto)).rejects.toThrow(BadRequestException); + }); + }); + + describe('reject', () => { + it('should reject a procurement request', async () => { + const rejectDto: RejectProcurementRequestDto = { + decidedBy: 'manager.smith', + notes: 'Budget constraints', + }; + + const existingRequest = { + id: 1, + itemName: 'Laptop', + status: ProcurementStatus.PENDING, + notes: 'Original notes', + }; + + const rejectedRequest = { + ...existingRequest, + status: ProcurementStatus.REJECTED, + decidedBy: rejectDto.decidedBy, + decidedAt: expect.any(Date), + notes: rejectDto.notes, + }; + + mockProcurementRequestRepository.findOne.mockResolvedValue(existingRequest); + mockProcurementRequestRepository.save.mockResolvedValue(rejectedRequest); + + const result = await service.reject(1, rejectDto); + + expect(mockProcurementRequestRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: ProcurementStatus.REJECTED, + decidedBy: rejectDto.decidedBy, + notes: rejectDto.notes, + }), + ); + expect(result).toEqual(rejectedRequest); + }); + }); + + describe('remove', () => { + it('should remove a pending procurement request', async () => { + const existingRequest = { + id: 1, + status: ProcurementStatus.PENDING, + }; + + mockProcurementRequestRepository.findOne.mockResolvedValue(existingRequest); + mockProcurementRequestRepository.remove.mockResolvedValue(existingRequest); + + await service.remove(1); + + expect(mockProcurementRequestRepository.remove).toHaveBeenCalledWith(existingRequest); + }); + + it('should throw BadRequestException when trying to remove non-pending request', async () => { + const existingRequest = { + id: 1, + status: ProcurementStatus.APPROVED, + }; + + mockProcurementRequestRepository.findOne.mockResolvedValue(existingRequest); + + await expect(service.remove(1)).rejects.toThrow(BadRequestException); + }); + }); + + describe('getSummary', () => { + it('should return procurement summary statistics', async () => { + mockProcurementRequestRepository.count + .mockResolvedValueOnce(10) // totalRequests + .mockResolvedValueOnce(3) // pendingRequests + .mockResolvedValueOnce(5) // approvedRequests + .mockResolvedValueOnce(2); // rejectedRequests + mockAssetRegistrationRepository.count.mockResolvedValue(5); // totalAssetsCreated + + const result = await service.getSummary(); + + expect(result).toEqual({ + totalRequests: 10, + pendingRequests: 3, + approvedRequests: 5, + rejectedRequests: 2, + totalAssetsCreated: 5, + }); + }); + }); + + describe('getPendingRequestsByUser', () => { + it('should return pending requests for a specific user', async () => { + const expectedRequests = [ + { + id: 1, + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + }, + ]; + + mockProcurementRequestRepository.find.mockResolvedValue(expectedRequests); + + const result = await service.getPendingRequestsByUser('john.doe'); + + expect(mockProcurementRequestRepository.find).toHaveBeenCalledWith({ + where: { + requestedBy: 'john.doe', + status: ProcurementStatus.PENDING, + }, + order: { requestedAt: 'DESC' }, + }); + expect(result).toEqual(expectedRequests); + }); + }); + + describe('getAssetsByAssignee', () => { + it('should return assets assigned to a specific user', async () => { + const expectedAssets = [ + { + id: 1, + assignedTo: 'john.doe', + assetName: 'Laptop', + }, + ]; + + mockAssetRegistrationRepository.find.mockResolvedValue(expectedAssets); + + const result = await service.getAssetsByAssignee('john.doe'); + + expect(mockAssetRegistrationRepository.find).toHaveBeenCalledWith({ + where: { assignedTo: 'john.doe' }, + relations: ['procurementRequest'], + order: { createdAt: 'DESC' }, + }); + expect(result).toEqual(expectedAssets); + }); + }); + + describe('updateAssetStatus', () => { + it('should update asset status by asset ID', async () => { + const existingAsset = { + id: 1, + assetId: 'AST-000001', + status: AssetStatus.PENDING, + }; + + const updatedAsset = { + ...existingAsset, + status: AssetStatus.ACTIVE, + }; + + mockAssetRegistrationRepository.findOne.mockResolvedValue(existingAsset); + mockAssetRegistrationRepository.save.mockResolvedValue(updatedAsset); + + const result = await service.updateAssetStatus('AST-000001', AssetStatus.ACTIVE); + + expect(mockAssetRegistrationRepository.save).toHaveBeenCalledWith(updatedAsset); + expect(result).toEqual(updatedAsset); + }); + }); +}); diff --git a/backend/src/procurement/procurement.service.ts b/backend/src/procurement/procurement.service.ts new file mode 100644 index 0000000..58f680c --- /dev/null +++ b/backend/src/procurement/procurement.service.ts @@ -0,0 +1,322 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ProcurementRequest, ProcurementStatus } from './entities/procurement-request.entity'; +import { AssetRegistration, AssetStatus } from './entities/asset-registration.entity'; +import { + CreateProcurementRequestDto, + ApproveProcurementRequestDto, + RejectProcurementRequestDto, + UpdateProcurementRequestDto, + ProcurementRequestResponseDto, + AssetRegistrationResponseDto, + ProcurementSummaryDto, +} from './dto/procurement.dto'; + +@Injectable() +export class ProcurementService { + constructor( + @InjectRepository(ProcurementRequest) + private readonly procurementRequestRepository: Repository, + + @InjectRepository(AssetRegistration) + private readonly assetRegistrationRepository: Repository, + ) {} + + /** + * Create a new procurement request + */ + async create(createDto: CreateProcurementRequestDto): Promise { + const procurementRequest = this.procurementRequestRepository.create({ + ...createDto, + status: ProcurementStatus.PENDING, + }); + + return await this.procurementRequestRepository.save(procurementRequest); + } + + /** + * Find all procurement requests with optional filtering + */ + async findAll(filters?: { + status?: ProcurementStatus; + requestedBy?: string; + itemName?: string; + }): Promise { + const queryBuilder = this.procurementRequestRepository + .createQueryBuilder('pr') + .leftJoinAndSelect('pr.assetRegistration', 'ar') + .orderBy('pr.requestedAt', 'DESC'); + + if (filters?.status) { + queryBuilder.andWhere('pr.status = :status', { status: filters.status }); + } + + if (filters?.requestedBy) { + queryBuilder.andWhere('pr.requestedBy = :requestedBy', { + requestedBy: filters.requestedBy + }); + } + + if (filters?.itemName) { + queryBuilder.andWhere('LOWER(pr.itemName) LIKE LOWER(:itemName)', { + itemName: `%${filters.itemName}%` + }); + } + + return await queryBuilder.getMany(); + } + + /** + * Find one procurement request by ID + */ + async findOne(id: number): Promise { + const procurementRequest = await this.procurementRequestRepository.findOne({ + where: { id } as FindOptionsWhere, + relations: ['assetRegistration'], + }); + + if (!procurementRequest) { + throw new NotFoundException(`Procurement request with ID ${id} not found`); + } + + return procurementRequest; + } + + /** + * Update a procurement request (only if pending) + */ + async update( + id: number, + updateDto: UpdateProcurementRequestDto, + ): Promise { + const procurementRequest = await this.findOne(id); + + if (procurementRequest.status !== ProcurementStatus.PENDING) { + throw new BadRequestException('Cannot update a request that has already been decided'); + } + + Object.assign(procurementRequest, updateDto); + return await this.procurementRequestRepository.save(procurementRequest); + } + + /** + * Approve a procurement request and create asset registration + */ + async approve( + id: number, + approveDto: ApproveProcurementRequestDto, + ): Promise<{ procurementRequest: ProcurementRequest; assetRegistration: AssetRegistration }> { + const procurementRequest = await this.findOne(id); + + if (procurementRequest.status !== ProcurementStatus.PENDING) { + throw new BadRequestException('Request has already been decided'); + } + + // Create asset registration first + const assetRegistration = this.assetRegistrationRepository.create({ + assetName: procurementRequest.itemName, + description: approveDto.description, + serialNumber: approveDto.serialNumber, + model: approveDto.model, + manufacturer: approveDto.manufacturer, + cost: approveDto.cost, + assignedTo: approveDto.assignedTo, + location: approveDto.location, + status: AssetStatus.PENDING, + assetId: '', // Will be set after save + }); + + const savedAssetRegistration = await this.assetRegistrationRepository.save(assetRegistration); + + // Generate asset ID based on saved ID + savedAssetRegistration.assetId = savedAssetRegistration.generateAssetId(); + await this.assetRegistrationRepository.save(savedAssetRegistration); + + // Update procurement request + procurementRequest.status = ProcurementStatus.APPROVED; + procurementRequest.decidedAt = new Date(); + procurementRequest.decidedBy = approveDto.decidedBy; + procurementRequest.notes = approveDto.notes || procurementRequest.notes; + procurementRequest.assetRegistrationId = savedAssetRegistration.id; + + const savedProcurementRequest = await this.procurementRequestRepository.save(procurementRequest); + + return { + procurementRequest: savedProcurementRequest, + assetRegistration: savedAssetRegistration, + }; + } + + /** + * Reject a procurement request + */ + async reject( + id: number, + rejectDto: RejectProcurementRequestDto, + ): Promise { + const procurementRequest = await this.findOne(id); + + if (procurementRequest.status !== ProcurementStatus.PENDING) { + throw new BadRequestException('Request has already been decided'); + } + + procurementRequest.status = ProcurementStatus.REJECTED; + procurementRequest.decidedAt = new Date(); + procurementRequest.decidedBy = rejectDto.decidedBy; + procurementRequest.notes = rejectDto.notes || procurementRequest.notes; + + return await this.procurementRequestRepository.save(procurementRequest); + } + + /** + * Delete a procurement request (only if pending) + */ + async remove(id: number): Promise { + const procurementRequest = await this.findOne(id); + + if (procurementRequest.status !== ProcurementStatus.PENDING) { + throw new BadRequestException('Cannot delete a request that has already been decided'); + } + + await this.procurementRequestRepository.remove(procurementRequest); + } + + /** + * Get asset registration by procurement request ID + */ + async getAssetRegistration(procurementRequestId: number): Promise { + const procurementRequest = await this.findOne(procurementRequestId); + + if (!procurementRequest.assetRegistration) { + throw new NotFoundException('No asset registration found for this procurement request'); + } + + return procurementRequest.assetRegistration; + } + + /** + * Get asset registration by asset ID + */ + async getAssetByAssetId(assetId: string): Promise { + const assetRegistration = await this.assetRegistrationRepository.findOne({ + where: { assetId } as FindOptionsWhere, + relations: ['procurementRequest'], + }); + + if (!assetRegistration) { + throw new NotFoundException(`Asset with ID ${assetId} not found`); + } + + return assetRegistration; + } + + /** + * Get all asset registrations with filtering + */ + async getAllAssets(filters?: { + status?: AssetStatus; + assignedTo?: string; + location?: string; + }): Promise { + const queryBuilder = this.assetRegistrationRepository + .createQueryBuilder('ar') + .leftJoinAndSelect('ar.procurementRequest', 'pr') + .orderBy('ar.createdAt', 'DESC'); + + if (filters?.status) { + queryBuilder.andWhere('ar.status = :status', { status: filters.status }); + } + + if (filters?.assignedTo) { + queryBuilder.andWhere('ar.assignedTo = :assignedTo', { + assignedTo: filters.assignedTo + }); + } + + if (filters?.location) { + queryBuilder.andWhere('LOWER(ar.location) LIKE LOWER(:location)', { + location: `%${filters.location}%` + }); + } + + return await queryBuilder.getMany(); + } + + /** + * Update asset registration status + */ + async updateAssetStatus( + assetId: string, + status: AssetStatus, + updatedBy?: string, + ): Promise { + const assetRegistration = await this.getAssetByAssetId(assetId); + + assetRegistration.status = status; + + return await this.assetRegistrationRepository.save(assetRegistration); + } + + /** + * Get procurement summary statistics + */ + async getSummary(): Promise { + const [ + totalRequests, + pendingRequests, + approvedRequests, + rejectedRequests, + totalAssetsCreated, + ] = await Promise.all([ + this.procurementRequestRepository.count(), + this.procurementRequestRepository.count({ + where: { status: ProcurementStatus.PENDING } + }), + this.procurementRequestRepository.count({ + where: { status: ProcurementStatus.APPROVED } + }), + this.procurementRequestRepository.count({ + where: { status: ProcurementStatus.REJECTED } + }), + this.assetRegistrationRepository.count(), + ]); + + return new ProcurementSummaryDto({ + totalRequests, + pendingRequests, + approvedRequests, + rejectedRequests, + totalAssetsCreated, + }); + } + + /** + * Get pending requests by user + */ + async getPendingRequestsByUser(requestedBy: string): Promise { + return await this.procurementRequestRepository.find({ + where: { + requestedBy, + status: ProcurementStatus.PENDING, + } as FindOptionsWhere, + order: { requestedAt: 'DESC' }, + }); + } + + /** + * Get assets assigned to a user + */ + async getAssetsByAssignee(assignedTo: string): Promise { + return await this.assetRegistrationRepository.find({ + where: { assignedTo } as FindOptionsWhere, + relations: ['procurementRequest'], + order: { createdAt: 'DESC' }, + }); + } +}