erp-construccion-backend-v2/src/modules/assets/services/work-order.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

750 lines
23 KiB
TypeScript

/**
* WorkOrder Service
* ERP Construccion - Modulo Activos (MAE-015)
*
* Logica de negocio para ordenes de trabajo de mantenimiento.
*/
import { Repository, DataSource, LessThanOrEqual, In, IsNull } from 'typeorm';
import { WorkOrder, WorkOrderStatus, WorkOrderPriority, MaintenanceType, WorkOrderStatusValues, WorkOrderPriorityValues } from '../entities/work-order.entity';
import { WorkOrderPart } from '../entities/work-order-part.entity';
import { MaintenanceHistory } from '../entities/maintenance-history.entity';
import { MaintenancePlan } from '../entities/maintenance-plan.entity';
import { Asset } from '../entities/asset.entity';
// DTOs
export interface CreateWorkOrderDto {
assetId: string;
maintenanceType: MaintenanceType;
priority?: WorkOrderPriority;
title: string;
description?: string;
problemReported?: string;
projectId?: string;
projectName?: string;
scheduledStartDate?: Date;
scheduledEndDate?: Date;
assignedToId?: string;
assignedToName?: string;
estimatedHours?: number;
activitiesChecklist?: Record<string, any>[];
isScheduled?: boolean;
scheduleId?: string;
planId?: string;
}
export interface UpdateWorkOrderDto {
status?: WorkOrderStatus;
priority?: WorkOrderPriority;
title?: string;
description?: string;
diagnosis?: string;
scheduledStartDate?: Date;
scheduledEndDate?: Date;
actualStartDate?: Date;
actualEndDate?: Date;
assignedToId?: string;
assignedToName?: string;
workPerformed?: string;
findings?: string;
recommendations?: string;
activitiesChecklist?: Record<string, any>[];
actualHours?: number;
laborCost?: number;
externalServiceCost?: number;
otherCosts?: number;
requiresFollowup?: boolean;
followupNotes?: string;
notes?: string;
}
export interface AddPartDto {
partId?: string;
partCode?: string;
partName: string;
partDescription?: string;
quantityRequired: number;
quantityUsed?: number;
unitCost?: number;
fromInventory?: boolean;
}
export interface WorkOrderFilters {
status?: WorkOrderStatus;
priority?: WorkOrderPriority;
maintenanceType?: MaintenanceType;
assetId?: string;
projectId?: string;
assignedToId?: string;
fromDate?: Date;
toDate?: Date;
search?: string;
}
export interface CompleteWorkOrderDto {
workPerformed: string;
findings?: string;
recommendations?: string;
actualHours: number;
laborCost: number;
completedById?: string;
completedByName?: string;
completionNotes?: string;
photosAfter?: string[];
requiresFollowup?: boolean;
followupNotes?: string;
}
export class WorkOrderService {
private workOrderRepository: Repository<WorkOrder>;
private partRepository: Repository<WorkOrderPart>;
private historyRepository: Repository<MaintenanceHistory>;
private planRepository: Repository<MaintenancePlan>;
private assetRepository: Repository<Asset>;
constructor(dataSource: DataSource) {
this.workOrderRepository = dataSource.getRepository(WorkOrder);
this.partRepository = dataSource.getRepository(WorkOrderPart);
this.historyRepository = dataSource.getRepository(MaintenanceHistory);
this.planRepository = dataSource.getRepository(MaintenancePlan);
this.assetRepository = dataSource.getRepository(Asset);
}
/**
* Generate next work order number
*/
private async generateWorkOrderNumber(tenantId: string): Promise<string> {
const year = new Date().getFullYear();
const prefix = `OT-${year}-`;
const lastOrder = await this.workOrderRepository.findOne({
where: { tenantId },
order: { createdAt: 'DESC' },
});
let sequence = 1;
if (lastOrder?.workOrderNumber?.startsWith(prefix)) {
const lastSeq = parseInt(lastOrder.workOrderNumber.replace(prefix, ''), 10);
sequence = isNaN(lastSeq) ? 1 : lastSeq + 1;
}
return `${prefix}${sequence.toString().padStart(5, '0')}`;
}
/**
* Create a new work order
*/
async create(tenantId: string, dto: CreateWorkOrderDto, userId?: string): Promise<WorkOrder> {
// Get asset info
const asset = await this.assetRepository.findOne({
where: { id: dto.assetId, tenantId },
});
if (!asset) {
throw new Error('Asset not found');
}
const workOrderNumber = await this.generateWorkOrderNumber(tenantId);
const workOrder = this.workOrderRepository.create({
tenantId,
workOrderNumber,
assetId: dto.assetId,
assetCode: asset.assetCode,
assetName: asset.name,
maintenanceType: dto.maintenanceType,
priority: dto.priority || WorkOrderPriorityValues.MEDIUM,
status: WorkOrderStatusValues.DRAFT,
title: dto.title,
description: dto.description,
problemReported: dto.problemReported,
projectId: dto.projectId || asset.currentProjectId,
projectName: dto.projectName,
requestedDate: new Date(),
scheduledStartDate: dto.scheduledStartDate,
scheduledEndDate: dto.scheduledEndDate,
hoursAtWorkOrder: asset.currentHours,
kilometersAtWorkOrder: asset.currentKilometers,
assignedToId: dto.assignedToId,
assignedToName: dto.assignedToName,
requestedById: userId,
estimatedHours: dto.estimatedHours,
activitiesChecklist: dto.activitiesChecklist,
isScheduled: dto.isScheduled || false,
scheduleId: dto.scheduleId,
planId: dto.planId,
createdBy: userId,
});
return this.workOrderRepository.save(workOrder);
}
/**
* Find work order by ID
*/
async findById(tenantId: string, id: string): Promise<WorkOrder | null> {
return this.workOrderRepository.findOne({
where: { id, tenantId },
relations: ['asset', 'partsUsed'],
});
}
/**
* Find work order by number
*/
async findByNumber(tenantId: string, workOrderNumber: string): Promise<WorkOrder | null> {
return this.workOrderRepository.findOne({
where: { tenantId, workOrderNumber },
relations: ['asset', 'partsUsed'],
});
}
/**
* List work orders with filters
*/
async findAll(
tenantId: string,
filters: WorkOrderFilters = {},
pagination = { page: 1, limit: 20 }
) {
const queryBuilder = this.workOrderRepository.createQueryBuilder('wo')
.leftJoinAndSelect('wo.asset', 'asset')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.deleted_at IS NULL');
if (filters.status) {
queryBuilder.andWhere('wo.status = :status', { status: filters.status });
}
if (filters.priority) {
queryBuilder.andWhere('wo.priority = :priority', { priority: filters.priority });
}
if (filters.maintenanceType) {
queryBuilder.andWhere('wo.maintenance_type = :maintenanceType', { maintenanceType: filters.maintenanceType });
}
if (filters.assetId) {
queryBuilder.andWhere('wo.asset_id = :assetId', { assetId: filters.assetId });
}
if (filters.projectId) {
queryBuilder.andWhere('wo.project_id = :projectId', { projectId: filters.projectId });
}
if (filters.assignedToId) {
queryBuilder.andWhere('wo.assigned_to_id = :assignedToId', { assignedToId: filters.assignedToId });
}
if (filters.fromDate) {
queryBuilder.andWhere('wo.requested_date >= :fromDate', { fromDate: filters.fromDate });
}
if (filters.toDate) {
queryBuilder.andWhere('wo.requested_date <= :toDate', { toDate: filters.toDate });
}
if (filters.search) {
queryBuilder.andWhere(
'(wo.work_order_number ILIKE :search OR wo.title ILIKE :search OR wo.asset_name ILIKE :search)',
{ search: `%${filters.search}%` }
);
}
const skip = (pagination.page - 1) * pagination.limit;
const [data, total] = await queryBuilder
.orderBy('wo.requested_date', 'DESC')
.skip(skip)
.take(pagination.limit)
.getManyAndCount();
return {
data,
total,
page: pagination.page,
limit: pagination.limit,
totalPages: Math.ceil(total / pagination.limit),
};
}
/**
* Update work order
*/
async update(tenantId: string, id: string, dto: UpdateWorkOrderDto, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
// Handle status transitions
if (dto.status && dto.status !== workOrder.status) {
this.validateStatusTransition(workOrder.status, dto.status);
this.applyStatusSideEffects(workOrder, dto.status);
}
Object.assign(workOrder, dto, { updatedBy: userId });
// Recalculate total cost
workOrder.totalCost =
(workOrder.laborCost || 0) +
(workOrder.partsCost || 0) +
(workOrder.externalServiceCost || 0) +
(workOrder.otherCosts || 0);
return this.workOrderRepository.save(workOrder);
}
/**
* Validate status transition
*/
private validateStatusTransition(from: WorkOrderStatus, to: WorkOrderStatus): void {
const validTransitions: Record<WorkOrderStatus, WorkOrderStatus[]> = {
draft: ['scheduled', 'in_progress', 'cancelled'],
scheduled: ['in_progress', 'cancelled'],
in_progress: ['on_hold', 'completed', 'cancelled'],
on_hold: ['in_progress', 'cancelled'],
completed: [],
cancelled: [],
};
if (!validTransitions[from]?.includes(to)) {
throw new Error(`Invalid status transition from ${from} to ${to}`);
}
}
/**
* Apply side effects when status changes
*/
private applyStatusSideEffects(workOrder: WorkOrder, newStatus: WorkOrderStatus): void {
const now = new Date();
switch (newStatus) {
case 'in_progress':
if (!workOrder.actualStartDate) {
workOrder.actualStartDate = now;
}
break;
case 'completed':
workOrder.actualEndDate = now;
break;
}
}
/**
* Start work order (change to in_progress)
*/
async start(tenantId: string, id: string, userId?: string): Promise<WorkOrder | null> {
return this.update(tenantId, id, { status: 'in_progress' }, userId);
}
/**
* Complete work order
*/
async complete(tenantId: string, id: string, dto: CompleteWorkOrderDto, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
// Update work order
workOrder.status = 'completed';
workOrder.actualEndDate = new Date();
workOrder.workPerformed = dto.workPerformed;
workOrder.findings = dto.findings;
workOrder.recommendations = dto.recommendations;
workOrder.actualHours = dto.actualHours;
workOrder.laborCost = dto.laborCost;
workOrder.completedById = dto.completedById || userId;
workOrder.completedByName = dto.completedByName;
workOrder.completionNotes = dto.completionNotes;
workOrder.photosAfter = dto.photosAfter;
workOrder.requiresFollowup = dto.requiresFollowup || false;
workOrder.followupNotes = dto.followupNotes;
workOrder.updatedBy = userId;
// Recalculate total cost
workOrder.totalCost =
(workOrder.laborCost || 0) +
(workOrder.partsCost || 0) +
(workOrder.externalServiceCost || 0) +
(workOrder.otherCosts || 0);
const savedWorkOrder = await this.workOrderRepository.save(workOrder);
// Create maintenance history record
await this.historyRepository.save({
tenantId,
assetId: workOrder.assetId,
workOrderId: workOrder.id,
maintenanceDate: new Date(),
maintenanceType: workOrder.maintenanceType,
description: workOrder.title,
workPerformed: dto.workPerformed,
hoursAtMaintenance: workOrder.hoursAtWorkOrder,
kilometersAtMaintenance: workOrder.kilometersAtWorkOrder,
laborCost: workOrder.laborCost,
partsCost: workOrder.partsCost,
totalCost: workOrder.totalCost,
performedById: dto.completedById || userId,
performedByName: dto.completedByName,
createdBy: userId,
});
return savedWorkOrder;
}
/**
* Add part to work order
*/
async addPart(tenantId: string, workOrderId: string, dto: AddPartDto, userId?: string): Promise<WorkOrderPart> {
const workOrder = await this.findById(tenantId, workOrderId);
if (!workOrder) {
throw new Error('Work order not found');
}
const totalCost = dto.unitCost && dto.quantityUsed
? dto.unitCost * dto.quantityUsed
: undefined;
const part = this.partRepository.create({
tenantId,
workOrderId,
partId: dto.partId,
partCode: dto.partCode,
partName: dto.partName,
partDescription: dto.partDescription,
quantityRequired: dto.quantityRequired,
quantityUsed: dto.quantityUsed,
unitCost: dto.unitCost,
totalCost,
fromInventory: dto.fromInventory || false,
createdBy: userId,
});
const savedPart = await this.partRepository.save(part);
// Update parts cost in work order
await this.recalculatePartsCost(workOrderId);
return savedPart;
}
/**
* Recalculate parts cost for work order
*/
private async recalculatePartsCost(workOrderId: string): Promise<void> {
const parts = await this.partRepository.find({ where: { workOrderId } });
const partsCost = parts.reduce((sum, part) => sum + (Number(part.totalCost) || 0), 0);
const partsCount = parts.length;
await this.workOrderRepository.update(
{ id: workOrderId },
{
partsCost,
partsUsedCount: partsCount,
totalCost: () => `labor_cost + ${partsCost} + external_service_cost + other_costs`,
}
);
}
/**
* Get work order statistics
*/
async getStatistics(tenantId: string): Promise<{
total: number;
byStatus: Record<string, number>;
byType: Record<string, number>;
pendingCount: number;
inProgressCount: number;
completedThisMonth: number;
totalCostThisMonth: number;
}> {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const [total, byStatusRaw, byTypeRaw, pendingCount, inProgressCount, completedThisMonth, costResult] =
await Promise.all([
this.workOrderRepository.count({ where: { tenantId, deletedAt: IsNull() } }),
this.workOrderRepository.createQueryBuilder('wo')
.select('wo.status', 'status')
.addSelect('COUNT(*)', 'count')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.deleted_at IS NULL')
.groupBy('wo.status')
.getRawMany(),
this.workOrderRepository.createQueryBuilder('wo')
.select('wo.maintenance_type', 'type')
.addSelect('COUNT(*)', 'count')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.deleted_at IS NULL')
.groupBy('wo.maintenance_type')
.getRawMany(),
this.workOrderRepository.count({
where: { tenantId, status: 'draft' as WorkOrderStatus, deletedAt: IsNull() },
}),
this.workOrderRepository.count({
where: { tenantId, status: 'in_progress' as WorkOrderStatus, deletedAt: IsNull() },
}),
this.workOrderRepository.createQueryBuilder('wo')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.status = :status', { status: 'completed' as WorkOrderStatus })
.andWhere('wo.actual_end_date >= :startOfMonth', { startOfMonth })
.getCount(),
this.workOrderRepository.createQueryBuilder('wo')
.select('SUM(wo.total_cost)', 'total')
.where('wo.tenant_id = :tenantId', { tenantId })
.andWhere('wo.status = :status', { status: 'completed' as WorkOrderStatus })
.andWhere('wo.actual_end_date >= :startOfMonth', { startOfMonth })
.getRawOne(),
]);
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,
pendingCount,
inProgressCount,
completedThisMonth,
totalCostThisMonth: parseFloat(costResult?.total) || 0,
};
}
/**
* Get work orders grouped by status (for Kanban)
*/
async getByStatus(tenantId: string): Promise<Record<WorkOrderStatus, WorkOrder[]>> {
const workOrders = await this.workOrderRepository.find({
where: { tenantId, deletedAt: IsNull() },
relations: ['asset'],
order: { requestedDate: 'DESC' },
});
const grouped: Record<WorkOrderStatus, WorkOrder[]> = {
draft: [],
scheduled: [],
in_progress: [],
on_hold: [],
completed: [],
cancelled: [],
};
for (const wo of workOrders) {
grouped[wo.status].push(wo);
}
return grouped;
}
/**
* Put work order on hold
*/
async hold(tenantId: string, id: string, reason?: string, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
this.validateStatusTransition(workOrder.status, 'on_hold' as WorkOrderStatus);
workOrder.status = 'on_hold' as WorkOrderStatus;
workOrder.notes = reason ? `${workOrder.notes || ''}\n[ON HOLD] ${reason}` : workOrder.notes;
workOrder.updatedBy = userId;
return this.workOrderRepository.save(workOrder);
}
/**
* Resume work order from hold
*/
async resume(tenantId: string, id: string, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
this.validateStatusTransition(workOrder.status, 'in_progress' as WorkOrderStatus);
workOrder.status = 'in_progress' as WorkOrderStatus;
workOrder.updatedBy = userId;
return this.workOrderRepository.save(workOrder);
}
/**
* Cancel work order
*/
async cancel(tenantId: string, id: string, reason?: string, userId?: string): Promise<WorkOrder | null> {
const workOrder = await this.findById(tenantId, id);
if (!workOrder) return null;
this.validateStatusTransition(workOrder.status, 'cancelled' as WorkOrderStatus);
workOrder.status = 'cancelled' as WorkOrderStatus;
workOrder.notes = reason ? `${workOrder.notes || ''}\n[CANCELLED] ${reason}` : workOrder.notes;
workOrder.updatedBy = userId;
return this.workOrderRepository.save(workOrder);
}
/**
* Soft delete work order
*/
async delete(tenantId: string, id: string, userId?: string): Promise<boolean> {
const result = await this.workOrderRepository.update(
{ id, tenantId },
{ deletedAt: new Date(), updatedBy: userId }
);
return (result.affected ?? 0) > 0;
}
/**
* Get parts for a work order
*/
async getParts(tenantId: string, workOrderId: string): Promise<WorkOrderPart[]> {
return this.partRepository.find({
where: { tenantId, workOrderId },
order: { createdAt: 'ASC' },
});
}
/**
* Update a part
*/
async updatePart(tenantId: string, partId: string, dto: Partial<AddPartDto>, _userId?: string): Promise<WorkOrderPart | null> {
const part = await this.partRepository.findOne({ where: { id: partId, tenantId } });
if (!part) return null;
if (dto.quantityUsed !== undefined) {
part.quantityUsed = dto.quantityUsed;
}
if (dto.unitCost !== undefined) {
part.unitCost = dto.unitCost;
}
if (part.unitCost && part.quantityUsed) {
part.totalCost = part.unitCost * part.quantityUsed;
}
const savedPart = await this.partRepository.save(part);
await this.recalculatePartsCost(part.workOrderId);
return savedPart;
}
/**
* Remove a part from work order
*/
async removePart(tenantId: string, partId: string, _userId?: string): Promise<boolean> {
const part = await this.partRepository.findOne({ where: { id: partId, tenantId } });
if (!part) return false;
const workOrderId = part.workOrderId;
await this.partRepository.remove(part);
await this.recalculatePartsCost(workOrderId);
return true;
}
/**
* Get overdue work orders
*/
async getOverdue(tenantId: string): Promise<WorkOrder[]> {
const now = new Date();
return this.workOrderRepository.find({
where: {
tenantId,
deletedAt: undefined,
status: In(['draft' as WorkOrderStatus, 'scheduled' as WorkOrderStatus, 'in_progress' as WorkOrderStatus]),
scheduledEndDate: LessThanOrEqual(now),
},
relations: ['asset'],
order: { scheduledEndDate: 'ASC' },
});
}
/**
* Get maintenance plans
*/
async getMaintenancePlans(tenantId: string, assetId?: string): Promise<MaintenancePlan[]> {
const where: any = { tenantId, isActive: true, deletedAt: undefined };
if (assetId) {
where.assetId = assetId;
}
return this.planRepository.find({
where,
relations: ['asset', 'category'],
order: { name: 'ASC' },
});
}
/**
* Create a maintenance plan
*/
async createMaintenancePlan(tenantId: string, data: Partial<MaintenancePlan>, userId?: string): Promise<MaintenancePlan> {
const plan = this.planRepository.create({
tenantId,
...data,
isActive: true,
createdBy: userId,
});
return this.planRepository.save(plan);
}
/**
* Generate work orders from a maintenance plan
*/
async generateFromPlan(tenantId: string, planId: string, userId?: string): Promise<WorkOrder[]> {
const plan = await this.planRepository.findOne({
where: { id: planId, tenantId },
relations: ['asset'],
});
if (!plan) {
throw new Error('Maintenance plan not found');
}
const assets: Asset[] = [];
if (plan.assetId && plan.asset) {
assets.push(plan.asset);
} else if (plan.categoryId || plan.assetType) {
const queryBuilder = this.assetRepository.createQueryBuilder('asset')
.where('asset.tenant_id = :tenantId', { tenantId })
.andWhere('asset.deleted_at IS NULL');
if (plan.categoryId) {
queryBuilder.andWhere('asset.category_id = :categoryId', { categoryId: plan.categoryId });
}
if (plan.assetType) {
queryBuilder.andWhere('asset.asset_type = :assetType', { assetType: plan.assetType });
}
const foundAssets = await queryBuilder.getMany();
assets.push(...foundAssets);
}
const createdOrders: WorkOrder[] = [];
for (const asset of assets) {
const workOrder = await this.create(tenantId, {
assetId: asset.id,
maintenanceType: plan.maintenanceType as MaintenanceType,
title: `[${plan.planCode}] ${plan.name}`,
description: plan.description,
estimatedHours: plan.estimatedDurationHours ? Number(plan.estimatedDurationHours) : undefined,
activitiesChecklist: plan.activities,
isScheduled: true,
planId: plan.id,
}, userId);
createdOrders.push(workOrder);
}
return createdOrders;
}
}