- 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>
750 lines
23 KiB
TypeScript
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;
|
|
}
|
|
}
|