/** * Service Order Service * Mecánicas Diesel - ERP Suite * * Business logic for service orders management. */ import { Repository, DataSource, FindOptionsWhere, ILike } from 'typeorm'; import { ServiceOrder, ServiceOrderStatus, ServiceOrderPriority, } from '../entities/service-order.entity'; import { OrderItem, OrderItemType, OrderItemStatus } from '../entities/order-item.entity'; import { Customer } from '../../customers/entities/customer.entity'; import { Vehicle } from '../../vehicle-management/entities/vehicle.entity'; import { User, UserRole } from '../../auth/entities/user.entity'; import { Service } from '../entities/service.entity'; // DTOs export interface CreateServiceOrderDto { customerId: string; vehicleId: string; customerSymptoms?: string; priority?: ServiceOrderPriority; promisedAt?: Date; assignedTo?: string; bayId?: string; odometerIn?: number; internalNotes?: string; } export interface UpdateServiceOrderDto { status?: ServiceOrderStatus; priority?: ServiceOrderPriority; assignedTo?: string; bayId?: string; promisedAt?: Date; odometerOut?: number; customerSymptoms?: string; internalNotes?: string; customerNotes?: string; } export interface AddOrderItemDto { itemType: OrderItemType; description: string; quantity: number; unitPrice: number; discountPct?: number; serviceId?: string; partId?: string; estimatedHours?: number; notes?: string; } export interface ServiceOrderFilters { status?: ServiceOrderStatus; priority?: ServiceOrderPriority; customerId?: string; vehicleId?: string; assignedTo?: string; bayId?: string; search?: string; fromDate?: Date; toDate?: Date; } export interface PaginationOptions { page: number; limit: number; } export interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } export interface ServiceContext { tenantId: string; userId?: string; } export interface CostBreakdown { laborSubtotal: number; partsSubtotal: number; subtotal: number; discountPercent: number; discountAmount: number; taxableAmount: number; taxRate: number; taxAmount: number; grandTotal: number; items: { id: string; itemType: OrderItemType; description: string; quantity: number; unitPrice: number; discountPct: number; subtotal: number; status: OrderItemStatus; }[]; } export interface OrderHistoryEntry { field: string; oldValue: unknown; newValue: unknown; changedAt: Date; changedBy?: string; } // Estimated completion times by service type (in hours) const SERVICE_ESTIMATION_HOURS: Record = { diagnostico: 2, cambio_aceite: 1, frenos: 4, suspension: 6, motor: 24, transmision: 16, electrico: 4, turbo: 8, inyectores: 6, bomba_diesel: 8, mantenimiento_general: 4, default: 4, }; export class ServiceOrderService { private orderRepository: Repository; private itemRepository: Repository; private customerRepository: Repository; private vehicleRepository: Repository; private userRepository: Repository; private serviceRepository: Repository; constructor(private dataSource: DataSource) { this.orderRepository = dataSource.getRepository(ServiceOrder); this.itemRepository = dataSource.getRepository(OrderItem); this.customerRepository = dataSource.getRepository(Customer); this.vehicleRepository = dataSource.getRepository(Vehicle); this.userRepository = dataSource.getRepository(User); this.serviceRepository = dataSource.getRepository(Service); } /** * Generate next order number for tenant */ private async generateOrderNumber(tenantId: string): Promise { const year = new Date().getFullYear(); const prefix = `OS-${year}-`; const lastOrder = await this.orderRepository.findOne({ where: { tenantId }, order: { createdAt: 'DESC' }, }); let sequence = 1; if (lastOrder?.orderNumber?.startsWith(prefix)) { const lastSeq = parseInt(lastOrder.orderNumber.replace(prefix, ''), 10); sequence = isNaN(lastSeq) ? 1 : lastSeq + 1; } return `${prefix}${sequence.toString().padStart(5, '0')}`; } /** * Create a new service order with full validation * * Validates: * - Customer exists and is active * - Vehicle exists, belongs to customer, and has complete data * - Generates sequential order number (OS-YYYY-NNNNN) * - Sets initial status to RECEIVED * - Calculates estimated completion date based on service type */ async createOrder(dto: CreateServiceOrderDto, ctx: ServiceContext): Promise { const { tenantId, userId } = ctx; // Validate customer exists and is active const customer = await this.customerRepository.findOne({ where: { id: dto.customerId, tenantId }, }); if (!customer) { throw new Error(`Customer not found: ${dto.customerId}`); } if (!customer.isActive) { throw new Error(`Customer is inactive: ${customer.name}`); } // Validate vehicle exists and has complete data const vehicle = await this.vehicleRepository.findOne({ where: { id: dto.vehicleId, tenantId }, }); if (!vehicle) { throw new Error(`Vehicle not found: ${dto.vehicleId}`); } // Verify vehicle belongs to the customer if (vehicle.customerId !== dto.customerId) { throw new Error(`Vehicle ${vehicle.licensePlate} does not belong to customer ${customer.name}`); } // Check vehicle has complete required data if (!vehicle.make || !vehicle.model || !vehicle.year) { throw new Error( `Vehicle ${vehicle.licensePlate} has incomplete data. ` + `Required: make, model, year. Please update vehicle information first.` ); } // Generate sequential order number const orderNumber = await this.generateOrderNumber(tenantId); // Calculate estimated completion date if not provided let promisedAt = dto.promisedAt; if (!promisedAt) { promisedAt = this.calculateEstimatedDate(dto.priority || ServiceOrderPriority.NORMAL); } // Validate technician if provided if (dto.assignedTo) { const technician = await this.userRepository.findOne({ where: { id: dto.assignedTo, tenantId, isActive: true }, }); if (!technician) { throw new Error(`Technician not found or inactive: ${dto.assignedTo}`); } } // Create the order within a transaction const order = await this.dataSource.transaction(async (manager) => { const orderRepo = manager.getRepository(ServiceOrder); const newOrder = orderRepo.create({ tenantId, orderNumber, customerId: dto.customerId, vehicleId: dto.vehicleId, customerSymptoms: dto.customerSymptoms, priority: dto.priority || ServiceOrderPriority.NORMAL, status: ServiceOrderStatus.RECEIVED, promisedAt, assignedTo: dto.assignedTo, bayId: dto.bayId, odometerIn: dto.odometerIn, internalNotes: dto.internalNotes, createdBy: userId, receivedAt: new Date(), }); return orderRepo.save(newOrder); }); return order; } /** * Create a new service order (legacy method for backward compatibility) */ async create(tenantId: string, dto: CreateServiceOrderDto, userId?: string): Promise { return this.createOrder(dto, { tenantId, userId }); } /** * Calculate estimated completion date based on priority */ private calculateEstimatedDate(priority: ServiceOrderPriority): Date { const now = new Date(); let hoursToAdd: number; switch (priority) { case ServiceOrderPriority.URGENT: hoursToAdd = 8; // Same day break; case ServiceOrderPriority.HIGH: hoursToAdd = 24; // Next day break; case ServiceOrderPriority.NORMAL: hoursToAdd = 48; // 2 business days break; case ServiceOrderPriority.LOW: hoursToAdd = 96; // 4 business days break; default: hoursToAdd = 48; } const estimatedDate = new Date(now.getTime() + hoursToAdd * 60 * 60 * 1000); return estimatedDate; } /** * Find order by ID */ async findById(tenantId: string, id: string): Promise { return this.orderRepository.findOne({ where: { id, tenantId }, }); } /** * Find order by order number */ async findByOrderNumber(tenantId: string, orderNumber: string): Promise { return this.orderRepository.findOne({ where: { tenantId, orderNumber }, }); } /** * List orders with filters and pagination */ async findAll( tenantId: string, filters: ServiceOrderFilters = {}, pagination: PaginationOptions = { page: 1, limit: 20 } ): Promise> { const where: FindOptionsWhere = { tenantId }; if (filters.status) where.status = filters.status; if (filters.priority) where.priority = filters.priority; if (filters.customerId) where.customerId = filters.customerId; if (filters.vehicleId) where.vehicleId = filters.vehicleId; if (filters.assignedTo) where.assignedTo = filters.assignedTo; if (filters.bayId) where.bayId = filters.bayId; const queryBuilder = this.orderRepository.createQueryBuilder('order') .where('order.tenant_id = :tenantId', { tenantId }); if (filters.status) { queryBuilder.andWhere('order.status = :status', { status: filters.status }); } if (filters.priority) { queryBuilder.andWhere('order.priority = :priority', { priority: filters.priority }); } if (filters.customerId) { queryBuilder.andWhere('order.customer_id = :customerId', { customerId: filters.customerId }); } if (filters.vehicleId) { queryBuilder.andWhere('order.vehicle_id = :vehicleId', { vehicleId: filters.vehicleId }); } if (filters.assignedTo) { queryBuilder.andWhere('order.assigned_to = :assignedTo', { assignedTo: filters.assignedTo }); } if (filters.fromDate) { queryBuilder.andWhere('order.received_at >= :fromDate', { fromDate: filters.fromDate }); } if (filters.toDate) { queryBuilder.andWhere('order.received_at <= :toDate', { toDate: filters.toDate }); } if (filters.search) { queryBuilder.andWhere( '(order.order_number ILIKE :search OR order.customer_symptoms ILIKE :search)', { search: `%${filters.search}%` } ); } const skip = (pagination.page - 1) * pagination.limit; const [data, total] = await queryBuilder .orderBy('order.received_at', 'DESC') .skip(skip) .take(pagination.limit) .getManyAndCount(); return { data, total, page: pagination.page, limit: pagination.limit, totalPages: Math.ceil(total / pagination.limit), }; } /** * Update service order with validation and history tracking * * Validates: * - Order exists * - Status transitions are valid * - Fields are modifiable based on current status * - Records history of changes */ async updateOrder( id: string, dto: UpdateServiceOrderDto, ctx: ServiceContext ): Promise<{ order: ServiceOrder; changes: OrderHistoryEntry[] }> { const { tenantId, userId } = ctx; const order = await this.findById(tenantId, id); if (!order) { throw new Error(`Service order not found: ${id}`); } const changes: OrderHistoryEntry[] = []; const now = new Date(); // Handle status transitions with validation if (dto.status && dto.status !== order.status) { this.validateStatusTransition(order.status, dto.status); // Check if modifications are allowed based on status if (order.status === ServiceOrderStatus.DELIVERED) { throw new Error('Cannot modify a delivered order'); } if (order.status === ServiceOrderStatus.CANCELLED) { throw new Error('Cannot modify a cancelled order'); } changes.push({ field: 'status', oldValue: order.status, newValue: dto.status, changedAt: now, changedBy: userId, }); this.applyStatusSideEffects(order, dto.status); } // Check field modification restrictions based on status const restrictedStatuses = [ ServiceOrderStatus.COMPLETED, ServiceOrderStatus.DELIVERED, ServiceOrderStatus.CANCELLED, ]; if (restrictedStatuses.includes(order.status)) { // Only allow updating notes and odometer on completed orders const allowedFields = ['customerNotes', 'internalNotes', 'odometerOut']; const attemptedFields = Object.keys(dto).filter(k => k !== 'status'); const disallowedFields = attemptedFields.filter(f => !allowedFields.includes(f)); if (disallowedFields.length > 0) { throw new Error( `Cannot modify fields [${disallowedFields.join(', ')}] on order with status ${order.status}` ); } } // Track changes for audit const trackableFields: (keyof UpdateServiceOrderDto)[] = [ 'priority', 'assignedTo', 'bayId', 'promisedAt', 'odometerOut', 'customerSymptoms', 'internalNotes', 'customerNotes', ]; for (const field of trackableFields) { if (dto[field] !== undefined && dto[field] !== order[field as keyof ServiceOrder]) { changes.push({ field, oldValue: order[field as keyof ServiceOrder], newValue: dto[field], changedAt: now, changedBy: userId, }); } } // Validate technician if changing assignment if (dto.assignedTo && dto.assignedTo !== order.assignedTo) { const technician = await this.userRepository.findOne({ where: { id: dto.assignedTo, tenantId, isActive: true }, }); if (!technician) { throw new Error(`Technician not found or inactive: ${dto.assignedTo}`); } } // Apply updates Object.assign(order, dto); const savedOrder = await this.orderRepository.save(order); return { order: savedOrder, changes }; } /** * Update service order (legacy method for backward compatibility) */ async update( tenantId: string, id: string, dto: UpdateServiceOrderDto ): Promise { try { const result = await this.updateOrder(id, dto, { tenantId }); return result.order; } catch { return null; } } /** * Validate status transition */ private validateStatusTransition(from: ServiceOrderStatus, to: ServiceOrderStatus): void { const validTransitions: Record = { [ServiceOrderStatus.RECEIVED]: [ServiceOrderStatus.DIAGNOSED, ServiceOrderStatus.CANCELLED], [ServiceOrderStatus.DIAGNOSED]: [ServiceOrderStatus.QUOTED, ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED], [ServiceOrderStatus.QUOTED]: [ServiceOrderStatus.APPROVED, ServiceOrderStatus.CANCELLED], [ServiceOrderStatus.APPROVED]: [ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED], [ServiceOrderStatus.IN_PROGRESS]: [ServiceOrderStatus.WAITING_PARTS, ServiceOrderStatus.COMPLETED, ServiceOrderStatus.CANCELLED], [ServiceOrderStatus.WAITING_PARTS]: [ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.CANCELLED], [ServiceOrderStatus.COMPLETED]: [ServiceOrderStatus.DELIVERED], [ServiceOrderStatus.DELIVERED]: [], [ServiceOrderStatus.CANCELLED]: [], }; if (!validTransitions[from].includes(to)) { throw new Error(`Invalid status transition from ${from} to ${to}`); } } /** * Apply side effects when status changes */ private applyStatusSideEffects(order: ServiceOrder, newStatus: ServiceOrderStatus): void { const now = new Date(); switch (newStatus) { case ServiceOrderStatus.IN_PROGRESS: if (!order.startedAt) order.startedAt = now; break; case ServiceOrderStatus.COMPLETED: order.completedAt = now; break; case ServiceOrderStatus.DELIVERED: order.deliveredAt = now; break; } } /** * Assign a technician to a service order * * Validates: * - Technician exists and is active * - Technician has appropriate role (MECANICO or JEFE_TALLER) * - Order is in an assignable status (RECEIVED or DIAGNOSED) * - Updates order status to IN_PROGRESS if appropriate */ async assignTechnician( orderId: string, technicianId: string, ctx: ServiceContext ): Promise { const { tenantId, userId } = ctx; const order = await this.findById(tenantId, orderId); if (!order) { throw new Error(`Service order not found: ${orderId}`); } // Validate order is in an assignable status const assignableStatuses = [ ServiceOrderStatus.RECEIVED, ServiceOrderStatus.DIAGNOSED, ServiceOrderStatus.QUOTED, ServiceOrderStatus.APPROVED, ]; if (!assignableStatuses.includes(order.status)) { throw new Error( `Cannot assign technician to order with status '${order.status}'. ` + `Order must be in status: ${assignableStatuses.join(', ')}` ); } // Validate technician exists and is active const technician = await this.userRepository.findOne({ where: { id: technicianId, tenantId, isActive: true }, }); if (!technician) { throw new Error(`Technician not found or inactive: ${technicianId}`); } // Validate technician has appropriate role const allowedRoles = [UserRole.MECANICO, UserRole.JEFE_TALLER, UserRole.ADMIN]; if (!allowedRoles.includes(technician.role)) { throw new Error( `User ${technician.fullName} does not have technician role. ` + `Required roles: ${allowedRoles.join(', ')}` ); } // Check technician workload (optional business logic) const activeOrders = await this.orderRepository.count({ where: { tenantId, assignedTo: technicianId, status: ServiceOrderStatus.IN_PROGRESS, }, }); // Warn if technician has many active orders (threshold: 5) if (activeOrders >= 5) { console.warn( `Technician ${technician.fullName} has ${activeOrders} active orders. ` + `Consider load balancing.` ); } // Perform the assignment within a transaction return this.dataSource.transaction(async (manager) => { const orderRepo = manager.getRepository(ServiceOrder); const previousAssignee = order.assignedTo; order.assignedTo = technicianId; // If order is in APPROVED status and being assigned, move to IN_PROGRESS if (order.status === ServiceOrderStatus.APPROVED) { order.status = ServiceOrderStatus.IN_PROGRESS; order.startedAt = new Date(); } // Record internal note about assignment const assignmentNote = previousAssignee ? `Reassigned from ${previousAssignee} to ${technician.fullName}` : `Assigned to ${technician.fullName}`; order.internalNotes = order.internalNotes ? `${order.internalNotes}\n[${new Date().toISOString()}] ${assignmentNote}` : `[${new Date().toISOString()}] ${assignmentNote}`; return orderRepo.save(order); }); } /** * Calculate detailed cost breakdown for a service order * * Returns: * - Labor subtotal (sum of all service items) * - Parts subtotal (sum of all part items) * - Discount calculation * - IVA (16%) calculation * - Grand total * - Itemized breakdown */ async calculateCosts(orderId: string, ctx: ServiceContext): Promise { const { tenantId } = ctx; const order = await this.findById(tenantId, orderId); if (!order) { throw new Error(`Service order not found: ${orderId}`); } const items = await this.getItems(orderId); let laborSubtotal = 0; let partsSubtotal = 0; const itemizedBreakdown = items.map((item) => { const itemSubtotal = Number(item.subtotal); if (item.itemType === OrderItemType.SERVICE) { laborSubtotal += itemSubtotal; } else { partsSubtotal += itemSubtotal; } return { id: item.id, itemType: item.itemType, description: item.description, quantity: Number(item.quantity), unitPrice: Number(item.unitPrice), discountPct: Number(item.discountPct), subtotal: itemSubtotal, status: item.status, }; }); const subtotal = laborSubtotal + partsSubtotal; const discountPercent = Number(order.discountPercent) || 0; const discountAmount = subtotal * (discountPercent / 100); const taxableAmount = subtotal - discountAmount; const taxRate = 0.16; // 16% IVA Mexico const taxAmount = taxableAmount * taxRate; const grandTotal = taxableAmount + taxAmount; return { laborSubtotal, partsSubtotal, subtotal, discountPercent, discountAmount, taxableAmount, taxRate, taxAmount, grandTotal, items: itemizedBreakdown, }; } /** * Close a service order (mark as COMPLETED) * * Validates: * - All work items are completed * - No pending parts items * - Order is in a closeable status * * Actions: * - Sets status to COMPLETED * - Records completion timestamp * - Updates customer stats * - Prepares order for invoicing */ async closeOrder(orderId: string, ctx: ServiceContext): Promise { const { tenantId, userId } = ctx; const order = await this.findById(tenantId, orderId); if (!order) { throw new Error(`Service order not found: ${orderId}`); } // Validate order is in a closeable status const closeableStatuses = [ServiceOrderStatus.IN_PROGRESS, ServiceOrderStatus.WAITING_PARTS]; if (!closeableStatuses.includes(order.status)) { throw new Error( `Cannot close order with status '${order.status}'. ` + `Order must be in status: ${closeableStatuses.join(' or ')}` ); } // Get all order items const items = await this.getItems(orderId); if (items.length === 0) { throw new Error('Cannot close order without any work items'); } // Check all items are completed const pendingItems = items.filter( (item) => item.status !== OrderItemStatus.COMPLETED ); if (pendingItems.length > 0) { const pendingDescriptions = pendingItems .map((item) => `- ${item.description} (${item.status})`) .join('\n'); throw new Error( `Cannot close order. The following items are not completed:\n${pendingDescriptions}` ); } // Check for pending parts (items marked as PART type with PENDING status) const pendingParts = items.filter( (item) => item.itemType === OrderItemType.PART && item.status === OrderItemStatus.PENDING ); if (pendingParts.length > 0) { throw new Error( `Cannot close order. There are ${pendingParts.length} pending parts that need to be resolved.` ); } // Close the order within a transaction return this.dataSource.transaction(async (manager) => { const orderRepo = manager.getRepository(ServiceOrder); const customerRepo = manager.getRepository(Customer); const now = new Date(); // Update order status order.status = ServiceOrderStatus.COMPLETED; order.completedAt = now; // Add completion note const closureNote = `Order closed by ${userId || 'system'} at ${now.toISOString()}`; order.internalNotes = order.internalNotes ? `${order.internalNotes}\n${closureNote}` : closureNote; // Recalculate final totals await this.recalculateTotals(orderId); // Reload order with updated totals const updatedOrder = await orderRepo.findOne({ where: { id: orderId } }); if (!updatedOrder) { throw new Error('Failed to reload order after total recalculation'); } // Update customer statistics const customer = await customerRepo.findOne({ where: { id: order.customerId, tenantId }, }); if (customer) { customer.totalOrders = (customer.totalOrders || 0) + 1; customer.totalSpent = Number(customer.totalSpent || 0) + Number(updatedOrder.grandTotal); customer.lastVisitAt = now; await customerRepo.save(customer); } // Save and return the completed order return orderRepo.save(updatedOrder); }); } /** * Deliver a completed order to customer * * Final step in the order lifecycle: * - Validates order is completed * - Records delivery timestamp * - Updates vehicle odometer if provided */ async deliverOrder( orderId: string, odometerOut?: number, ctx?: ServiceContext ): Promise { const tenantId = ctx?.tenantId; if (!tenantId) { throw new Error('Tenant ID is required'); } const order = await this.findById(tenantId, orderId); if (!order) { throw new Error(`Service order not found: ${orderId}`); } if (order.status !== ServiceOrderStatus.COMPLETED) { throw new Error( `Cannot deliver order with status '${order.status}'. Order must be completed first.` ); } return this.dataSource.transaction(async (manager) => { const orderRepo = manager.getRepository(ServiceOrder); const vehicleRepo = manager.getRepository(Vehicle); const now = new Date(); order.status = ServiceOrderStatus.DELIVERED; order.deliveredAt = now; if (odometerOut !== undefined) { if (order.odometerIn && odometerOut < order.odometerIn) { throw new Error( `Odometer out (${odometerOut}) cannot be less than odometer in (${order.odometerIn})` ); } order.odometerOut = odometerOut; // Update vehicle's current odometer const vehicle = await vehicleRepo.findOne({ where: { id: order.vehicleId, tenantId }, }); if (vehicle) { vehicle.currentOdometer = odometerOut; vehicle.odometerUpdatedAt = now; await vehicleRepo.save(vehicle); } } return orderRepo.save(order); }); } /** * Get technician workload summary */ async getTechnicianWorkload( technicianId: string, ctx: ServiceContext ): Promise<{ activeOrders: number; completedToday: number; pendingItems: number; estimatedHoursRemaining: number; }> { const { tenantId } = ctx; const today = new Date(); today.setHours(0, 0, 0, 0); const [activeOrders, completedToday] = await Promise.all([ this.orderRepository.count({ where: { tenantId, assignedTo: technicianId, status: ServiceOrderStatus.IN_PROGRESS, }, }), this.orderRepository .createQueryBuilder('order') .where('order.tenant_id = :tenantId', { tenantId }) .andWhere('order.assigned_to = :technicianId', { technicianId }) .andWhere('order.status = :status', { status: ServiceOrderStatus.COMPLETED }) .andWhere('order.completed_at >= :today', { today }) .getCount(), ]); // Get pending items for active orders const activeOrderIds = await this.orderRepository .createQueryBuilder('order') .select('order.id') .where('order.tenant_id = :tenantId', { tenantId }) .andWhere('order.assigned_to = :technicianId', { technicianId }) .andWhere('order.status = :status', { status: ServiceOrderStatus.IN_PROGRESS }) .getMany(); let pendingItems = 0; let estimatedHoursRemaining = 0; for (const order of activeOrderIds) { const items = await this.itemRepository.find({ where: { orderId: order.id, status: OrderItemStatus.PENDING, }, }); pendingItems += items.length; estimatedHoursRemaining += items.reduce( (sum, item) => sum + (Number(item.estimatedHours) || 0), 0 ); } return { activeOrders, completedToday, pendingItems, estimatedHoursRemaining, }; } /** * Add item to order */ async addItem(tenantId: string, orderId: string, dto: AddOrderItemDto): Promise { const order = await this.findById(tenantId, orderId); if (!order) return null; const subtotal = dto.quantity * dto.unitPrice * (1 - (dto.discountPct || 0) / 100); const item = this.itemRepository.create({ orderId, itemType: dto.itemType, description: dto.description, quantity: dto.quantity, unitPrice: dto.unitPrice, discountPct: dto.discountPct || 0, subtotal, serviceId: dto.serviceId, partId: dto.partId, estimatedHours: dto.estimatedHours, notes: dto.notes, status: OrderItemStatus.PENDING, }); const savedItem = await this.itemRepository.save(item); // Recalculate totals await this.recalculateTotals(orderId); return savedItem; } /** * Get order items */ async getItems(orderId: string): Promise { return this.itemRepository.find({ where: { orderId }, order: { sortOrder: 'ASC', createdAt: 'ASC' }, }); } /** * Update order item */ async updateItem( itemId: string, dto: Partial ): Promise { const item = await this.itemRepository.findOne({ where: { id: itemId } }); if (!item) return null; if (dto.quantity !== undefined || dto.unitPrice !== undefined || dto.discountPct !== undefined) { const quantity = dto.quantity ?? item.quantity; const unitPrice = dto.unitPrice ?? item.unitPrice; const discountPct = dto.discountPct ?? item.discountPct; item.subtotal = quantity * unitPrice * (1 - discountPct / 100); } Object.assign(item, dto); const savedItem = await this.itemRepository.save(item); // Recalculate totals await this.recalculateTotals(item.orderId); return savedItem; } /** * Remove order item */ async removeItem(itemId: string): Promise { const item = await this.itemRepository.findOne({ where: { id: itemId } }); if (!item) return false; const orderId = item.orderId; await this.itemRepository.remove(item); // Recalculate totals await this.recalculateTotals(orderId); return true; } /** * Recalculate order totals */ private async recalculateTotals(orderId: string): Promise { const items = await this.getItems(orderId); let laborTotal = 0; let partsTotal = 0; for (const item of items) { if (item.itemType === OrderItemType.SERVICE) { laborTotal += Number(item.subtotal); } else { partsTotal += Number(item.subtotal); } } const order = await this.orderRepository.findOne({ where: { id: orderId } }); if (!order) return; order.laborTotal = laborTotal; order.partsTotal = partsTotal; const subtotal = laborTotal + partsTotal; const discountAmount = subtotal * (Number(order.discountPercent) / 100); order.discountAmount = discountAmount; const taxableAmount = subtotal - discountAmount; order.tax = taxableAmount * 0.16; // 16% IVA México order.grandTotal = taxableAmount + order.tax; await this.orderRepository.save(order); } /** * Get orders by status (for Kanban board) */ async getOrdersByStatus(tenantId: string): Promise> { const orders = await this.orderRepository.find({ where: { tenantId }, order: { receivedAt: 'DESC' }, }); const grouped: Record = { [ServiceOrderStatus.RECEIVED]: [], [ServiceOrderStatus.DIAGNOSED]: [], [ServiceOrderStatus.QUOTED]: [], [ServiceOrderStatus.APPROVED]: [], [ServiceOrderStatus.IN_PROGRESS]: [], [ServiceOrderStatus.WAITING_PARTS]: [], [ServiceOrderStatus.COMPLETED]: [], [ServiceOrderStatus.DELIVERED]: [], [ServiceOrderStatus.CANCELLED]: [], }; for (const order of orders) { grouped[order.status].push(order); } return grouped; } /** * Get dashboard statistics */ async getDashboardStats(tenantId: string): Promise<{ totalOrders: number; pendingOrders: number; inProgressOrders: number; completedToday: number; totalRevenue: number; averageTicket: number; }> { const today = new Date(); today.setHours(0, 0, 0, 0); const [ totalOrders, pendingOrders, inProgressOrders, completedToday, revenueResult, ] = await Promise.all([ this.orderRepository.count({ where: { tenantId } }), this.orderRepository.count({ where: { tenantId, status: ServiceOrderStatus.RECEIVED }, }), this.orderRepository.count({ where: { tenantId, status: ServiceOrderStatus.IN_PROGRESS }, }), this.orderRepository.createQueryBuilder('order') .where('order.tenant_id = :tenantId', { tenantId }) .andWhere('order.status = :status', { status: ServiceOrderStatus.COMPLETED }) .andWhere('order.completed_at >= :today', { today }) .getCount(), this.orderRepository.createQueryBuilder('order') .select('SUM(order.grand_total)', 'total') .where('order.tenant_id = :tenantId', { tenantId }) .andWhere('order.status IN (:...statuses)', { statuses: [ServiceOrderStatus.COMPLETED, ServiceOrderStatus.DELIVERED], }) .getRawOne(), ]); const totalRevenue = parseFloat(revenueResult?.total) || 0; const completedCount = await this.orderRepository.count({ where: { tenantId, status: ServiceOrderStatus.COMPLETED, }, }); return { totalOrders, pendingOrders, inProgressOrders, completedToday, totalRevenue, averageTicket: completedCount > 0 ? totalRevenue / completedCount : 0, }; } }