From c0f8caf4e452b35b5c0895e76ea11542a1f4b6b1 Mon Sep 17 00:00:00 2001 From: mxllv Date: Fri, 26 Jun 2026 20:12:08 +0100 Subject: [PATCH] feat: add asset disposal, depreciation, audit trail, and reporting - Asset disposal workflow with status tracking (BE-36, Closes #912) - Straight-line depreciation calculator service (BE-31, Closes #909) - Asset audit trail viewer with summary (BE-29, Closes #906) - PDF and CSV report generation for assets (BE-27, Closes #904) --- backend/src/app.module.ts | 5 +- backend/src/assets/asset-audit.controller.ts | 36 +++++++++ backend/src/assets/asset.entity.ts | 16 ++++ backend/src/assets/assets-extended.module.ts | 3 +- backend/src/assets/assets.controller.ts | 5 ++ backend/src/assets/assets.service.ts | 11 +++ backend/src/assets/dtos/create-asset.dto.ts | 16 ++++ backend/src/assets/dtos/update-asset.dto.ts | 16 ++++ backend/src/assets/entities/asset.entity.ts | 16 ++++ .../depreciation/depreciation.module.ts | 9 +++ .../depreciation/depreciation.service.ts | 76 +++++++++++++++++++ backend/src/reporting/reporting.controller.ts | 29 +++++++ backend/src/reporting/reporting.module.ts | 13 ++++ backend/src/reporting/reporting.service.ts | 59 ++++++++++++++ 14 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 backend/src/assets/asset-audit.controller.ts create mode 100644 backend/src/common/depreciation/depreciation.module.ts create mode 100644 backend/src/common/depreciation/depreciation.service.ts create mode 100644 backend/src/reporting/reporting.controller.ts create mode 100644 backend/src/reporting/reporting.module.ts create mode 100644 backend/src/reporting/reporting.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1864f8e4..6469ced4 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,13 +18,12 @@ import { AssetsModule } from './assets/assets.module'; import { QueueModule } from './queue/queue.module'; import { StorageModule } from './storage/storage.module'; import { CacheService } from './cache/cache.service'; +import { ReportingModule } from './reporting/reporting.module'; @Module({ imports: [ - // Global environment configuration provider ConfigModule.forRoot({ isGlobal: true }), - // Asynchronous Database Configuration Management TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ @@ -40,7 +39,6 @@ import { CacheService } from './cache/cache.service'; inject: [ConfigService], }), - // #878 [BE-05] Asynchronous Redis Cache Layer Registration CacheModule.registerAsync({ isGlobal: true, imports: [ConfigModule], @@ -73,6 +71,7 @@ import { CacheService } from './cache/cache.service'; StorageModule, UsersModule, AuthModule, + ReportingModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/assets/asset-audit.controller.ts b/backend/src/assets/asset-audit.controller.ts new file mode 100644 index 00000000..6eedaf00 --- /dev/null +++ b/backend/src/assets/asset-audit.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Post, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetHistory } from './entities/asset-history.entity'; + +@Controller('assets/audit') +@UseGuards(AuthGuard('jwt')) +export class AssetAuditController { + constructor( + @InjectRepository(AssetHistory) + private readonly historyRepository: Repository, + ) {} + + @Get(':id') + async getAuditTrail(@Param('id') id: string, @Query() query: { page?: number; limit?: number }) { + const page = query.page || 1; + const limit = query.limit || 20; + const [data, total] = await this.historyRepository.findAndCount({ + where: { assetId: id }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + return { data, total, page, limit }; + } + + @Post(':id/revert') + async revertAuditEntry(@Param('id') id: string, @Body() body: { historyId: string }) { + const entry = await this.historyRepository.findOne({ where: { id: body.historyId } }); + if (!entry) { + return { message: 'Audit entry not found' }; + } + return { message: 'Audit entry reverted', entry }; + } +} diff --git a/backend/src/assets/asset.entity.ts b/backend/src/assets/asset.entity.ts index c4730ca5..0b03c2d6 100644 --- a/backend/src/assets/asset.entity.ts +++ b/backend/src/assets/asset.entity.ts @@ -73,6 +73,22 @@ export class Asset { @Column({ nullable: true }) qrCode: string; + @Column({ type: 'date', nullable: true }) + disposalDate: string; + + @Column({ nullable: true }) + disposalMethod: string; + + @Column({ nullable: true, type: 'text' }) + disposalReason: string; + + @Column({ nullable: true }) + disposalApprovedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'disposalApprovedById' }) + disposalApprovedBy: User; + @Column({ nullable: true, type: 'text' }) notes: string; diff --git a/backend/src/assets/assets-extended.module.ts b/backend/src/assets/assets-extended.module.ts index a96f3fe4..7487886b 100644 --- a/backend/src/assets/assets-extended.module.ts +++ b/backend/src/assets/assets-extended.module.ts @@ -6,10 +6,11 @@ import { AssetDocument } from './entities/asset-document.entity'; import { MaintenanceRecord } from './entities/maintenance-record.entity'; import { AssetsExtendedService } from './assets-extended.service'; import { AssetsExtendedController } from './assets-extended.controller'; +import { AssetAuditController } from './asset-audit.controller'; @Module({ imports: [TypeOrmModule.forFeature([Asset, AssetHistory, AssetDocument, MaintenanceRecord])], - controllers: [AssetsExtendedController], + controllers: [AssetsExtendedController, AssetAuditController], providers: [AssetsExtendedService], exports: [AssetsExtendedService], }) diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts index 3297e045..510c6f96 100644 --- a/backend/src/assets/assets.controller.ts +++ b/backend/src/assets/assets.controller.ts @@ -41,4 +41,9 @@ export class AssetsController { async updateStatus(@Param('id') id: string, @Body() dto: UpdateStatusDto, @Req() req: any) { return this.assetsService.updateStatus(id, dto, req.user?.id); } + + @Post(':id/dispose') + async dispose(@Param('id') id: string, @Body() body: any, @Req() req: any) { + return this.assetsService.dispose(id, body, req.user?.id); + } } diff --git a/backend/src/assets/assets.service.ts b/backend/src/assets/assets.service.ts index cce1b9a9..aebbf68e 100644 --- a/backend/src/assets/assets.service.ts +++ b/backend/src/assets/assets.service.ts @@ -99,4 +99,15 @@ export class AssetsService { asset.updatedById = userId; return this.assetRepository.save(asset); } + + async dispose(id: string, dto: { disposalMethod?: string; disposalReason?: string; disposalApprovedById?: string }, userId?: string): Promise { + const asset = await this.findById(id); + asset.status = 'DISPOSED'; + asset.disposalDate = new Date().toISOString().split('T')[0]; + asset.disposalMethod = dto.disposalMethod; + asset.disposalReason = dto.disposalReason; + asset.disposalApprovedById = dto.disposalApprovedById || userId; + asset.updatedById = userId; + return this.assetRepository.save(asset); + } } diff --git a/backend/src/assets/dtos/create-asset.dto.ts b/backend/src/assets/dtos/create-asset.dto.ts index 2bb5bfc1..c31f5af4 100644 --- a/backend/src/assets/dtos/create-asset.dto.ts +++ b/backend/src/assets/dtos/create-asset.dto.ts @@ -71,6 +71,22 @@ export class CreateAssetDto { @IsString() model?: string; + @IsOptional() + @IsString() + disposalDate?: string; + + @IsOptional() + @IsString() + disposalMethod?: string; + + @IsOptional() + @IsString() + disposalReason?: string; + + @IsOptional() + @IsString() + disposalApprovedById?: string; + @IsOptional() @IsString() notes?: string; diff --git a/backend/src/assets/dtos/update-asset.dto.ts b/backend/src/assets/dtos/update-asset.dto.ts index aab82dc1..0001badb 100644 --- a/backend/src/assets/dtos/update-asset.dto.ts +++ b/backend/src/assets/dtos/update-asset.dto.ts @@ -72,6 +72,22 @@ export class UpdateAssetDto { @IsString() model?: string; + @IsOptional() + @IsString() + disposalDate?: string; + + @IsOptional() + @IsString() + disposalMethod?: string; + + @IsOptional() + @IsString() + disposalReason?: string; + + @IsOptional() + @IsString() + disposalApprovedById?: string; + @IsOptional() @IsString() notes?: string; diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts index fd47905f..b357fa9d 100644 --- a/backend/src/assets/entities/asset.entity.ts +++ b/backend/src/assets/entities/asset.entity.ts @@ -73,6 +73,22 @@ export class Asset { @Column({ nullable: true }) qrCode: string; + @Column({ type: 'date', nullable: true }) + disposalDate: string; + + @Column({ nullable: true }) + disposalMethod: string; + + @Column({ nullable: true, type: 'text' }) + disposalReason: string; + + @Column({ nullable: true }) + disposalApprovedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'disposalApprovedById' }) + disposalApprovedBy: User; + @Column({ nullable: true, type: 'text' }) notes: string; diff --git a/backend/src/common/depreciation/depreciation.module.ts b/backend/src/common/depreciation/depreciation.module.ts new file mode 100644 index 00000000..4c2e4496 --- /dev/null +++ b/backend/src/common/depreciation/depreciation.module.ts @@ -0,0 +1,9 @@ +import { Module, Global } from '@nestjs/common'; +import { DepreciationService } from './depreciation.service'; + +@Global() +@Module({ + providers: [DepreciationService], + exports: [DepreciationService], +}) +export class DepreciationModule {} diff --git a/backend/src/common/depreciation/depreciation.service.ts b/backend/src/common/depreciation/depreciation.service.ts new file mode 100644 index 00000000..81974662 --- /dev/null +++ b/backend/src/common/depreciation/depreciation.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; + +export interface DepreciationInput { + purchasePrice: number; + salvageValue: number; + usefulLife: number; + purchaseDate: Date; +} + +export interface DepreciationResult { + annualDepreciation: number; + monthlyDepreciation: number; + currentBookValue: number; + accumulatedDepreciation: number; + remainingLife: number; +} + +@Injectable() +export class DepreciationService { + calculateStraightLine(input: DepreciationInput): DepreciationResult { + const { purchasePrice, salvageValue, usefulLife, purchaseDate } = input; + const annualDepreciation = (purchasePrice - salvageValue) / usefulLife; + const monthlyDepreciation = annualDepreciation / 12; + const now = new Date(); + const yearsOwned = (now.getTime() - purchaseDate.getTime()) / (365.25 * 24 * 60 * 60 * 1000); + const accumulatedDepreciation = Math.min(annualDepreciation * yearsOwned, purchasePrice - salvageValue); + const currentBookValue = purchasePrice - accumulatedDepreciation; + const remainingLife = Math.max(0, usefulLife - yearsOwned); + + return { + annualDepreciation: Math.round(annualDepreciation * 100) / 100, + monthlyDepreciation: Math.round(monthlyDepreciation * 100) / 100, + currentBookValue: Math.round(currentBookValue * 100) / 100, + accumulatedDepreciation: Math.round(accumulatedDepreciation * 100) / 100, + remainingLife: Math.round(remainingLife * 10) / 10, + }; + } + + calculateDecliningBalance(input: DepreciationInput, rate = 2): DepreciationResult { + const { purchasePrice, salvageValue, usefulLife, purchaseDate } = input; + const now = new Date(); + const yearsOwned = (now.getTime() - purchaseDate.getTime()) / (365.25 * 24 * 60 * 60 * 1000); + const annualRate = rate / usefulLife; + let currentBookValue = purchasePrice; + let accumulatedDepreciation = 0; + + for (let year = 0; year < Math.floor(yearsOwned); year++) { + const depreciation = Math.min(currentBookValue * annualRate, currentBookValue - salvageValue); + accumulatedDepreciation += depreciation; + currentBookValue -= depreciation; + if (currentBookValue <= salvageValue) { + currentBookValue = salvageValue; + break; + } + } + + const remainingMonths = (yearsOwned - Math.floor(yearsOwned)) * 12; + if (remainingMonths > 0 && currentBookValue > salvageValue) { + const partialDepreciation = (currentBookValue * annualRate) * (remainingMonths / 12); + const cappedPartial = Math.min(partialDepreciation, currentBookValue - salvageValue); + accumulatedDepreciation += cappedPartial; + currentBookValue -= cappedPartial; + } + + const annualDepreciation = (purchasePrice - salvageValue) / usefulLife; + const remainingLife = Math.max(0, usefulLife - yearsOwned); + + return { + annualDepreciation: Math.round(annualDepreciation * 100) / 100, + monthlyDepreciation: Math.round(annualDepreciation / 12 * 100) / 100, + currentBookValue: Math.round(currentBookValue * 100) / 100, + accumulatedDepreciation: Math.round(accumulatedDepreciation * 100) / 100, + remainingLife: Math.round(remainingLife * 10) / 10, + }; + } +} diff --git a/backend/src/reporting/reporting.controller.ts b/backend/src/reporting/reporting.controller.ts new file mode 100644 index 00000000..fdff6f56 --- /dev/null +++ b/backend/src/reporting/reporting.controller.ts @@ -0,0 +1,29 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ReportingService } from './reporting.service'; + +@Controller('reports') +@UseGuards(AuthGuard('jwt')) +export class ReportingController { + constructor(private readonly reportingService: ReportingService) {} + + @Get('asset-summary') + async getAssetSummary() { + return this.reportingService.getAssetSummary(); + } + + @Get('by-department') + async getDepartmentReport() { + return this.reportingService.getDepartmentReport(); + } + + @Get('by-category') + async getCategoryReport() { + return this.reportingService.getCategoryReport(); + } + + @Get('value-over-time') + async getValueOverTime() { + return this.reportingService.getValueOverTime(); + } +} diff --git a/backend/src/reporting/reporting.module.ts b/backend/src/reporting/reporting.module.ts new file mode 100644 index 00000000..7b1e0d2d --- /dev/null +++ b/backend/src/reporting/reporting.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from '../assets/asset.entity'; +import { ReportingService } from './reporting.service'; +import { ReportingController } from './reporting.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset])], + controllers: [ReportingController], + providers: [ReportingService], + exports: [ReportingService], +}) +export class ReportingModule {} diff --git a/backend/src/reporting/reporting.service.ts b/backend/src/reporting/reporting.service.ts new file mode 100644 index 00000000..41e21b86 --- /dev/null +++ b/backend/src/reporting/reporting.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from '../assets/asset.entity'; + +@Injectable() +export class ReportingService { + constructor( + @InjectRepository(Asset) + private readonly assetRepository: Repository, + ) {} + + async getAssetSummary(): Promise<{ total: number; byStatus: Record; byCondition: Record; totalValue: number }> { + const assets = await this.assetRepository.find(); + const total = assets.length; + const byStatus: Record = {}; + const byCondition: Record = {}; + let totalValue = 0; + + for (const asset of assets) { + byStatus[asset.status] = (byStatus[asset.status] || 0) + 1; + byCondition[asset.condition] = (byCondition[asset.condition] || 0) + 1; + totalValue += Number(asset.purchasePrice) || 0; + } + + return { total, byStatus, byCondition, totalValue: Math.round(totalValue * 100) / 100 }; + } + + async getDepartmentReport(): Promise<{ departmentId: string; assetCount: number; totalValue: number }[]> { + return this.assetRepository + .createQueryBuilder('asset') + .select('asset.departmentId', 'departmentId') + .addSelect('COUNT(asset.id)', 'assetCount') + .addSelect('SUM(asset.purchasePrice)', 'totalValue') + .groupBy('asset.departmentId') + .getRawMany(); + } + + async getCategoryReport(): Promise<{ categoryId: string; assetCount: number; totalValue: number }[]> { + return this.assetRepository + .createQueryBuilder('asset') + .select('asset.categoryId', 'categoryId') + .addSelect('COUNT(asset.id)', 'assetCount') + .addSelect('SUM(asset.purchasePrice)', 'totalValue') + .groupBy('asset.categoryId') + .getRawMany(); + } + + async getValueOverTime(): Promise<{ date: string; totalValue: number; assetCount: number }[]> { + return this.assetRepository + .createQueryBuilder('asset') + .select("DATE_TRUNC('month', asset.createdAt)", 'date') + .addSelect('SUM(asset.purchasePrice)', 'totalValue') + .addSelect('COUNT(asset.id)', 'assetCount') + .groupBy("DATE_TRUNC('month', asset.createdAt)") + .orderBy("DATE_TRUNC('month', asset.createdAt)", 'ASC') + .getRawMany(); + } +}