From 70036312687aad89435c654a5120daf7409b16a3 Mon Sep 17 00:00:00 2001 From: Big-cedar Date: Mon, 29 Sep 2025 10:27:11 +0100 Subject: [PATCH 1/2] feat(asset-disposals): add asset retirement module, disposal records, and inventory status updates --- backend/src/app.module.ts | 24 +++------ .../asset-disposals.controller.ts | 24 +++++++++ .../asset-disposals/asset-disposals.module.ts | 14 +++++ .../asset-disposals.service.ts | 54 +++++++++++++++++++ .../dto/create-asset-disposal.dto.ts | 21 ++++++++ .../entities/asset-disposal.entity.ts | 37 +++++++++++++ .../entities/inventory-item.entity.ts | 12 ++++- .../inventory-alerts.module.ts | 2 +- .../inventory-alerts.service.ts | 13 +++-- 9 files changed, 177 insertions(+), 24 deletions(-) create mode 100644 backend/src/asset-disposals/asset-disposals.controller.ts create mode 100644 backend/src/asset-disposals/asset-disposals.module.ts create mode 100644 backend/src/asset-disposals/asset-disposals.service.ts create mode 100644 backend/src/asset-disposals/dto/create-asset-disposal.dto.ts create mode 100644 backend/src/asset-disposals/entities/asset-disposal.entity.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 0c2a507f..fe019b8e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -7,6 +7,9 @@ import { AssetCategoriesModule } from './asset-categories/asset-categories.modul import { AssetCategory } from './asset-categories/asset-category.entity'; import { DepartmentsModule } from './departments/departments.module'; import { Department } from './departments/department.entity'; +import { AssetDisposalsModule } from './asset-disposals/asset-disposals.module'; +import { AssetDisposal } from './asset-disposals/entities/asset-disposal.entity'; +import { InventoryItem } from './inventory/entities/inventory-item.entity'; @Module({ imports: [ @@ -14,23 +17,7 @@ import { Department } from './departments/department.entity'; isGlobal: true, }), TypeOrmModule.forRootAsync({ - imports: [ConfigModule, - // --- ADD THIS CONFIGURATION --- - I18nModule.forRoot({ - fallbackLanguage: 'en', - loaderOptions: { - path: path.join(__dirname, '/i18n/'), // Directory for translation files - watch: true, // Watch for changes in translation files - }, - resolvers: [ - // Order matters: checks query param, then header, then browser settings - new QueryResolver(['lang', 'l']), - new HeaderResolver(['x-custom-lang-header']), - AcceptLanguageResolver, // Standard 'Accept-Language' header - ], - }), - // --- END OF CONFIGURATION --- - ],], + imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ type: 'postgres', host: configService.get('DB_HOST', 'localhost'), @@ -38,13 +25,14 @@ import { Department } from './departments/department.entity'; username: configService.get('DB_USERNAME', 'postgres'), password: configService.get('DB_PASSWORD', 'password'), database: configService.get('DB_DATABASE', 'manage_assets'), - entities: [AssetCategory, Department], + entities: [AssetCategory, Department, InventoryItem, AssetDisposal], synchronize: configService.get('NODE_ENV') !== 'production', // Only for development }), inject: [ConfigService], }), AssetCategoriesModule, DepartmentsModule, + AssetDisposalsModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/asset-disposals/asset-disposals.controller.ts b/backend/src/asset-disposals/asset-disposals.controller.ts new file mode 100644 index 00000000..22eea13a --- /dev/null +++ b/backend/src/asset-disposals/asset-disposals.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Post, Body, Get, Param, UsePipes, ValidationPipe } from '@nestjs/common'; +import { AssetDisposalsService } from './asset-disposals.service'; +import { CreateAssetDisposalDto } from './dto/create-asset-disposal.dto'; + +@Controller('asset-disposals') +export class AssetDisposalsController { + constructor(private readonly disposalsService: AssetDisposalsService) {} + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true })) + markDisposed(@Body() dto: CreateAssetDisposalDto) { + return this.disposalsService.markDisposed(dto); + } + + @Get() + findAll() { + return this.disposalsService.findAll(); + } + + @Get('asset/:assetId') + findByAsset(@Param('assetId') assetId: string) { + return this.disposalsService.findByAsset(assetId); + } +} \ No newline at end of file diff --git a/backend/src/asset-disposals/asset-disposals.module.ts b/backend/src/asset-disposals/asset-disposals.module.ts new file mode 100644 index 00000000..3caf92cc --- /dev/null +++ b/backend/src/asset-disposals/asset-disposals.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetDisposalsService } from './asset-disposals.service'; +import { AssetDisposalsController } from './asset-disposals.controller'; +import { AssetDisposal } from './entities/asset-disposal.entity'; +import { InventoryItem } from '../inventory/entities/inventory-item.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetDisposal, InventoryItem])], + controllers: [AssetDisposalsController], + providers: [AssetDisposalsService], + exports: [AssetDisposalsService], +}) +export class AssetDisposalsModule {} \ No newline at end of file diff --git a/backend/src/asset-disposals/asset-disposals.service.ts b/backend/src/asset-disposals/asset-disposals.service.ts new file mode 100644 index 00000000..15b273e7 --- /dev/null +++ b/backend/src/asset-disposals/asset-disposals.service.ts @@ -0,0 +1,54 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetDisposal } from './entities/asset-disposal.entity'; +import { CreateAssetDisposalDto } from './dto/create-asset-disposal.dto'; +import { InventoryItem, InventoryStatus } from '../inventory/entities/inventory-item.entity'; + +@Injectable() +export class AssetDisposalsService { + constructor( + @InjectRepository(AssetDisposal) + private readonly disposalRepository: Repository, + @InjectRepository(InventoryItem) + private readonly inventoryRepository: Repository, + ) {} + + async markDisposed(dto: CreateAssetDisposalDto) { + const asset = await this.inventoryRepository.findOne({ where: { id: dto.assetId } }); + if (!asset) { + throw new NotFoundException(`Asset with ID ${dto.assetId} not found`); + } + + if (asset.status === InventoryStatus.DISPOSED) { + throw new BadRequestException('Asset is already disposed'); + } + + return await this.inventoryRepository.manager.transaction(async (manager) => { + const itemRepo = manager.getRepository(InventoryItem); + const disposalRepo = manager.getRepository(AssetDisposal); + + asset.status = InventoryStatus.DISPOSED; + await itemRepo.save(asset); + + const disposal = disposalRepo.create({ + assetId: dto.assetId, + disposalDate: new Date(dto.disposalDate), + method: dto.method, + reason: dto.reason, + approvedBy: dto.approvedBy, + }); + const saved = await disposalRepo.save(disposal); + + return { message: 'Asset marked as disposed', disposal: saved }; + }); + } + + async findAll() { + return this.disposalRepository.find({ order: { disposalDate: 'DESC' } }); + } + + async findByAsset(assetId: string) { + return this.disposalRepository.find({ where: { assetId }, order: { disposalDate: 'DESC' } }); + } +} \ No newline at end of file diff --git a/backend/src/asset-disposals/dto/create-asset-disposal.dto.ts b/backend/src/asset-disposals/dto/create-asset-disposal.dto.ts new file mode 100644 index 00000000..b343f338 --- /dev/null +++ b/backend/src/asset-disposals/dto/create-asset-disposal.dto.ts @@ -0,0 +1,21 @@ +import { IsUUID, IsDateString, IsEnum, IsString, IsNotEmpty, IsOptional } from 'class-validator'; +import { DisposalMethod } from '../entities/asset-disposal.entity'; + +export class CreateAssetDisposalDto { + @IsUUID() + assetId: string; + + @IsDateString() + disposalDate: string; + + @IsEnum(DisposalMethod) + method: DisposalMethod; + + @IsString() + @IsOptional() + reason?: string; + + @IsString() + @IsNotEmpty() + approvedBy: string; +} \ No newline at end of file diff --git a/backend/src/asset-disposals/entities/asset-disposal.entity.ts b/backend/src/asset-disposals/entities/asset-disposal.entity.ts new file mode 100644 index 00000000..f4e7ab5d --- /dev/null +++ b/backend/src/asset-disposals/entities/asset-disposal.entity.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +export enum DisposalMethod { + SALE = 'sale', + DONATION = 'donation', + SCRAP = 'scrap', + OTHER = 'other', +} + +@Entity('asset_disposals') +@Index(['assetId', 'disposalDate']) +export class AssetDisposal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + @Index() + assetId: string; + + @Column({ type: 'timestamp' }) + disposalDate: Date; + + @Column({ type: 'enum', enum: DisposalMethod }) + method: DisposalMethod; + + @Column({ type: 'text', nullable: true }) + reason: string; + + @Column() + approvedBy: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/inventory/entities/inventory-item.entity.ts b/backend/src/inventory/entities/inventory-item.entity.ts index 79a45875..33556250 100644 --- a/backend/src/inventory/entities/inventory-item.entity.ts +++ b/backend/src/inventory/entities/inventory-item.entity.ts @@ -1,5 +1,10 @@ import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +export enum InventoryStatus { + ACTIVE = 'active', + DISPOSED = 'disposed', +} + @Entity() export class InventoryItem { @PrimaryGeneratedColumn('uuid') @@ -14,7 +19,10 @@ export class InventoryItem { @Column({ type: 'int' }) quantity: number; - // --- ADD THIS NEW FIELD --- - @Column({ type: 'int', default: 10 }) // Default threshold of 10 units + // Default threshold of 10 units + @Column({ type: 'int', default: 10 }) threshold: number; + + @Column({ type: 'enum', enum: InventoryStatus, default: InventoryStatus.ACTIVE }) + status: InventoryStatus; } \ No newline at end of file diff --git a/backend/src/inventory/inventory-alerts/inventory-alerts.module.ts b/backend/src/inventory/inventory-alerts/inventory-alerts.module.ts index fef049f8..1c50640a 100644 --- a/backend/src/inventory/inventory-alerts/inventory-alerts.module.ts +++ b/backend/src/inventory/inventory-alerts/inventory-alerts.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { InventoryItem } from '../inventory/entities/inventory-item.entity'; +import { InventoryItem } from '../entities/inventory-item.entity'; import { InventoryAlertsService } from './inventory-alerts.service'; import { InventoryAlertsController } from './inventory-alerts.controller'; import { InventoryEventListener } from './listeners/inventory.listener'; diff --git a/backend/src/inventory/inventory-alerts/inventory-alerts.service.ts b/backend/src/inventory/inventory-alerts/inventory-alerts.service.ts index 8daee2cf..6e955d5f 100644 --- a/backend/src/inventory/inventory-alerts/inventory-alerts.service.ts +++ b/backend/src/inventory/inventory-alerts/inventory-alerts.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { InventoryItem } from '../inventory/entities/inventory-item.entity'; +import { InventoryItem, InventoryStatus } from '../entities/inventory-item.entity'; export interface LowStockAlert { itemId: string; @@ -38,6 +38,15 @@ export class InventoryAlertsService { return; } + // Ignore disposed items + if (item.status === InventoryStatus.DISPOSED) { + if (this.activeAlerts.has(item.id)) { + this.activeAlerts.delete(item.id); + } + this.logger.log(`Skipping threshold check for disposed item ${item.name} (SKU: ${item.sku}).`); + return; + } + if (item.quantity <= item.threshold) { // Stock is low or has reached the threshold, create/update an alert. if (!this.activeAlerts.has(item.id)) { @@ -51,8 +60,6 @@ export class InventoryAlertsService { }; this.activeAlerts.set(item.id, newAlert); this.logger.log(`New low-stock alert generated for ${item.name} (SKU: ${item.sku})`); - // Here you could also emit an event to trigger notifications (email, etc.) - // this.eventEmitter.emit('alert.low_stock', newAlert); } } else { // Stock is sufficient, remove any existing alert. From f7cf4d11fe87a5d5c64850b5285c242a0b395459 Mon Sep 17 00:00:00 2001 From: Big-cedar Date: Mon, 29 Sep 2025 12:07:01 +0100 Subject: [PATCH 2/2] feat(asset-maintenance): add maintenance scheduling and history with disposed-asset guard --- backend/src/app.module.ts | 5 +- .../asset-maintenance.controller.ts | 31 +++++++++ .../asset-maintenance.module.ts | 14 +++++ .../asset-maintenance.service.ts | 63 +++++++++++++++++++ .../dto/complete-maintenance.dto.ts | 10 +++ .../dto/schedule-maintenance.dto.ts | 17 +++++ .../entities/asset-maintenance.entity.ts | 39 ++++++++++++ 7 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 backend/src/asset-maintenance/asset-maintenance.controller.ts create mode 100644 backend/src/asset-maintenance/asset-maintenance.module.ts create mode 100644 backend/src/asset-maintenance/asset-maintenance.service.ts create mode 100644 backend/src/asset-maintenance/dto/complete-maintenance.dto.ts create mode 100644 backend/src/asset-maintenance/dto/schedule-maintenance.dto.ts create mode 100644 backend/src/asset-maintenance/entities/asset-maintenance.entity.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index fe019b8e..e9f081eb 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -10,6 +10,8 @@ import { Department } from './departments/department.entity'; import { AssetDisposalsModule } from './asset-disposals/asset-disposals.module'; import { AssetDisposal } from './asset-disposals/entities/asset-disposal.entity'; import { InventoryItem } from './inventory/entities/inventory-item.entity'; +import { AssetMaintenanceModule } from './asset-maintenance/asset-maintenance.module'; +import { AssetMaintenance } from './asset-maintenance/entities/asset-maintenance.entity'; @Module({ imports: [ @@ -25,7 +27,7 @@ import { InventoryItem } from './inventory/entities/inventory-item.entity'; username: configService.get('DB_USERNAME', 'postgres'), password: configService.get('DB_PASSWORD', 'password'), database: configService.get('DB_DATABASE', 'manage_assets'), - entities: [AssetCategory, Department, InventoryItem, AssetDisposal], + entities: [AssetCategory, Department, InventoryItem, AssetDisposal, AssetMaintenance], synchronize: configService.get('NODE_ENV') !== 'production', // Only for development }), inject: [ConfigService], @@ -33,6 +35,7 @@ import { InventoryItem } from './inventory/entities/inventory-item.entity'; AssetCategoriesModule, DepartmentsModule, AssetDisposalsModule, + AssetMaintenanceModule, ], controllers: [AppController], providers: [AppService], diff --git a/backend/src/asset-maintenance/asset-maintenance.controller.ts b/backend/src/asset-maintenance/asset-maintenance.controller.ts new file mode 100644 index 00000000..38376d8e --- /dev/null +++ b/backend/src/asset-maintenance/asset-maintenance.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Post, Body, Get, Param, Patch, UsePipes, ValidationPipe } from '@nestjs/common'; +import { AssetMaintenanceService } from './asset-maintenance.service'; +import { ScheduleMaintenanceDto } from './dto/schedule-maintenance.dto'; +import { CompleteMaintenanceDto } from './dto/complete-maintenance.dto'; + +@Controller('asset-maintenance') +export class AssetMaintenanceController { + constructor(private readonly maintenanceService: AssetMaintenanceService) {} + + @Post('schedule') + @UsePipes(new ValidationPipe({ whitelist: true })) + schedule(@Body() dto: ScheduleMaintenanceDto) { + return this.maintenanceService.schedule(dto); + } + + @Patch(':id/complete') + @UsePipes(new ValidationPipe({ whitelist: true })) + complete(@Param('id') id: string, @Body() dto: CompleteMaintenanceDto) { + return this.maintenanceService.complete(id, dto); + } + + @Get() + findAll() { + return this.maintenanceService.findAll(); + } + + @Get('asset/:assetId') + findByAsset(@Param('assetId') assetId: string) { + return this.maintenanceService.findByAsset(assetId); + } +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/asset-maintenance.module.ts b/backend/src/asset-maintenance/asset-maintenance.module.ts new file mode 100644 index 00000000..16e67cbb --- /dev/null +++ b/backend/src/asset-maintenance/asset-maintenance.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetMaintenanceService } from './asset-maintenance.service'; +import { AssetMaintenanceController } from './asset-maintenance.controller'; +import { AssetMaintenance } from './entities/asset-maintenance.entity'; +import { InventoryItem } from '../inventory/entities/inventory-item.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetMaintenance, InventoryItem])], + controllers: [AssetMaintenanceController], + providers: [AssetMaintenanceService], + exports: [AssetMaintenanceService], +}) +export class AssetMaintenanceModule {} \ No newline at end of file diff --git a/backend/src/asset-maintenance/asset-maintenance.service.ts b/backend/src/asset-maintenance/asset-maintenance.service.ts new file mode 100644 index 00000000..4d0a8d71 --- /dev/null +++ b/backend/src/asset-maintenance/asset-maintenance.service.ts @@ -0,0 +1,63 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetMaintenance } from './entities/asset-maintenance.entity'; +import { ScheduleMaintenanceDto } from './dto/schedule-maintenance.dto'; +import { CompleteMaintenanceDto } from './dto/complete-maintenance.dto'; +import { InventoryItem, InventoryStatus } from '../inventory/entities/inventory-item.entity'; + +@Injectable() +export class AssetMaintenanceService { + constructor( + @InjectRepository(AssetMaintenance) + private readonly maintenanceRepository: Repository, + @InjectRepository(InventoryItem) + private readonly inventoryRepository: Repository, + ) {} + + async schedule(dto: ScheduleMaintenanceDto) { + const asset = await this.inventoryRepository.findOne({ where: { id: dto.assetId } }); + if (!asset) { + throw new NotFoundException(`Asset with ID ${dto.assetId} not found`); + } + + // Prevent scheduling maintenance for disposed assets + if (asset.status === InventoryStatus.DISPOSED) { + throw new BadRequestException('Cannot schedule maintenance for disposed asset'); + } + + const record = this.maintenanceRepository.create({ + assetId: dto.assetId, + scheduledDate: new Date(dto.scheduledDate), + completedDate: null, + maintenanceType: dto.maintenanceType, + notes: dto.notes, + }); + + const saved = await this.maintenanceRepository.save(record); + return { message: 'Maintenance scheduled', maintenance: saved }; + } + + async complete(id: string, dto: CompleteMaintenanceDto) { + const record = await this.maintenanceRepository.findOne({ where: { id } }); + if (!record) { + throw new NotFoundException(`Maintenance record with ID ${id} not found`); + } + + record.completedDate = new Date(dto.completedDate); + if (dto.notes) { + record.notes = dto.notes; + } + + const saved = await this.maintenanceRepository.save(record); + return { message: 'Maintenance completed', maintenance: saved }; + } + + async findAll() { + return this.maintenanceRepository.find({ order: { scheduledDate: 'DESC' } }); + } + + async findByAsset(assetId: string) { + return this.maintenanceRepository.find({ where: { assetId }, order: { scheduledDate: 'DESC' } }); + } +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/dto/complete-maintenance.dto.ts b/backend/src/asset-maintenance/dto/complete-maintenance.dto.ts new file mode 100644 index 00000000..ce9e9dbc --- /dev/null +++ b/backend/src/asset-maintenance/dto/complete-maintenance.dto.ts @@ -0,0 +1,10 @@ +import { IsDateString, IsString, IsOptional } from 'class-validator'; + +export class CompleteMaintenanceDto { + @IsDateString() + completedDate: string; + + @IsString() + @IsOptional() + notes?: string; +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/dto/schedule-maintenance.dto.ts b/backend/src/asset-maintenance/dto/schedule-maintenance.dto.ts new file mode 100644 index 00000000..9e7a6133 --- /dev/null +++ b/backend/src/asset-maintenance/dto/schedule-maintenance.dto.ts @@ -0,0 +1,17 @@ +import { IsUUID, IsDateString, IsEnum, IsString, IsOptional } from 'class-validator'; +import { MaintenanceType } from '../entities/asset-maintenance.entity'; + +export class ScheduleMaintenanceDto { + @IsUUID() + assetId: string; + + @IsDateString() + scheduledDate: string; + + @IsEnum(MaintenanceType) + maintenanceType: MaintenanceType; + + @IsString() + @IsOptional() + notes?: string; +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/entities/asset-maintenance.entity.ts b/backend/src/asset-maintenance/entities/asset-maintenance.entity.ts new file mode 100644 index 00000000..6d9f3e52 --- /dev/null +++ b/backend/src/asset-maintenance/entities/asset-maintenance.entity.ts @@ -0,0 +1,39 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +export enum MaintenanceType { + PREVENTIVE = 'preventive', + CORRECTIVE = 'corrective', + INSPECTION = 'inspection', + REPLACEMENT = 'replacement', + SERVICE = 'service', + OTHER = 'other', +} + +@Entity('asset_maintenance') +@Index(['assetId', 'scheduledDate']) +export class AssetMaintenance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + @Index() + assetId: string; + + @Column({ type: 'timestamp' }) + scheduledDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + completedDate: Date | null; + + @Column({ type: 'enum', enum: MaintenanceType }) + maintenanceType: MaintenanceType; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file