- 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>
1153 lines
34 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|