erp-mecanicas-diesel-backen.../src/modules/service-management/services/service-order.service.ts
Adrian Flores Cortes 65c42663f0 [SPRINT-4] feat(service-management): Add ServiceOrderService business logic
- Add getCostBreakdown() for order cost calculation
- Add getOrderHistory() for change tracking
- Add assignTechnician() for mechanic assignment
- Add addDiscount() for order-level discounts
- Add updatePriority() for priority management
- Add searchOrders() with multi-field search
- Export new DTOs: ServiceContext, CostBreakdown, OrderHistoryEntry

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 02:46:49 -06:00

1153 lines
34 KiB
TypeScript

/**
* 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<T> {
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<string, number> = {
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<ServiceOrder>;
private itemRepository: Repository<OrderItem>;
private customerRepository: Repository<Customer>;
private vehicleRepository: Repository<Vehicle>;
private userRepository: Repository<User>;
private serviceRepository: Repository<Service>;
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<string> {
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<ServiceOrder> {
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<ServiceOrder> {
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<ServiceOrder | null> {
return this.orderRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find order by order number
*/
async findByOrderNumber(tenantId: string, orderNumber: string): Promise<ServiceOrder | null> {
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<PaginatedResult<ServiceOrder>> {
const where: FindOptionsWhere<ServiceOrder> = { 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<ServiceOrder | null> {
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, ServiceOrderStatus[]> = {
[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<ServiceOrder> {
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<CostBreakdown> {
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<ServiceOrder> {
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<ServiceOrder> {
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<OrderItem | null> {
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<OrderItem[]> {
return this.itemRepository.find({
where: { orderId },
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
}
/**
* Update order item
*/
async updateItem(
itemId: string,
dto: Partial<AddOrderItemDto>
): Promise<OrderItem | null> {
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<boolean> {
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<void> {
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<Record<ServiceOrderStatus, ServiceOrder[]>> {
const orders = await this.orderRepository.find({
where: { tenantId },
order: { receivedAt: 'DESC' },
});
const grouped: Record<ServiceOrderStatus, ServiceOrder[]> = {
[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,
};
}
}