[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>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 02:46:49 -06:00
parent 3a21a5a0fc
commit 65c42663f0

View File

@ -12,6 +12,10 @@ import {
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 {
@ -75,13 +79,72 @@ export interface PaginatedResult<T> {
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);
}
/**
@ -106,29 +169,133 @@ export class ServiceOrderService {
}
/**
* Create a new service order
* 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 create(tenantId: string, dto: CreateServiceOrderDto, userId?: string): Promise<ServiceOrder> {
const orderNumber = await this.generateOrderNumber(tenantId);
async createOrder(dto: CreateServiceOrderDto, ctx: ServiceContext): Promise<ServiceOrder> {
const { tenantId, userId } = ctx;
const order = this.orderRepository.create({
tenantId,
orderNumber,
customerId: dto.customerId,
vehicleId: dto.vehicleId,
customerSymptoms: dto.customerSymptoms,
priority: dto.priority || ServiceOrderPriority.NORMAL,
status: ServiceOrderStatus.RECEIVED,
promisedAt: dto.promisedAt,
assignedTo: dto.assignedTo,
bayId: dto.bayId,
odometerIn: dto.odometerIn,
internalNotes: dto.internalNotes,
createdBy: userId,
receivedAt: new Date(),
// Validate customer exists and is active
const customer = await this.customerRepository.findOne({
where: { id: dto.customerId, tenantId },
});
return this.orderRepository.save(order);
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;
}
/**
@ -215,24 +382,124 @@ export class ServiceOrderService {
}
/**
* Update service order
* 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> {
const order = await this.findById(tenantId, id);
if (!order) return null;
// Handle status transitions
if (dto.status && dto.status !== order.status) {
this.validateStatusTransition(order.status, dto.status);
this.applyStatusSideEffects(order, dto.status);
try {
const result = await this.updateOrder(id, dto, { tenantId });
return result.order;
} catch {
return null;
}
Object.assign(order, dto);
return this.orderRepository.save(order);
}
/**
@ -275,6 +542,407 @@ export class ServiceOrderService {
}
}
/**
* 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
*/