/** * 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[]; 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[]; 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; private partRepository: Repository; private historyRepository: Repository; private planRepository: Repository; private assetRepository: Repository; 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 { 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 { // 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 { return this.workOrderRepository.findOne({ where: { id, tenantId }, relations: ['asset', 'partsUsed'], }); } /** * Find work order by number */ async findByNumber(tenantId: string, workOrderNumber: string): Promise { 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 { 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 = { 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 { return this.update(tenantId, id, { status: 'in_progress' }, userId); } /** * Complete work order */ async complete(tenantId: string, id: string, dto: CompleteWorkOrderDto, userId?: string): Promise { 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 { 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 { 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; byType: Record; 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 = {}; 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, pendingCount, inProgressCount, completedThisMonth, totalCostThisMonth: parseFloat(costResult?.total) || 0, }; } /** * Get work orders grouped by status (for Kanban) */ async getByStatus(tenantId: string): Promise> { const workOrders = await this.workOrderRepository.find({ where: { tenantId, deletedAt: IsNull() }, relations: ['asset'], order: { requestedDate: 'DESC' }, }); const grouped: Record = { 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 { 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 { 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 { 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 { 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 { return this.partRepository.find({ where: { tenantId, workOrderId }, order: { createdAt: 'ASC' }, }); } /** * Update a part */ async updatePart(tenantId: string, partId: string, dto: Partial, _userId?: string): Promise { 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 { 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 { 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 { 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, userId?: string): Promise { 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 { 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; } }