- 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>
512 lines
15 KiB
TypeScript
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();
|
|
}
|
|
}
|