erp-construccion-backend-v2/src/modules/assets/services/asset.service.ts
Adrian Flores Cortes f14829d2ce [MAE-015] fix: Fix TypeScript errors in assets module
- Fix controllers to use Promise<void> return types
- Replace 'return res.status()' with 'res.status(); return;'
- Add NextFunction parameter to handlers
- Fix enum-style references with string literals
- Replace 'deletedAt: null' with 'IsNull()' for TypeORM
- Remove unused parameters and imports
- Fix grouped object initialization in getByStatus
- Remove non-existent 'updatedBy' property from WorkOrderPart

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 09:51:01 -06:00

512 lines
15 KiB
TypeScript

/**
* 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<string, any>;
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<CreateAssetDto> {
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<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export class AssetService {
private assetRepository: Repository<Asset>;
private categoryRepository: Repository<AssetCategory>;
private assignmentRepository: Repository<AssetAssignment>;
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<Asset> {
// 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<Asset | null> {
return this.assetRepository.findOne({
where: { id, tenantId },
relations: ['category'],
});
}
/**
* Find asset by code
*/
async findByCode(tenantId: string, code: string): Promise<Asset | null> {
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<PaginatedResult<Asset>> {
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<Asset | null> {
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<Asset | null> {
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<Asset | null> {
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<boolean> {
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<AssetAssignment> {
// 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<AssetAssignment | null> {
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<PaginatedResult<AssetAssignment>> {
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<boolean> {
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<AssetCategory>, userId?: string): Promise<AssetCategory> {
const category = this.categoryRepository.create({
tenantId,
...data,
createdBy: userId,
});
return this.categoryRepository.save(category);
}
/**
* Get all categories
*/
async getCategories(tenantId: string): Promise<AssetCategory[]> {
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<string, number>;
byType: Record<string, number>;
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<string, number> = {};
byStatusRaw.forEach((row: any) => {
byStatus[row.status] = parseInt(row.count, 10);
});
const byType: Record<string, number> = {};
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<Asset[]> {
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<Asset[]> {
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();
}
}