/** * Asset Service * ERP Construccion - Modulo Activos (MAE-015) * * Logica de negocio para gestion de activos fijos y maquinaria. */ import { Repository, DataSource, IsNull } from 'typeorm'; import { Asset, AssetType, AssetStatus, OwnershipType } from '../entities/asset.entity'; import { AssetCategory } from '../entities/asset-category.entity'; import { AssetAssignment } from '../entities/asset-assignment.entity'; // DTOs export interface CreateAssetDto { assetCode: string; name: string; description?: string; categoryId?: string; assetType: AssetType; ownershipType?: OwnershipType; brand?: string; model?: string; serialNumber?: string; yearManufactured?: number; specifications?: Record; capacity?: string; powerRating?: string; fuelType?: string; fuelCapacity?: number; purchaseDate?: Date; purchasePrice?: number; supplierId?: string; usefulLifeYears?: number; salvageValue?: number; photoUrl?: string; notes?: string; tags?: string[]; } export interface UpdateAssetDto extends Partial { status?: AssetStatus; currentProjectId?: string; currentLocationName?: string; currentLatitude?: number; currentLongitude?: number; currentHours?: number; currentKilometers?: number; assignedOperatorId?: string; lastLocationUpdate?: Date; lastUsageUpdate?: Date; } export interface AssetFilters { assetType?: AssetType; status?: AssetStatus; ownershipType?: OwnershipType; categoryId?: string; projectId?: string; search?: string; tags?: string[]; } export interface AssignAssetDto { assetId: string; projectId: string; projectCode?: string; projectName?: string; startDate: Date; operatorId?: string; operatorName?: string; responsibleId?: string; responsibleName?: string; locationInProject?: string; dailyRate?: number; hourlyRate?: number; transferReason?: string; } export interface PaginationOptions { page: number; limit: number; } export interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } export class AssetService { private assetRepository: Repository; private categoryRepository: Repository; private assignmentRepository: Repository; constructor(dataSource: DataSource) { this.assetRepository = dataSource.getRepository(Asset); this.categoryRepository = dataSource.getRepository(AssetCategory); this.assignmentRepository = dataSource.getRepository(AssetAssignment); } // ============================================ // ASSETS // ============================================ /** * Create a new asset */ async create(tenantId: string, dto: CreateAssetDto, userId?: string): Promise { // Check code uniqueness const existing = await this.assetRepository.findOne({ where: { tenantId, assetCode: dto.assetCode }, }); if (existing) { throw new Error(`Asset with code ${dto.assetCode} already exists`); } const asset = this.assetRepository.create({ tenantId, ...dto, status: 'available' as AssetStatus, ownershipType: dto.ownershipType || ('owned' as OwnershipType), currentBookValue: dto.purchasePrice, createdBy: userId, }); return this.assetRepository.save(asset); } /** * Find asset by ID */ async findById(tenantId: string, id: string): Promise { return this.assetRepository.findOne({ where: { id, tenantId }, relations: ['category'], }); } /** * Find asset by code */ async findByCode(tenantId: string, code: string): Promise { return this.assetRepository.findOne({ where: { tenantId, assetCode: code }, relations: ['category'], }); } /** * List assets with filters and pagination */ async findAll( tenantId: string, filters: AssetFilters = {}, pagination: PaginationOptions = { page: 1, limit: 20 } ): Promise> { const queryBuilder = this.assetRepository.createQueryBuilder('asset') .leftJoinAndSelect('asset.category', 'category') .where('asset.tenant_id = :tenantId', { tenantId }) .andWhere('asset.deleted_at IS NULL'); if (filters.assetType) { queryBuilder.andWhere('asset.asset_type = :assetType', { assetType: filters.assetType }); } if (filters.status) { queryBuilder.andWhere('asset.status = :status', { status: filters.status }); } if (filters.ownershipType) { queryBuilder.andWhere('asset.ownership_type = :ownershipType', { ownershipType: filters.ownershipType }); } if (filters.categoryId) { queryBuilder.andWhere('asset.category_id = :categoryId', { categoryId: filters.categoryId }); } if (filters.projectId) { queryBuilder.andWhere('asset.current_project_id = :projectId', { projectId: filters.projectId }); } if (filters.search) { queryBuilder.andWhere( '(asset.asset_code ILIKE :search OR asset.name ILIKE :search OR asset.serial_number ILIKE :search)', { search: `%${filters.search}%` } ); } if (filters.tags && filters.tags.length > 0) { queryBuilder.andWhere('asset.tags && :tags', { tags: filters.tags }); } const skip = (pagination.page - 1) * pagination.limit; const [data, total] = await queryBuilder .orderBy('asset.name', 'ASC') .skip(skip) .take(pagination.limit) .getManyAndCount(); return { data, total, page: pagination.page, limit: pagination.limit, totalPages: Math.ceil(total / pagination.limit), }; } /** * Update asset */ async update(tenantId: string, id: string, dto: UpdateAssetDto, userId?: string): Promise { const asset = await this.findById(tenantId, id); if (!asset) return null; // Update location timestamp if location changed if (dto.currentLatitude !== undefined || dto.currentLongitude !== undefined) { dto.lastLocationUpdate = new Date(); } // Update usage timestamp if hours/km changed if (dto.currentHours !== undefined || dto.currentKilometers !== undefined) { dto.lastUsageUpdate = new Date(); } Object.assign(asset, dto, { updatedBy: userId }); return this.assetRepository.save(asset); } /** * Update asset status */ async updateStatus(tenantId: string, id: string, status: AssetStatus, userId?: string): Promise { return this.update(tenantId, id, { status }, userId); } /** * Update asset usage metrics (hours, km) */ async updateUsage( tenantId: string, id: string, hours?: number, kilometers?: number, userId?: string ): Promise { const asset = await this.findById(tenantId, id); if (!asset) return null; if (hours !== undefined) { asset.currentHours = hours; } if (kilometers !== undefined) { asset.currentKilometers = kilometers; } asset.lastUsageUpdate = new Date(); asset.updatedBy = userId; return this.assetRepository.save(asset); } /** * Soft delete asset */ async delete(tenantId: string, id: string, userId?: string): Promise { const result = await this.assetRepository.update( { id, tenantId }, { deletedAt: new Date(), status: 'retired' as AssetStatus, updatedBy: userId } ); return (result.affected ?? 0) > 0; } // ============================================ // ASSIGNMENTS // ============================================ /** * Assign asset to project */ async assignToProject(tenantId: string, dto: AssignAssetDto, userId?: string): Promise { // Close current assignment if exists await this.assignmentRepository.update( { tenantId, assetId: dto.assetId, isCurrent: true }, { isCurrent: false, endDate: new Date() } ); // Get asset for current metrics const asset = await this.findById(tenantId, dto.assetId); if (!asset) { throw new Error('Asset not found'); } // Create new assignment const assignment = this.assignmentRepository.create({ tenantId, assetId: dto.assetId, projectId: dto.projectId, projectCode: dto.projectCode, projectName: dto.projectName, startDate: dto.startDate, isCurrent: true, operatorId: dto.operatorId, operatorName: dto.operatorName, responsibleId: dto.responsibleId, responsibleName: dto.responsibleName, locationInProject: dto.locationInProject, hoursAtStart: asset.currentHours, kilometersAtStart: asset.currentKilometers, dailyRate: dto.dailyRate, hourlyRate: dto.hourlyRate, transferReason: dto.transferReason, createdBy: userId, }); const savedAssignment = await this.assignmentRepository.save(assignment); // Update asset with new project await this.update(tenantId, dto.assetId, { currentProjectId: dto.projectId, status: 'assigned' as AssetStatus, assignedOperatorId: dto.operatorId, }, userId); return savedAssignment; } /** * Get current assignment for asset */ async getCurrentAssignment(tenantId: string, assetId: string): Promise { return this.assignmentRepository.findOne({ where: { tenantId, assetId, isCurrent: true }, }); } /** * Get assignment history for asset */ async getAssignmentHistory( tenantId: string, assetId: string, pagination: PaginationOptions = { page: 1, limit: 20 } ): Promise> { const skip = (pagination.page - 1) * pagination.limit; const [data, total] = await this.assignmentRepository.findAndCount({ where: { tenantId, assetId }, order: { startDate: 'DESC' }, skip, take: pagination.limit, }); return { data, total, page: pagination.page, limit: pagination.limit, totalPages: Math.ceil(total / pagination.limit), }; } /** * Return asset from project (close assignment) */ async returnFromProject(tenantId: string, assetId: string, endDate: Date, userId?: string): Promise { const assignment = await this.getCurrentAssignment(tenantId, assetId); if (!assignment) return false; const asset = await this.findById(tenantId, assetId); if (!asset) return false; // Close assignment assignment.isCurrent = false; assignment.endDate = endDate; assignment.hoursAtEnd = asset.currentHours; assignment.kilometersAtEnd = asset.currentKilometers; assignment.updatedBy = userId; await this.assignmentRepository.save(assignment); // Update asset status await this.update(tenantId, assetId, { currentProjectId: undefined, status: 'available' as AssetStatus, }, userId); return true; } // ============================================ // CATEGORIES // ============================================ /** * Create category */ async createCategory(tenantId: string, data: Partial, userId?: string): Promise { const category = this.categoryRepository.create({ tenantId, ...data, createdBy: userId, }); return this.categoryRepository.save(category); } /** * Get all categories */ async getCategories(tenantId: string): Promise { return this.categoryRepository.find({ where: { tenantId, isActive: true, deletedAt: IsNull() }, order: { level: 'ASC', name: 'ASC' }, }); } // ============================================ // STATISTICS // ============================================ /** * Get asset statistics */ async getStatistics(tenantId: string): Promise<{ total: number; byStatus: Record; byType: Record; totalValue: number; maintenanceDue: number; }> { const [total, byStatusRaw, byTypeRaw, valueResult, maintenanceDue] = await Promise.all([ this.assetRepository.count({ where: { tenantId, deletedAt: IsNull() } }), this.assetRepository.createQueryBuilder('asset') .select('asset.status', 'status') .addSelect('COUNT(*)', 'count') .where('asset.tenant_id = :tenantId', { tenantId }) .andWhere('asset.deleted_at IS NULL') .groupBy('asset.status') .getRawMany(), this.assetRepository.createQueryBuilder('asset') .select('asset.asset_type', 'type') .addSelect('COUNT(*)', 'count') .where('asset.tenant_id = :tenantId', { tenantId }) .andWhere('asset.deleted_at IS NULL') .groupBy('asset.asset_type') .getRawMany(), this.assetRepository.createQueryBuilder('asset') .select('SUM(asset.current_book_value)', 'total') .where('asset.tenant_id = :tenantId', { tenantId }) .andWhere('asset.deleted_at IS NULL') .getRawOne(), this.assetRepository.createQueryBuilder('asset') .where('asset.tenant_id = :tenantId', { tenantId }) .andWhere('asset.deleted_at IS NULL') .andWhere('asset.next_maintenance_date <= :date', { date: new Date() }) .getCount(), ]); const byStatus: Record = {}; byStatusRaw.forEach((row: any) => { byStatus[row.status] = parseInt(row.count, 10); }); const byType: Record = {}; byTypeRaw.forEach((row: any) => { byType[row.type] = parseInt(row.count, 10); }); return { total, byStatus, byType, totalValue: parseFloat(valueResult?.total) || 0, maintenanceDue, }; } /** * Get assets needing maintenance */ async getAssetsNeedingMaintenance(tenantId: string): Promise { const today = new Date(); return this.assetRepository.createQueryBuilder('asset') .where('asset.tenant_id = :tenantId', { tenantId }) .andWhere('asset.deleted_at IS NULL') .andWhere('asset.status != :retired', { retired: 'retired' }) .andWhere( '(asset.next_maintenance_date <= :today OR asset.current_hours >= asset.next_maintenance_hours OR asset.current_kilometers >= asset.next_maintenance_kilometers)', { today } ) .orderBy('asset.next_maintenance_date', 'ASC') .getMany(); } /** * Search assets for autocomplete */ async search(tenantId: string, query: string, limit = 10): Promise { return this.assetRepository.createQueryBuilder('asset') .where('asset.tenant_id = :tenantId', { tenantId }) .andWhere('asset.deleted_at IS NULL') .andWhere( '(asset.asset_code ILIKE :query OR asset.name ILIKE :query OR asset.serial_number ILIKE :query)', { query: `%${query}%` } ) .orderBy('asset.name', 'ASC') .take(limit) .getMany(); } }