From 2dad407f713181eab22f90fd81bc8cceb5b1bbae Mon Sep 17 00:00:00 2001 From: kike-alt Date: Fri, 26 Jun 2026 20:04:39 +0100 Subject: [PATCH] feat: add Contract, License, PurchaseOrder, and Scheduled Tasks modules - Contract entity with CRUD and file upload support (BE-28, #905) - License entity with CRUD and seat management (BE-30, #907) - PurchaseOrder entity with CRUD and item management (BE-35, #910) - Scheduled tasks for department asset summaries (BE-40, #913) --- backend/src/app.module.ts | 11 ++- backend/src/contracts/contracts.controller.ts | 52 +++++++++++ backend/src/contracts/contracts.module.ts | 14 +++ backend/src/contracts/contracts.service.ts | 73 +++++++++++++++ .../src/contracts/dtos/contract-query.dto.ts | 33 +++++++ .../src/contracts/dtos/create-contract.dto.ts | 37 ++++++++ .../src/contracts/dtos/update-contract.dto.ts | 39 ++++++++ .../src/contracts/entities/contract.entity.ts | 55 ++++++++++++ .../src/licenses/dtos/create-license.dto.ts | 46 ++++++++++ .../src/licenses/dtos/license-query.dto.ts | 29 ++++++ .../src/licenses/dtos/update-license.dto.ts | 47 ++++++++++ .../src/licenses/entities/license.entity.ts | 61 +++++++++++++ backend/src/licenses/licenses.controller.ts | 38 ++++++++ backend/src/licenses/licenses.module.ts | 13 +++ backend/src/licenses/licenses.service.ts | 65 ++++++++++++++ .../dtos/create-purchase-order.dto.ts | 60 +++++++++++++ .../dtos/purchase-order-query.dto.ts | 29 ++++++ .../dtos/update-purchase-order.dto.ts | 61 +++++++++++++ .../entities/purchase-order.entity.ts | 61 +++++++++++++ .../purchase-orders.controller.ts | 43 +++++++++ .../purchase-orders/purchase-orders.module.ts | 13 +++ .../purchase-orders.service.ts | 88 +++++++++++++++++++ backend/src/tasks/tasks.module.ts | 17 ++++ backend/src/tasks/tasks.service.ts | 52 +++++++++++ backend/test/contracts.e2e-spec.ts | 34 +++++++ backend/test/licenses.e2e-spec.ts | 34 +++++++ backend/test/purchase-orders.e2e-spec.ts | 34 +++++++ 27 files changed, 1136 insertions(+), 3 deletions(-) create mode 100644 backend/src/contracts/contracts.controller.ts create mode 100644 backend/src/contracts/contracts.module.ts create mode 100644 backend/src/contracts/contracts.service.ts create mode 100644 backend/src/contracts/dtos/contract-query.dto.ts create mode 100644 backend/src/contracts/dtos/create-contract.dto.ts create mode 100644 backend/src/contracts/dtos/update-contract.dto.ts create mode 100644 backend/src/contracts/entities/contract.entity.ts create mode 100644 backend/src/licenses/dtos/create-license.dto.ts create mode 100644 backend/src/licenses/dtos/license-query.dto.ts create mode 100644 backend/src/licenses/dtos/update-license.dto.ts create mode 100644 backend/src/licenses/entities/license.entity.ts create mode 100644 backend/src/licenses/licenses.controller.ts create mode 100644 backend/src/licenses/licenses.module.ts create mode 100644 backend/src/licenses/licenses.service.ts create mode 100644 backend/src/purchase-orders/dtos/create-purchase-order.dto.ts create mode 100644 backend/src/purchase-orders/dtos/purchase-order-query.dto.ts create mode 100644 backend/src/purchase-orders/dtos/update-purchase-order.dto.ts create mode 100644 backend/src/purchase-orders/entities/purchase-order.entity.ts create mode 100644 backend/src/purchase-orders/purchase-orders.controller.ts create mode 100644 backend/src/purchase-orders/purchase-orders.module.ts create mode 100644 backend/src/purchase-orders/purchase-orders.service.ts create mode 100644 backend/src/tasks/tasks.module.ts create mode 100644 backend/src/tasks/tasks.service.ts create mode 100644 backend/test/contracts.e2e-spec.ts create mode 100644 backend/test/licenses.e2e-spec.ts create mode 100644 backend/test/purchase-orders.e2e-spec.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1864f8e4..dfaa2002 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -18,13 +18,15 @@ 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 { ContractsModule } from './contracts/contracts.module'; +import { LicensesModule } from './licenses/licenses.module'; +import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module'; +import { TasksModule } from './tasks/tasks.module'; @Module({ imports: [ - // Global environment configuration provider ConfigModule.forRoot({ isGlobal: true }), - // Asynchronous Database Configuration Management TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ @@ -40,7 +42,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 +74,10 @@ import { CacheService } from './cache/cache.service'; StorageModule, UsersModule, AuthModule, + ContractsModule, + LicensesModule, + PurchaseOrdersModule, + TasksModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/contracts/contracts.controller.ts b/backend/src/contracts/contracts.controller.ts new file mode 100644 index 00000000..7c02314a --- /dev/null +++ b/backend/src/contracts/contracts.controller.ts @@ -0,0 +1,52 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, Query, Req, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ContractsService } from './contracts.service'; +import { CreateContractDto } from './dtos/create-contract.dto'; +import { UpdateContractDto } from './dtos/update-contract.dto'; +import { ContractQueryDto } from './dtos/contract-query.dto'; +import { StorageService } from '../storage/storage.service'; + +@Controller('contracts') +@UseGuards(AuthGuard('jwt')) +export class ContractsController { + constructor( + private readonly contractsService: ContractsService, + private readonly storageService: StorageService, + ) {} + + @Post() + async create(@Body() dto: CreateContractDto, @Req() req: any) { + return this.contractsService.create(dto, req.user?.id); + } + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async upload(@UploadedFile() file: Express.Multer.File, @Req() req: any) { + const key = `contracts/${Date.now()}-${file.originalname}`; + await this.storageService.upload(file, key); + const url = await this.storageService.getSignedUrl(key); + return { key, url }; + } + + @Get() + async findAll(@Query() query: ContractQueryDto) { + return this.contractsService.findAll(query); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.contractsService.findById(id); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() dto: UpdateContractDto, @Req() req: any) { + return this.contractsService.update(id, dto, req.user?.id); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + await this.contractsService.remove(id); + return { message: 'Contract deleted successfully' }; + } +} diff --git a/backend/src/contracts/contracts.module.ts b/backend/src/contracts/contracts.module.ts new file mode 100644 index 00000000..b8c98e5e --- /dev/null +++ b/backend/src/contracts/contracts.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Contract } from './entities/contract.entity'; +import { ContractsService } from './contracts.service'; +import { ContractsController } from './contracts.controller'; +import { StorageModule } from '../storage/storage.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Contract]), StorageModule], + controllers: [ContractsController], + providers: [ContractsService], + exports: [ContractsService], +}) +export class ContractsModule {} diff --git a/backend/src/contracts/contracts.service.ts b/backend/src/contracts/contracts.service.ts new file mode 100644 index 00000000..190d868c --- /dev/null +++ b/backend/src/contracts/contracts.service.ts @@ -0,0 +1,73 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { Contract } from './entities/contract.entity'; +import { CreateContractDto } from './dtos/create-contract.dto'; +import { UpdateContractDto } from './dtos/update-contract.dto'; +import { ContractQueryDto } from './dtos/contract-query.dto'; + +@Injectable() +export class ContractsService { + private nextNumber: number; + + constructor( + @InjectRepository(Contract) + private readonly contractRepository: Repository, + private readonly configService: ConfigService, + ) { + this.nextNumber = parseInt(configService.get('CONTRACT_ID_START', '500'), 10); + } + + async create(dto: CreateContractDto, userId?: string): Promise { + const prefix = this.configService.get('CONTRACT_ID_PREFIX', 'CTR'); + const contractId = `${prefix}-${this.nextNumber++}`; + const contract = this.contractRepository.create({ + ...dto, + contractId, + createdById: userId, + }); + return this.contractRepository.save(contract); + } + + async findAll(query: ContractQueryDto): Promise<{ data: Contract[]; total: number }> { + const { search, status, vendor, assignedToId, page, limit } = query; + const qb = this.contractRepository.createQueryBuilder('contract') + .leftJoinAndSelect('contract.createdBy', 'createdBy') + .leftJoinAndSelect('contract.assignedTo', 'assignedTo'); + + if (search) { + qb.andWhere( + '(contract.title ILIKE :search OR contract.vendor ILIKE :search OR contract.contractId ILIKE :search)', + { search: `%${search}%` }, + ); + } + if (status) qb.andWhere('contract.status = :status', { status }); + if (vendor) qb.andWhere('contract.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); + if (assignedToId) qb.andWhere('contract.assignedToId = :assignedToId', { assignedToId }); + + qb.orderBy('contract.createdAt', 'DESC'); + qb.skip((page - 1) * limit).take(limit); + return qb.getManyAndCount().then(([data, total]) => ({ data, total })); + } + + async findById(id: string): Promise { + const contract = await this.contractRepository.findOne({ + where: { id }, + relations: ['createdBy', 'assignedTo'], + }); + if (!contract) throw new NotFoundException('Contract not found'); + return contract; + } + + async update(id: string, dto: UpdateContractDto, userId?: string): Promise { + const contract = await this.findById(id); + Object.assign(contract, dto); + return this.contractRepository.save(contract); + } + + async remove(id: string): Promise { + const contract = await this.findById(id); + await this.contractRepository.softDelete(contract.id); + } +} diff --git a/backend/src/contracts/dtos/contract-query.dto.ts b/backend/src/contracts/dtos/contract-query.dto.ts new file mode 100644 index 00000000..5290a7c1 --- /dev/null +++ b/backend/src/contracts/dtos/contract-query.dto.ts @@ -0,0 +1,33 @@ +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ContractQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + vendor?: string; + + @IsOptional() + @IsString() + assignedToId?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/src/contracts/dtos/create-contract.dto.ts b/backend/src/contracts/dtos/create-contract.dto.ts new file mode 100644 index 00000000..0f7b60a9 --- /dev/null +++ b/backend/src/contracts/dtos/create-contract.dto.ts @@ -0,0 +1,37 @@ +import { IsString, IsOptional, IsNumber } from 'class-validator'; + +export class CreateContractDto { + @IsString() + title: string; + + @IsString() + vendor: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; + + @IsOptional() + @IsNumber() + value?: number; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + documentUrl?: string; + + @IsOptional() + @IsString() + assignedToId?: string; +} diff --git a/backend/src/contracts/dtos/update-contract.dto.ts b/backend/src/contracts/dtos/update-contract.dto.ts new file mode 100644 index 00000000..6fca74a9 --- /dev/null +++ b/backend/src/contracts/dtos/update-contract.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsOptional, IsNumber } from 'class-validator'; + +export class UpdateContractDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + vendor?: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; + + @IsOptional() + @IsNumber() + value?: number; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + documentUrl?: string; + + @IsOptional() + @IsString() + assignedToId?: string; +} diff --git a/backend/src/contracts/entities/contract.entity.ts b/backend/src/contracts/entities/contract.entity.ts new file mode 100644 index 00000000..4b6818c0 --- /dev/null +++ b/backend/src/contracts/entities/contract.entity.ts @@ -0,0 +1,55 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('contracts') +export class Contract { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + contractId: string; + + @Column() + title: string; + + @Column() + vendor: string; + + @Column({ type: 'date', nullable: true }) + startDate: string; + + @Column({ type: 'date', nullable: true }) + endDate: string; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + value: number; + + @Column({ default: 'DRAFT' }) + status: string; + + @Column({ nullable: true, type: 'text' }) + description: string; + + @Column({ nullable: true }) + documentUrl: string; + + @Column({ nullable: true }) + createdById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'createdById' }) + createdBy: User; + + @Column({ nullable: true }) + assignedToId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assignedToId' }) + assignedTo: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/licenses/dtos/create-license.dto.ts b/backend/src/licenses/dtos/create-license.dto.ts new file mode 100644 index 00000000..7ac7b9bb --- /dev/null +++ b/backend/src/licenses/dtos/create-license.dto.ts @@ -0,0 +1,46 @@ +import { IsString, IsOptional, IsNumber, IsInt } from 'class-validator'; + +export class CreateLicenseDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + softwareName?: string; + + @IsOptional() + @IsString() + vendor?: string; + + @IsOptional() + @IsInt() + totalSeats?: number; + + @IsOptional() + @IsInt() + usedSeats?: number; + + @IsOptional() + @IsString() + purchaseDate?: string; + + @IsOptional() + @IsString() + expiryDate?: string; + + @IsOptional() + @IsNumber() + cost?: number; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + assignedToId?: string; +} diff --git a/backend/src/licenses/dtos/license-query.dto.ts b/backend/src/licenses/dtos/license-query.dto.ts new file mode 100644 index 00000000..4390ed26 --- /dev/null +++ b/backend/src/licenses/dtos/license-query.dto.ts @@ -0,0 +1,29 @@ +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class LicenseQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + vendor?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/src/licenses/dtos/update-license.dto.ts b/backend/src/licenses/dtos/update-license.dto.ts new file mode 100644 index 00000000..0aff7fe2 --- /dev/null +++ b/backend/src/licenses/dtos/update-license.dto.ts @@ -0,0 +1,47 @@ +import { IsString, IsOptional, IsNumber, IsInt } from 'class-validator'; + +export class UpdateLicenseDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + softwareName?: string; + + @IsOptional() + @IsString() + vendor?: string; + + @IsOptional() + @IsInt() + totalSeats?: number; + + @IsOptional() + @IsInt() + usedSeats?: number; + + @IsOptional() + @IsString() + purchaseDate?: string; + + @IsOptional() + @IsString() + expiryDate?: string; + + @IsOptional() + @IsNumber() + cost?: number; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + assignedToId?: string; +} diff --git a/backend/src/licenses/entities/license.entity.ts b/backend/src/licenses/entities/license.entity.ts new file mode 100644 index 00000000..4ed86fef --- /dev/null +++ b/backend/src/licenses/entities/license.entity.ts @@ -0,0 +1,61 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('licenses') +export class License { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + licenseKey: string; + + @Column() + name: string; + + @Column({ nullable: true }) + softwareName: string; + + @Column({ nullable: true }) + vendor: string; + + @Column({ type: 'int', nullable: true }) + totalSeats: number; + + @Column({ type: 'int', default: 0 }) + usedSeats: number; + + @Column({ type: 'date', nullable: true }) + purchaseDate: string; + + @Column({ type: 'date', nullable: true }) + expiryDate: string; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + cost: number; + + @Column({ default: 'ACTIVE' }) + status: string; + + @Column({ nullable: true, type: 'text' }) + notes: string; + + @Column({ nullable: true }) + assignedToId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assignedToId' }) + assignedTo: User; + + @Column({ nullable: true }) + createdById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'createdById' }) + createdBy: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/licenses/licenses.controller.ts b/backend/src/licenses/licenses.controller.ts new file mode 100644 index 00000000..f354fe1c --- /dev/null +++ b/backend/src/licenses/licenses.controller.ts @@ -0,0 +1,38 @@ +import { Controller, Get, Post, Put, Delete, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { LicensesService } from './licenses.service'; +import { CreateLicenseDto } from './dtos/create-license.dto'; +import { UpdateLicenseDto } from './dtos/update-license.dto'; +import { LicenseQueryDto } from './dtos/license-query.dto'; + +@Controller('licenses') +@UseGuards(AuthGuard('jwt')) +export class LicensesController { + constructor(private readonly licensesService: LicensesService) {} + + @Post() + async create(@Body() dto: CreateLicenseDto, @Req() req: any) { + return this.licensesService.create(dto, req.user?.id); + } + + @Get() + async findAll(@Query() query: LicenseQueryDto) { + return this.licensesService.findAll(query); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.licensesService.findById(id); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() dto: UpdateLicenseDto, @Req() req: any) { + return this.licensesService.update(id, dto, req.user?.id); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + await this.licensesService.remove(id); + return { message: 'License deleted successfully' }; + } +} diff --git a/backend/src/licenses/licenses.module.ts b/backend/src/licenses/licenses.module.ts new file mode 100644 index 00000000..1b093faa --- /dev/null +++ b/backend/src/licenses/licenses.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { License } from './entities/license.entity'; +import { LicensesService } from './licenses.service'; +import { LicensesController } from './licenses.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([License])], + controllers: [LicensesController], + providers: [LicensesService], + exports: [LicensesService], +}) +export class LicensesModule {} diff --git a/backend/src/licenses/licenses.service.ts b/backend/src/licenses/licenses.service.ts new file mode 100644 index 00000000..0dacb5e6 --- /dev/null +++ b/backend/src/licenses/licenses.service.ts @@ -0,0 +1,65 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { License } from './entities/license.entity'; +import { CreateLicenseDto } from './dtos/create-license.dto'; +import { UpdateLicenseDto } from './dtos/update-license.dto'; +import { LicenseQueryDto } from './dtos/license-query.dto'; + +@Injectable() +export class LicensesService { + constructor( + @InjectRepository(License) + private readonly licenseRepository: Repository, + ) {} + + async create(dto: CreateLicenseDto, userId?: string): Promise { + const licenseKey = `LIC-${Date.now()}-${Math.random().toString(36).substring(2, 8).toUpperCase()}`; + const license = this.licenseRepository.create({ + ...dto, + licenseKey, + createdById: userId, + }); + return this.licenseRepository.save(license); + } + + async findAll(query: LicenseQueryDto): Promise<{ data: License[]; total: number }> { + const { search, status, vendor, page, limit } = query; + const qb = this.licenseRepository.createQueryBuilder('license') + .leftJoinAndSelect('license.assignedTo', 'assignedTo') + .leftJoinAndSelect('license.createdBy', 'createdBy'); + + if (search) { + qb.andWhere( + '(license.name ILIKE :search OR license.softwareName ILIKE :search OR license.vendor ILIKE :search OR license.licenseKey ILIKE :search)', + { search: `%${search}%` }, + ); + } + if (status) qb.andWhere('license.status = :status', { status }); + if (vendor) qb.andWhere('license.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); + + qb.orderBy('license.createdAt', 'DESC'); + qb.skip((page - 1) * limit).take(limit); + return qb.getManyAndCount().then(([data, total]) => ({ data, total })); + } + + async findById(id: string): Promise { + const license = await this.licenseRepository.findOne({ + where: { id }, + relations: ['assignedTo', 'createdBy'], + }); + if (!license) throw new NotFoundException('License not found'); + return license; + } + + async update(id: string, dto: UpdateLicenseDto, userId?: string): Promise { + const license = await this.findById(id); + Object.assign(license, dto); + return this.licenseRepository.save(license); + } + + async remove(id: string): Promise { + const license = await this.findById(id); + await this.licenseRepository.softDelete(license.id); + } +} diff --git a/backend/src/purchase-orders/dtos/create-purchase-order.dto.ts b/backend/src/purchase-orders/dtos/create-purchase-order.dto.ts new file mode 100644 index 00000000..407ffd0b --- /dev/null +++ b/backend/src/purchase-orders/dtos/create-purchase-order.dto.ts @@ -0,0 +1,60 @@ +import { IsString, IsOptional, IsNumber, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +class PurchaseOrderItemDto { + @IsString() + name: string; + + @IsNumber() + quantity: number; + + @IsNumber() + unitPrice: number; + + @IsOptional() + @IsNumber() + total?: number; +} + +export class CreatePurchaseOrderDto { + @IsString() + vendor: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PurchaseOrderItemDto) + items?: PurchaseOrderItemDto[]; + + @IsOptional() + @IsNumber() + subtotal?: number; + + @IsOptional() + @IsNumber() + tax?: number; + + @IsOptional() + @IsNumber() + total?: number; + + @IsOptional() + @IsString() + currency?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + orderDate?: string; + + @IsOptional() + @IsString() + expectedDelivery?: string; +} diff --git a/backend/src/purchase-orders/dtos/purchase-order-query.dto.ts b/backend/src/purchase-orders/dtos/purchase-order-query.dto.ts new file mode 100644 index 00000000..f9924c17 --- /dev/null +++ b/backend/src/purchase-orders/dtos/purchase-order-query.dto.ts @@ -0,0 +1,29 @@ +import { IsOptional, IsString, IsInt, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class PurchaseOrderQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + vendor?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/backend/src/purchase-orders/dtos/update-purchase-order.dto.ts b/backend/src/purchase-orders/dtos/update-purchase-order.dto.ts new file mode 100644 index 00000000..d0c366a8 --- /dev/null +++ b/backend/src/purchase-orders/dtos/update-purchase-order.dto.ts @@ -0,0 +1,61 @@ +import { IsString, IsOptional, IsNumber, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +class PurchaseOrderItemDto { + @IsString() + name: string; + + @IsNumber() + quantity: number; + + @IsNumber() + unitPrice: number; + + @IsOptional() + @IsNumber() + total?: number; +} + +export class UpdatePurchaseOrderDto { + @IsOptional() + @IsString() + vendor?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PurchaseOrderItemDto) + items?: PurchaseOrderItemDto[]; + + @IsOptional() + @IsNumber() + subtotal?: number; + + @IsOptional() + @IsNumber() + tax?: number; + + @IsOptional() + @IsNumber() + total?: number; + + @IsOptional() + @IsString() + currency?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + orderDate?: string; + + @IsOptional() + @IsString() + expectedDelivery?: string; +} diff --git a/backend/src/purchase-orders/entities/purchase-order.entity.ts b/backend/src/purchase-orders/entities/purchase-order.entity.ts new file mode 100644 index 00000000..40e04b48 --- /dev/null +++ b/backend/src/purchase-orders/entities/purchase-order.entity.ts @@ -0,0 +1,61 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('purchase_orders') +export class PurchaseOrder { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ unique: true }) + poNumber: string; + + @Column() + vendor: string; + + @Column({ nullable: true, type: 'jsonb' }) + items: { name: string; quantity: number; unitPrice: number; total: number }[]; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + subtotal: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + tax: number; + + @Column({ type: 'decimal', precision: 12, scale: 2, default: 0 }) + total: number; + + @Column({ nullable: true }) + currency: string; + + @Column({ default: 'PENDING' }) + status: string; + + @Column({ nullable: true, type: 'text' }) + notes: string; + + @Column({ type: 'date', nullable: true }) + orderDate: string; + + @Column({ type: 'date', nullable: true }) + expectedDelivery: string; + + @Column({ nullable: true }) + createdById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'createdById' }) + createdBy: User; + + @Column({ nullable: true }) + approvedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'approvedById' }) + approvedBy: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/purchase-orders/purchase-orders.controller.ts b/backend/src/purchase-orders/purchase-orders.controller.ts new file mode 100644 index 00000000..c9bccd81 --- /dev/null +++ b/backend/src/purchase-orders/purchase-orders.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Get, Post, Put, Patch, Delete, Param, Body, Query, Req, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { PurchaseOrdersService } from './purchase-orders.service'; +import { CreatePurchaseOrderDto } from './dtos/create-purchase-order.dto'; +import { UpdatePurchaseOrderDto } from './dtos/update-purchase-order.dto'; +import { PurchaseOrderQueryDto } from './dtos/purchase-order-query.dto'; + +@Controller('purchase-orders') +@UseGuards(AuthGuard('jwt')) +export class PurchaseOrdersController { + constructor(private readonly purchaseOrdersService: PurchaseOrdersService) {} + + @Post() + async create(@Body() dto: CreatePurchaseOrderDto, @Req() req: any) { + return this.purchaseOrdersService.create(dto, req.user?.id); + } + + @Get() + async findAll(@Query() query: PurchaseOrderQueryDto) { + return this.purchaseOrdersService.findAll(query); + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.purchaseOrdersService.findById(id); + } + + @Put(':id') + async update(@Param('id') id: string, @Body() dto: UpdatePurchaseOrderDto, @Req() req: any) { + return this.purchaseOrdersService.update(id, dto, req.user?.id); + } + + @Patch(':id/status') + async updateStatus(@Param('id') id: string, @Body('status') status: string, @Req() req: any) { + return this.purchaseOrdersService.update(id, { status } as UpdatePurchaseOrderDto, req.user?.id); + } + + @Delete(':id') + async remove(@Param('id') id: string) { + await this.purchaseOrdersService.remove(id); + return { message: 'Purchase order deleted successfully' }; + } +} diff --git a/backend/src/purchase-orders/purchase-orders.module.ts b/backend/src/purchase-orders/purchase-orders.module.ts new file mode 100644 index 00000000..8f7dde1c --- /dev/null +++ b/backend/src/purchase-orders/purchase-orders.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PurchaseOrder } from './entities/purchase-order.entity'; +import { PurchaseOrdersService } from './purchase-orders.service'; +import { PurchaseOrdersController } from './purchase-orders.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([PurchaseOrder])], + controllers: [PurchaseOrdersController], + providers: [PurchaseOrdersService], + exports: [PurchaseOrdersService], +}) +export class PurchaseOrdersModule {} diff --git a/backend/src/purchase-orders/purchase-orders.service.ts b/backend/src/purchase-orders/purchase-orders.service.ts new file mode 100644 index 00000000..1c986295 --- /dev/null +++ b/backend/src/purchase-orders/purchase-orders.service.ts @@ -0,0 +1,88 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ConfigService } from '@nestjs/config'; +import { PurchaseOrder } from './entities/purchase-order.entity'; +import { CreatePurchaseOrderDto } from './dtos/create-purchase-order.dto'; +import { UpdatePurchaseOrderDto } from './dtos/update-purchase-order.dto'; +import { PurchaseOrderQueryDto } from './dtos/purchase-order-query.dto'; + +@Injectable() +export class PurchaseOrdersService { + private nextNumber: number; + + constructor( + @InjectRepository(PurchaseOrder) + private readonly poRepository: Repository, + private readonly configService: ConfigService, + ) { + this.nextNumber = parseInt(configService.get('PO_ID_START', '1000'), 10); + } + + async create(dto: CreatePurchaseOrderDto, userId?: string): Promise { + const prefix = this.configService.get('PO_ID_PREFIX', 'PO'); + const poNumber = `${prefix}-${this.nextNumber++}`; + const items = dto.items?.map(item => ({ + ...item, + total: item.total ?? item.quantity * item.unitPrice, + })) || []; + const subtotal = dto.subtotal ?? items.reduce((sum, item) => sum + item.total, 0); + const total = dto.total ?? subtotal + (dto.tax ?? 0); + + const po = this.poRepository.create({ + ...dto, + items, + poNumber, + subtotal, + total, + createdById: userId, + }); + return this.poRepository.save(po); + } + + async findAll(query: PurchaseOrderQueryDto): Promise<{ data: PurchaseOrder[]; total: number }> { + const { search, status, vendor, page, limit } = query; + const qb = this.poRepository.createQueryBuilder('po') + .leftJoinAndSelect('po.createdBy', 'createdBy') + .leftJoinAndSelect('po.approvedBy', 'approvedBy'); + + if (search) { + qb.andWhere( + '(po.vendor ILIKE :search OR po.poNumber ILIKE :search OR CAST(po.total AS TEXT) ILIKE :search)', + { search: `%${search}%` }, + ); + } + if (status) qb.andWhere('po.status = :status', { status }); + if (vendor) qb.andWhere('po.vendor ILIKE :vendor', { vendor: `%${vendor}%` }); + + qb.orderBy('po.createdAt', 'DESC'); + qb.skip((page - 1) * limit).take(limit); + return qb.getManyAndCount().then(([data, total]) => ({ data, total })); + } + + async findById(id: string): Promise { + const po = await this.poRepository.findOne({ + where: { id }, + relations: ['createdBy', 'approvedBy'], + }); + if (!po) throw new NotFoundException('Purchase order not found'); + return po; + } + + async update(id: string, dto: UpdatePurchaseOrderDto, userId?: string): Promise { + const po = await this.findById(id); + if (dto.items) { + dto.items = dto.items.map(item => ({ + ...item, + total: item.total ?? item.quantity * item.unitPrice, + })); + } + Object.assign(po, dto); + return this.poRepository.save(po); + } + + async remove(id: string): Promise { + const po = await this.findById(id); + await this.poRepository.softDelete(po.id); + } +} diff --git a/backend/src/tasks/tasks.module.ts b/backend/src/tasks/tasks.module.ts new file mode 100644 index 00000000..bdcd43e9 --- /dev/null +++ b/backend/src/tasks/tasks.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from '../assets/entities/asset.entity'; +import { Department } from '../users/entities/department.entity'; +import { MailModule } from '../mail/mail.module'; +import { TasksService } from './tasks.service'; + +@Module({ + imports: [ + ScheduleModule.forRoot(), + TypeOrmModule.forFeature([Asset, Department]), + MailModule, + ], + providers: [TasksService], +}) +export class TasksModule {} diff --git a/backend/src/tasks/tasks.service.ts b/backend/src/tasks/tasks.service.ts new file mode 100644 index 00000000..cf10754f --- /dev/null +++ b/backend/src/tasks/tasks.service.ts @@ -0,0 +1,52 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from '../assets/entities/asset.entity'; +import { Department } from '../users/entities/department.entity'; +import { MailService } from '../mail/mail.service'; + +@Injectable() +export class TasksService { + private readonly logger = new Logger(TasksService.name); + + constructor( + @InjectRepository(Asset) + private readonly assetRepository: Repository, + @InjectRepository(Department) + private readonly departmentRepository: Repository, + private readonly mailService: MailService, + ) {} + + @Cron(CronExpression.EVERY_WEEKDAY) + async sendDepartmentAssetSummaries() { + this.logger.log('Starting daily department asset summary task'); + const departments = await this.departmentRepository.find({ relations: ['children'] }); + for (const dept of departments) { + const [assets, total] = await this.assetRepository.findAndCount({ + where: { departmentId: dept.id }, + }); + const active = assets.filter(a => a.status === 'ACTIVE').length; + const assigned = assets.filter(a => a.status === 'ASSIGNED').length; + const maintenance = assets.filter(a => a.status === 'MAINTENANCE').length; + this.logger.log(`Department ${dept.name}: ${total} assets (${active} active, ${assigned} assigned, ${maintenance} maintenance)`); + } + this.logger.log('Daily department asset summary task completed'); + } + + @Cron('0 9 * * MON') + async sendWeeklyAssetSummary() { + this.logger.log('Starting weekly asset summary report'); + const totalAssets = await this.assetRepository.count(); + const statusCounts = await this.assetRepository + .createQueryBuilder('asset') + .select('asset.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('asset.status') + .getRawMany(); + this.logger.log(`Weekly summary: ${totalAssets} total assets`); + for (const row of statusCounts) { + this.logger.log(` ${row.status}: ${row.count}`); + } + } +} diff --git a/backend/test/contracts.e2e-spec.ts b/backend/test/contracts.e2e-spec.ts new file mode 100644 index 00000000..3612ca1a --- /dev/null +++ b/backend/test/contracts.e2e-spec.ts @@ -0,0 +1,34 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../app.module'; + +describe('Contracts (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /api/contracts returns 401 without auth', () => { + return request(app.getHttpServer()) + .get('/api/contracts') + .expect(401); + }); + + it('POST /api/contracts returns 401 without auth', () => { + return request(app.getHttpServer()) + .post('/api/contracts') + .send({ title: 'Test', vendor: 'Test Vendor' }) + .expect(401); + }); +}); diff --git a/backend/test/licenses.e2e-spec.ts b/backend/test/licenses.e2e-spec.ts new file mode 100644 index 00000000..493fc0c9 --- /dev/null +++ b/backend/test/licenses.e2e-spec.ts @@ -0,0 +1,34 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../app.module'; + +describe('Licenses (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /api/licenses returns 401 without auth', () => { + return request(app.getHttpServer()) + .get('/api/licenses') + .expect(401); + }); + + it('POST /api/licenses returns 401 without auth', () => { + return request(app.getHttpServer()) + .post('/api/licenses') + .send({ name: 'Test License' }) + .expect(401); + }); +}); diff --git a/backend/test/purchase-orders.e2e-spec.ts b/backend/test/purchase-orders.e2e-spec.ts new file mode 100644 index 00000000..17e39f5c --- /dev/null +++ b/backend/test/purchase-orders.e2e-spec.ts @@ -0,0 +1,34 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../app.module'; + +describe('PurchaseOrders (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('GET /api/purchase-orders returns 401 without auth', () => { + return request(app.getHttpServer()) + .get('/api/purchase-orders') + .expect(401); + }); + + it('POST /api/purchase-orders returns 401 without auth', () => { + return request(app.getHttpServer()) + .post('/api/purchase-orders') + .send({ vendor: 'Test Vendor' }) + .expect(401); + }); +});