diff --git a/src/modules/service-management/controllers/diagnostic.controller.ts b/src/modules/service-management/controllers/diagnostic.controller.ts index 363e6e6..436735a 100644 --- a/src/modules/service-management/controllers/diagnostic.controller.ts +++ b/src/modules/service-management/controllers/diagnostic.controller.ts @@ -3,11 +3,21 @@ * Mecánicas Diesel - ERP Suite * * REST API endpoints for vehicle diagnostics. + * Supports the complete diagnostic workflow including: + * - Creating diagnostics for service orders + * - Processing scanner results (OBD-II codes) + * - Completing diagnostics with quote generation + * - Generating diagnostic reports */ import { Router, Request, Response, NextFunction } from 'express'; import { DataSource } from 'typeorm'; -import { DiagnosticService } from '../services/diagnostic.service'; +import { + DiagnosticService, + CreateDiagnosticDto, + UpdateDiagnosticDto, + ScannerDataDto, +} from '../services/diagnostic.service'; import { DiagnosticType, DiagnosticResult } from '../entities/diagnostic.entity'; interface TenantRequest extends Request { @@ -31,11 +41,63 @@ export function createDiagnosticController(dataSource: DataSource): Router { router.use(extractTenant); + // ============================================ + // CORE DIAGNOSTIC ENDPOINTS + // ============================================ + /** - * Create a new diagnostic + * Create a new diagnostic for an order * POST /api/diagnostics + * + * Body: + * - orderId: string (required) - Service order ID + * - type: DiagnosticType (required) - Type of diagnostic + * - findings?: string - Initial findings + * - recommendations?: string - Initial recommendations + * - estimatedCost?: number - Estimated repair cost + * - scannerData?: ScannerDataDto - Scanner data if applicable + * - equipment?: string - Equipment used + * - performedBy?: string - Technician ID */ router.post('/', async (req: TenantRequest, res: Response) => { + try { + const dto: CreateDiagnosticDto = { + orderId: req.body.orderId, + type: req.body.type || req.body.diagnosticType, + findings: req.body.findings, + recommendations: req.body.recommendations, + estimatedCost: req.body.estimatedCost, + scannerData: req.body.scannerData, + equipment: req.body.equipment, + performedBy: req.body.performedBy, + }; + + if (!dto.orderId) { + return res.status(400).json({ error: 'orderId is required' }); + } + if (!dto.type) { + return res.status(400).json({ error: 'type is required' }); + } + + const diagnostic = await service.createDiagnostic(req.tenantId!, dto, req.userId); + res.status(201).json(diagnostic); + } catch (error) { + const message = (error as Error).message; + if (message.includes('not found')) { + return res.status(404).json({ error: message }); + } + if (message.includes('Cannot create diagnostic')) { + return res.status(409).json({ error: message }); + } + res.status(400).json({ error: message }); + } + }); + + /** + * Create a basic diagnostic (legacy endpoint) + * POST /api/diagnostics/basic + */ + router.post('/basic', async (req: TenantRequest, res: Response) => { try { const diagnostic = await service.create(req.tenantId!, req.body); res.status(201).json(diagnostic); @@ -45,7 +107,7 @@ export function createDiagnosticController(dataSource: DataSource): Router { }); /** - * Get a single diagnostic + * Get a single diagnostic by ID * GET /api/diagnostics/:id */ router.get('/:id', async (req: TenantRequest, res: Response) => { @@ -60,13 +122,58 @@ export function createDiagnosticController(dataSource: DataSource): Router { } }); + /** + * Update a diagnostic + * PATCH /api/diagnostics/:id + * + * Body: + * - findings?: string + * - recommendations?: string + * - result?: DiagnosticResult + * - scannerData?: ScannerDataDto + * - equipment?: string + * - estimatedCost?: number + */ + router.patch('/:id', async (req: TenantRequest, res: Response) => { + try { + const dto: UpdateDiagnosticDto = { + findings: req.body.findings, + recommendations: req.body.recommendations, + result: req.body.result, + scannerData: req.body.scannerData, + equipment: req.body.equipment, + estimatedCost: req.body.estimatedCost, + }; + + const diagnostic = await service.updateDiagnostic(req.tenantId!, req.params.id, dto); + if (!diagnostic) { + return res.status(404).json({ error: 'Diagnostic not found' }); + } + res.json(diagnostic); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ============================================ + // DIAGNOSTIC QUERY ENDPOINTS + // ============================================ + /** * Get diagnostics by vehicle * GET /api/diagnostics/vehicle/:vehicleId + * + * Query params: + * - limit?: number (default: 50) */ router.get('/vehicle/:vehicleId', async (req: TenantRequest, res: Response) => { try { - const diagnostics = await service.findByVehicle(req.tenantId!, req.params.vehicleId); + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 50; + const diagnostics = await service.getDiagnosticsByVehicle( + req.tenantId!, + req.params.vehicleId, + limit + ); res.json(diagnostics); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -79,7 +186,7 @@ export function createDiagnosticController(dataSource: DataSource): Router { */ router.get('/order/:orderId', async (req: TenantRequest, res: Response) => { try { - const diagnostics = await service.findByOrder(req.tenantId!, req.params.orderId); + const diagnostics = await service.getDiagnosticsByOrder(req.tenantId!, req.params.orderId); res.json(diagnostics); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -99,13 +206,118 @@ export function createDiagnosticController(dataSource: DataSource): Router { } }); + // ============================================ + // SCANNER AND TEST ENDPOINTS + // ============================================ + /** - * Update diagnostic result + * Add scanner results to a diagnostic + * POST /api/diagnostics/:id/scanner-results + * + * Body: + * - rawData: string | object (required) - Raw scanner output + * - deviceModel?: string + * - deviceSerial?: string + * - softwareVersion?: string + * - timestamp?: Date + */ + router.post('/:id/scanner-results', async (req: TenantRequest, res: Response) => { + try { + const scannerData: ScannerDataDto = { + rawData: req.body.rawData, + deviceModel: req.body.deviceModel, + deviceSerial: req.body.deviceSerial, + softwareVersion: req.body.softwareVersion, + timestamp: req.body.timestamp ? new Date(req.body.timestamp) : undefined, + }; + + if (!scannerData.rawData) { + return res.status(400).json({ error: 'rawData is required' }); + } + + const diagnostic = await service.addScannerResults( + req.tenantId!, + req.params.id, + scannerData + ); + res.json(diagnostic); + } catch (error) { + const message = (error as Error).message; + if (message.includes('not found')) { + return res.status(404).json({ error: message }); + } + res.status(400).json({ error: message }); + } + }); + + /** + * Parse DTC codes from raw data (utility endpoint) + * POST /api/diagnostics/parse-dtc + * + * Body: + * - rawData: string | object - Scanner output to parse + */ + router.post('/parse-dtc', async (req: TenantRequest, res: Response) => { + try { + const items = service.parseDTCCodes(req.body.rawData || req.body || {}); + res.json(items); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Analyze injector test results (utility endpoint) + * POST /api/diagnostics/analyze-injectors + * + * Body: + * - rawData: object with injectors/cylinders array + */ + router.post('/analyze-injectors', async (req: TenantRequest, res: Response) => { + try { + const items = service.analyzeInjectorTest(req.body.rawData || req.body || {}); + res.json(items); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + /** + * Analyze compression test results (utility endpoint) + * POST /api/diagnostics/analyze-compression + * + * Body: + * - rawData: object with readings/cylinders/compression array + */ + router.post('/analyze-compression', async (req: TenantRequest, res: Response) => { + try { + const items = service.analyzeCompressionTest(req.body.rawData || req.body || {}); + res.json(items); + } catch (error) { + res.status(400).json({ error: (error as Error).message }); + } + }); + + // ============================================ + // DIAGNOSTIC WORKFLOW ENDPOINTS + // ============================================ + + /** + * Update diagnostic result (legacy endpoint) * PATCH /api/diagnostics/:id/result + * + * Body: + * - result: DiagnosticResult (required) + * - summary?: string */ router.patch('/:id/result', async (req: TenantRequest, res: Response) => { try { const { result, summary } = req.body; + + if (!result) { + return res.status(400).json({ error: 'result is required' }); + } + const diagnostic = await service.updateResult( req.tenantId!, req.params.id, @@ -122,28 +334,79 @@ export function createDiagnosticController(dataSource: DataSource): Router { }); /** - * Parse DTC codes from raw data - * POST /api/diagnostics/parse-dtc + * Complete a diagnostic + * POST /api/diagnostics/:id/complete + * + * Marks diagnostic as complete and optionally triggers quote generation + * for FAIL or NEEDS_ATTENTION results. + * + * Body: + * - result: DiagnosticResult (required) - PASS, FAIL, or NEEDS_ATTENTION + * - summary?: string - Final summary + * - triggerQuote?: boolean (default: true) - Create draft quote if needed */ - router.post('/parse-dtc', async (req: TenantRequest, res: Response) => { + router.post('/:id/complete', async (req: TenantRequest, res: Response) => { try { - const items = service.parseDTCCodes(req.body.rawData || {}); - res.json(items); + const { result, summary, triggerQuote } = req.body; + + if (!result) { + return res.status(400).json({ error: 'result is required' }); + } + + const validResults = Object.values(DiagnosticResult); + if (!validResults.includes(result)) { + return res.status(400).json({ + error: `Invalid result. Must be one of: ${validResults.join(', ')}`, + }); + } + + const response = await service.completeDiagnostic( + req.tenantId!, + req.params.id, + result as DiagnosticResult, + summary, + triggerQuote !== false + ); + + res.json({ + diagnostic: response.diagnostic, + quote: response.quote, + message: response.quote + ? `Diagnostic completed. Draft quote ${response.quote.quoteNumber} created.` + : 'Diagnostic completed.', + }); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + const message = (error as Error).message; + if (message.includes('not found')) { + return res.status(404).json({ error: message }); + } + if (message.includes('already been completed')) { + return res.status(409).json({ error: message }); + } + res.status(400).json({ error: message }); } }); + // ============================================ + // REPORT ENDPOINTS + // ============================================ + /** - * Analyze injector test results - * POST /api/diagnostics/analyze-injectors + * Generate diagnostic report data + * GET /api/diagnostics/:id/report + * + * Returns structured data for generating a printable report. */ - router.post('/analyze-injectors', async (req: TenantRequest, res: Response) => { + router.get('/:id/report', async (req: TenantRequest, res: Response) => { try { - const items = service.analyzeInjectorTest(req.body.rawData || {}); - res.json(items); + const reportData = await service.generateDiagnosticReport(req.tenantId!, req.params.id); + res.json(reportData); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + const message = (error as Error).message; + if (message.includes('not found')) { + return res.status(404).json({ error: message }); + } + res.status(500).json({ error: message }); } }); diff --git a/src/modules/service-management/entities/index.ts b/src/modules/service-management/entities/index.ts index 9139ccd..3b4f3b7 100644 --- a/src/modules/service-management/entities/index.ts +++ b/src/modules/service-management/entities/index.ts @@ -1,11 +1,12 @@ /** * Service Management Entities Index - * Mecánicas Diesel - ERP Suite + * Mecanicas Diesel - ERP Suite */ export * from './service-order.entity'; export * from './order-item.entity'; export * from './diagnostic.entity'; export * from './quote.entity'; +export * from './quote-item.entity'; export * from './work-bay.entity'; export * from './service.entity'; diff --git a/src/modules/service-management/entities/quote-item.entity.ts b/src/modules/service-management/entities/quote-item.entity.ts new file mode 100644 index 0000000..60c1e63 --- /dev/null +++ b/src/modules/service-management/entities/quote-item.entity.ts @@ -0,0 +1,69 @@ +/** + * Quote Item Entity + * Mecanicas Diesel - ERP Suite + * + * Represents line items (services or parts) in a quotation. + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Quote } from './quote.entity'; + +export enum QuoteItemType { + SERVICE = 'service', + PART = 'part', +} + +@Entity({ name: 'quote_items', schema: 'service_management' }) +@Index('idx_quote_items_quote', ['quoteId']) +export class QuoteItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'quote_id', type: 'uuid' }) + quoteId: string; + + @Column({ name: 'item_type', type: 'varchar', length: 20 }) + itemType: QuoteItemType; + + @Column({ name: 'service_id', type: 'uuid', nullable: true }) + serviceId?: string; + + @Column({ name: 'part_id', type: 'uuid', nullable: true }) + partId?: string; + + @Column({ type: 'varchar', length: 500 }) + description: string; + + @Column({ type: 'decimal', precision: 10, scale: 3, default: 1 }) + quantity: number; + + @Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 }) + unitPrice: number; + + @Column({ name: 'discount_pct', type: 'decimal', precision: 5, scale: 2, default: 0 }) + discountPct: number; + + @Column({ type: 'decimal', precision: 12, scale: 2 }) + subtotal: number; + + @Column({ name: 'is_approved', type: 'boolean', default: true }) + isApproved: boolean; + + @Column({ name: 'sort_order', type: 'integer', default: 0 }) + sortOrder: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @ManyToOne(() => Quote, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'quote_id' }) + quote: Quote; +} diff --git a/src/modules/service-management/index.ts b/src/modules/service-management/index.ts index 855578a..746edcd 100644 --- a/src/modules/service-management/index.ts +++ b/src/modules/service-management/index.ts @@ -8,13 +8,45 @@ export { ServiceOrder, ServiceOrderStatus, ServiceOrderPriority } from './entiti export { OrderItem, OrderItemType, OrderItemStatus } from './entities/order-item.entity'; export { Diagnostic, DiagnosticType, DiagnosticResult } from './entities/diagnostic.entity'; export { Quote, QuoteStatus } from './entities/quote.entity'; +export { QuoteItem, QuoteItemType } from './entities/quote-item.entity'; export { WorkBay, BayStatus, BayType } from './entities/work-bay.entity'; export { Service } from './entities/service.entity'; // Services export { ServiceOrderService, CreateServiceOrderDto, UpdateServiceOrderDto, ServiceOrderFilters } from './services/service-order.service'; -export { DiagnosticService, CreateDiagnosticDto, DiagnosticItemDto, DiagnosticRecommendationDto } from './services/diagnostic.service'; -export { QuoteService, CreateQuoteDto, QuoteItemDto, ApplyDiscountDto } from './services/quote.service'; +export { + DiagnosticService, + CreateDiagnosticDto, + UpdateDiagnosticDto, + ScannerDataDto, + ScannerResultDto, + ParsedDTCCode, + DiagnosticItemDto, + DiagnosticRecommendationDto, + DiagnosticReportData, +} from './services/diagnostic.service'; +export { + QuoteService, + CreateQuoteDto, + AddQuoteItemDto, + UpdateQuoteItemDto, + CustomerResponseDto, + ApplyDiscountDto, + QuoteFilters, + QuoteWithItems, + SendQuoteResult, +} from './services/quote.service'; +export { + VehicleReceptionService, + CreateVehicleReceptionDto, + QuickReceptionDto, + ReceptionFormData, + ServiceOrderSummary, + VehicleReceptionResult, + PlateValidationResult, + NewCustomerInfo, + NewVehicleInfo, +} from './services/vehicle-reception.service'; // Controllers export { createServiceOrderController } from './controllers/service-order.controller'; diff --git a/src/modules/service-management/services/diagnostic.service.ts b/src/modules/service-management/services/diagnostic.service.ts index 38d9aad..74b004d 100644 --- a/src/modules/service-management/services/diagnostic.service.ts +++ b/src/modules/service-management/services/diagnostic.service.ts @@ -2,7 +2,11 @@ * Diagnostic Service * Mecánicas Diesel - ERP Suite * - * Business logic for vehicle diagnostics. + * Business logic for vehicle diagnostics including: + * - Creating diagnostics for service orders + * - Processing scanner results (OBD-II codes) + * - Managing diagnostic workflow + * - Generating diagnostic reports */ import { Repository, DataSource } from 'typeorm'; @@ -11,18 +15,78 @@ import { DiagnosticType, DiagnosticResult, } from '../entities/diagnostic.entity'; +import { ServiceOrder, ServiceOrderStatus } from '../entities/service-order.entity'; +import { Quote, QuoteStatus } from '../entities/quote.entity'; +// ============================================ // DTOs +// ============================================ + +/** + * DTO for creating a new diagnostic + */ export interface CreateDiagnosticDto { - vehicleId: string; - orderId?: string; - diagnosticType: DiagnosticType; + orderId: string; + type: DiagnosticType; + findings?: string; + recommendations?: string; + estimatedCost?: number; + scannerData?: ScannerDataDto; equipment?: string; performedBy?: string; - summary?: string; - rawData?: Record; } +/** + * DTO for updating an existing diagnostic + */ +export interface UpdateDiagnosticDto { + findings?: string; + recommendations?: string; + result?: DiagnosticResult; + scannerData?: ScannerDataDto; + equipment?: string; + estimatedCost?: number; +} + +/** + * DTO for scanner data input + */ +export interface ScannerDataDto { + rawData: string | Record; + deviceModel?: string; + deviceSerial?: string; + softwareVersion?: string; + timestamp?: Date; +} + +/** + * DTO for parsed scanner results + */ +export interface ScannerResultDto { + rawData: string | Record; + parsedCodes: ParsedDTCCode[]; + timestamp: Date; + deviceInfo?: { + model?: string; + serial?: string; + softwareVersion?: string; + }; +} + +/** + * Parsed DTC code structure + */ +export interface ParsedDTCCode { + code: string; + description: string; + severity: 'critical' | 'warning' | 'info'; + system: string; + suggestedAction?: string; +} + +/** + * Diagnostic item for detailed findings + */ export interface DiagnosticItemDto { itemType: 'dtc_code' | 'test_result' | 'measurement' | 'observation'; code?: string; @@ -39,6 +103,9 @@ export interface DiagnosticItemDto { notes?: string; } +/** + * Diagnostic recommendation structure + */ export interface DiagnosticRecommendationDto { description: string; priority: 'critical' | 'high' | 'medium' | 'low'; @@ -48,32 +115,444 @@ export interface DiagnosticRecommendationDto { notes?: string; } +/** + * Diagnostic report data structure + */ +export interface DiagnosticReportData { + diagnostic: Diagnostic; + order: ServiceOrder | null; + vehicle: { + id: string; + plate?: string; + make?: string; + model?: string; + year?: number; + vin?: string; + } | null; + customer: { + id: string; + name?: string; + phone?: string; + email?: string; + } | null; + parsedCodes: ParsedDTCCode[]; + recommendations: DiagnosticRecommendationDto[]; + testResults: DiagnosticItemDto[]; + generatedAt: Date; + technicianName?: string; +} + +/** + * DiagnosticService + * + * Handles the complete diagnostic workflow for diesel vehicles: + * - Creating diagnostics linked to service orders + * - Processing and parsing OBD-II scanner data + * - Managing diagnostic lifecycle + * - Generating printable reports + * - Triggering quote generation when issues are found + */ export class DiagnosticService { private diagnosticRepository: Repository; + private orderRepository: Repository; + private quoteRepository: Repository; constructor(private dataSource: DataSource) { this.diagnosticRepository = dataSource.getRepository(Diagnostic); + this.orderRepository = dataSource.getRepository(ServiceOrder); + this.quoteRepository = dataSource.getRepository(Quote); + } + + // ============================================ + // CORE CRUD OPERATIONS + // ============================================ + + /** + * Create a new diagnostic for a service order + * + * Validates: + * - Order exists and belongs to tenant + * - Order is in valid status (received or diagnosed) + * + * Side effects: + * - Updates order status to 'diagnosed' if this is the first diagnostic + * + * @param tenantId - Tenant identifier for multi-tenancy + * @param dto - Create diagnostic data + * @param userId - User performing the diagnostic (optional) + * @returns Created diagnostic record + * @throws Error if order not found or invalid status + */ + async createDiagnostic( + tenantId: string, + dto: CreateDiagnosticDto, + userId?: string + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: dto.orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${dto.orderId}`); + } + + const validStatuses = [ServiceOrderStatus.RECEIVED, ServiceOrderStatus.DIAGNOSED]; + if (!validStatuses.includes(order.status)) { + throw new Error( + `Cannot create diagnostic for order in status '${order.status}'. ` + + `Order must be in status: ${validStatuses.join(' or ')}` + ); + } + + const rawData: Record = {}; + + if (dto.scannerData) { + rawData.scanner = { + rawData: dto.scannerData.rawData, + deviceModel: dto.scannerData.deviceModel, + deviceSerial: dto.scannerData.deviceSerial, + softwareVersion: dto.scannerData.softwareVersion, + timestamp: dto.scannerData.timestamp || new Date(), + }; + + if (dto.type === DiagnosticType.SCANNER) { + const parsed = this.parseRawScannerData(dto.scannerData.rawData); + rawData.parsedCodes = parsed; + } + } + + if (dto.findings) { + rawData.findings = dto.findings; + } + + if (dto.recommendations) { + rawData.recommendations = dto.recommendations; + } + + if (dto.estimatedCost !== undefined) { + rawData.estimatedCost = dto.estimatedCost; + } + + const diagnostic = this.diagnosticRepository.create({ + tenantId, + orderId: dto.orderId, + vehicleId: order.vehicleId, + diagnosticType: dto.type, + equipment: dto.equipment, + performedBy: userId || dto.performedBy, + performedAt: new Date(), + rawData, + summary: dto.findings, + }); + + const savedDiagnostic = await this.diagnosticRepository.save(diagnostic); + + if (order.status === ServiceOrderStatus.RECEIVED) { + order.status = ServiceOrderStatus.DIAGNOSED; + await this.orderRepository.save(order); + } + + return savedDiagnostic; } /** - * Create a new diagnostic + * Add scanner results to an existing diagnostic + * + * Parses OBD-II codes from scanner output and generates recommendations + * based on detected error codes. + * + * @param tenantId - Tenant identifier + * @param diagnosticId - Diagnostic to update + * @param scannerData - Scanner data to process + * @returns Updated diagnostic with parsed codes + * @throws Error if diagnostic not found */ - async create(tenantId: string, dto: CreateDiagnosticDto): Promise { - const diagnostic = this.diagnosticRepository.create({ - tenantId, - vehicleId: dto.vehicleId, - orderId: dto.orderId, - diagnosticType: dto.diagnosticType, - equipment: dto.equipment, - performedBy: dto.performedBy, - summary: dto.summary, - rawData: dto.rawData, - performedAt: new Date(), - }); + async addScannerResults( + tenantId: string, + diagnosticId: string, + scannerData: ScannerDataDto + ): Promise { + const diagnostic = await this.findById(tenantId, diagnosticId); + if (!diagnostic) { + throw new Error(`Diagnostic not found: ${diagnosticId}`); + } + + const parsedCodes = this.parseRawScannerData(scannerData.rawData); + const recommendations = this.generateRecommendationsFromCodes(parsedCodes); + + const existingData = diagnostic.rawData || {}; + diagnostic.rawData = { + ...existingData, + scanner: { + rawData: scannerData.rawData, + deviceModel: scannerData.deviceModel, + deviceSerial: scannerData.deviceSerial, + softwareVersion: scannerData.softwareVersion, + timestamp: scannerData.timestamp || new Date(), + }, + parsedCodes, + autoRecommendations: recommendations, + }; + + if (parsedCodes.length > 0) { + const summaryParts: string[] = []; + const criticalCodes = parsedCodes.filter(c => c.severity === 'critical'); + const warningCodes = parsedCodes.filter(c => c.severity === 'warning'); + + if (criticalCodes.length > 0) { + summaryParts.push(`${criticalCodes.length} critical issue(s) found`); + } + if (warningCodes.length > 0) { + summaryParts.push(`${warningCodes.length} warning(s)`); + } + summaryParts.push(`Total: ${parsedCodes.length} DTC code(s)`); + + diagnostic.summary = summaryParts.join('. '); + } return this.diagnosticRepository.save(diagnostic); } + /** + * Get all diagnostics for a service order + * + * @param tenantId - Tenant identifier + * @param orderId - Service order ID + * @returns Array of diagnostics ordered by date descending + */ + async getDiagnosticsByOrder(tenantId: string, orderId: string): Promise { + return this.diagnosticRepository.find({ + where: { tenantId, orderId }, + order: { performedAt: 'DESC' }, + }); + } + + /** + * Get diagnostic history for a vehicle + * + * Returns all diagnostics ever performed on a vehicle across all orders, + * useful for tracking recurring issues and maintenance patterns. + * + * @param tenantId - Tenant identifier + * @param vehicleId - Vehicle ID + * @param limit - Maximum number of records (default: 50) + * @returns Array of diagnostics ordered by date descending + */ + async getDiagnosticsByVehicle( + tenantId: string, + vehicleId: string, + limit: number = 50 + ): Promise { + return this.diagnosticRepository.find({ + where: { tenantId, vehicleId }, + order: { performedAt: 'DESC' }, + take: limit, + }); + } + + /** + * Update an existing diagnostic + * + * @param tenantId - Tenant identifier + * @param diagnosticId - Diagnostic to update + * @param dto - Fields to update + * @returns Updated diagnostic or null if not found + */ + async updateDiagnostic( + tenantId: string, + diagnosticId: string, + dto: UpdateDiagnosticDto + ): Promise { + const diagnostic = await this.findById(tenantId, diagnosticId); + if (!diagnostic) { + return null; + } + + const existingData = diagnostic.rawData || {}; + + if (dto.findings !== undefined) { + existingData.findings = dto.findings; + diagnostic.summary = dto.findings; + } + + if (dto.recommendations !== undefined) { + existingData.recommendations = dto.recommendations; + } + + if (dto.estimatedCost !== undefined) { + existingData.estimatedCost = dto.estimatedCost; + } + + if (dto.scannerData) { + const parsedCodes = this.parseRawScannerData(dto.scannerData.rawData); + existingData.scanner = { + rawData: dto.scannerData.rawData, + deviceModel: dto.scannerData.deviceModel, + deviceSerial: dto.scannerData.deviceSerial, + softwareVersion: dto.scannerData.softwareVersion, + timestamp: dto.scannerData.timestamp || new Date(), + }; + existingData.parsedCodes = parsedCodes; + } + + if (dto.result !== undefined) { + diagnostic.result = dto.result; + } + + if (dto.equipment !== undefined) { + diagnostic.equipment = dto.equipment; + } + + diagnostic.rawData = existingData; + + return this.diagnosticRepository.save(diagnostic); + } + + /** + * Complete a diagnostic and optionally trigger quote generation + * + * When the diagnostic result is FAIL or NEEDS_ATTENTION, this method + * can automatically create a draft quote based on the diagnostic findings. + * + * @param tenantId - Tenant identifier + * @param diagnosticId - Diagnostic to complete + * @param result - Final diagnostic result + * @param finalSummary - Optional final summary + * @param triggerQuote - Whether to create a draft quote (default: true for FAIL/NEEDS_ATTENTION) + * @returns Object with diagnostic and optional quote + * @throws Error if diagnostic not found + */ + async completeDiagnostic( + tenantId: string, + diagnosticId: string, + result: DiagnosticResult, + finalSummary?: string, + triggerQuote: boolean = true + ): Promise<{ diagnostic: Diagnostic; quote: Quote | null }> { + const diagnostic = await this.findById(tenantId, diagnosticId); + if (!diagnostic) { + throw new Error(`Diagnostic not found: ${diagnosticId}`); + } + + if (diagnostic.result) { + throw new Error('Diagnostic has already been completed'); + } + + diagnostic.result = result; + if (finalSummary) { + diagnostic.summary = finalSummary; + if (diagnostic.rawData) { + diagnostic.rawData.finalSummary = finalSummary; + } + } + + const savedDiagnostic = await this.diagnosticRepository.save(diagnostic); + + let quote: Quote | null = null; + const needsQuote = triggerQuote && + (result === DiagnosticResult.FAIL || result === DiagnosticResult.NEEDS_ATTENTION); + + if (needsQuote && diagnostic.orderId) { + const order = await this.orderRepository.findOne({ + where: { id: diagnostic.orderId, tenantId }, + }); + + if (order) { + quote = await this.createDraftQuoteFromDiagnostic(tenantId, diagnostic, order); + } + } + + return { diagnostic: savedDiagnostic, quote }; + } + + /** + * Generate printable report data for a diagnostic + * + * Compiles all diagnostic information into a structured format + * suitable for generating PDF reports or displaying in UI. + * + * @param tenantId - Tenant identifier + * @param diagnosticId - Diagnostic to generate report for + * @returns Structured report data + * @throws Error if diagnostic not found + */ + async generateDiagnosticReport( + tenantId: string, + diagnosticId: string + ): Promise { + const diagnostic = await this.diagnosticRepository.findOne({ + where: { id: diagnosticId, tenantId }, + relations: ['order'], + }); + + if (!diagnostic) { + throw new Error(`Diagnostic not found: ${diagnosticId}`); + } + + const rawData = diagnostic.rawData || {}; + const parsedCodes: ParsedDTCCode[] = (rawData.parsedCodes as ParsedDTCCode[]) || []; + + const recommendations: DiagnosticRecommendationDto[] = []; + + if (rawData.autoRecommendations && Array.isArray(rawData.autoRecommendations)) { + recommendations.push(...(rawData.autoRecommendations as DiagnosticRecommendationDto[])); + } + + if (rawData.recommendations && typeof rawData.recommendations === 'string') { + recommendations.push({ + description: rawData.recommendations as string, + priority: 'medium', + urgency: 'scheduled', + }); + } + + const testResults: DiagnosticItemDto[] = []; + + if (rawData.testResults && Array.isArray(rawData.testResults)) { + testResults.push(...(rawData.testResults as DiagnosticItemDto[])); + } + + for (const code of parsedCodes) { + testResults.push({ + itemType: 'dtc_code', + code: code.code, + description: code.description, + severity: code.severity, + component: code.system, + notes: code.suggestedAction, + }); + } + + let order: ServiceOrder | null = null; + if (diagnostic.orderId) { + order = await this.orderRepository.findOne({ + where: { id: diagnostic.orderId, tenantId }, + }); + } + + const reportData: DiagnosticReportData = { + diagnostic, + order, + vehicle: { + id: diagnostic.vehicleId, + }, + customer: order ? { + id: order.customerId, + } : null, + parsedCodes, + recommendations, + testResults, + generatedAt: new Date(), + technicianName: undefined, + }; + + return reportData; + } + + // ============================================ + // BASIC QUERY METHODS + // ============================================ + /** * Find diagnostic by ID */ @@ -84,27 +563,21 @@ export class DiagnosticService { } /** - * Find diagnostics by vehicle + * Find diagnostics by vehicle (alias for getDiagnosticsByVehicle) */ async findByVehicle(tenantId: string, vehicleId: string): Promise { - return this.diagnosticRepository.find({ - where: { tenantId, vehicleId }, - order: { performedAt: 'DESC' }, - }); + return this.getDiagnosticsByVehicle(tenantId, vehicleId); } /** - * Find diagnostics by order + * Find diagnostics by order (alias for getDiagnosticsByOrder) */ async findByOrder(tenantId: string, orderId: string): Promise { - return this.diagnosticRepository.find({ - where: { tenantId, orderId }, - order: { performedAt: 'DESC' }, - }); + return this.getDiagnosticsByOrder(tenantId, orderId); } /** - * Update diagnostic result + * Update diagnostic result (legacy compatibility) */ async updateResult( tenantId: string, @@ -121,6 +594,10 @@ export class DiagnosticService { return this.diagnosticRepository.save(diagnostic); } + // ============================================ + // STATISTICS AND ANALYTICS + // ============================================ + /** * Get diagnostic statistics for a vehicle */ @@ -129,6 +606,7 @@ export class DiagnosticService { lastDiagnosticDate: Date | null; diagnosticsByType: Record; issuesFound: number; + mostCommonCodes: Array<{ code: string; count: number }>; }> { const diagnostics = await this.findByVehicle(tenantId, vehicleId); @@ -142,113 +620,360 @@ export class DiagnosticService { }; let issuesFound = 0; + const codeCounts: Record = {}; for (const diag of diagnostics) { diagnosticsByType[diag.diagnosticType]++; + if (diag.result === DiagnosticResult.FAIL || diag.result === DiagnosticResult.NEEDS_ATTENTION) { issuesFound++; } + + const parsedCodes = (diag.rawData?.parsedCodes as ParsedDTCCode[]) || []; + for (const codeData of parsedCodes) { + codeCounts[codeData.code] = (codeCounts[codeData.code] || 0) + 1; + } } + const mostCommonCodes = Object.entries(codeCounts) + .map(([code, count]) => ({ code, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + return { totalDiagnostics: diagnostics.length, lastDiagnosticDate: diagnostics.length > 0 ? diagnostics[0].performedAt : null, diagnosticsByType, issuesFound, + mostCommonCodes, }; } + // ============================================ + // SCANNER DATA PARSING + // ============================================ + /** - * Parse DTC codes from scanner data + * Parse raw scanner data into structured DTC codes + * + * Handles multiple input formats: + * - String with comma/space separated codes + * - Array of code strings + * - Array of code objects with description/severity + * - Object with dtc_codes, codes, or faults array */ - parseDTCCodes(rawData: Record): DiagnosticItemDto[] { - const items: DiagnosticItemDto[] = []; + private parseRawScannerData(rawData: string | Record): ParsedDTCCode[] { + const codes: ParsedDTCCode[] = []; - // Handle common scanner data formats - const dtcCodes = rawData.dtc_codes || rawData.codes || rawData.faults || []; + if (typeof rawData === 'string') { + const codePattern = /[PCBU]\d{4}/gi; + const matches = rawData.match(codePattern) || []; - if (Array.isArray(dtcCodes)) { - for (const code of dtcCodes) { - if (typeof code === 'string') { - items.push({ - itemType: 'dtc_code', - code, - description: this.getDTCDescription(code), - severity: this.getDTCSeverity(code), - }); - } else if (typeof code === 'object' && code !== null) { - items.push({ - itemType: 'dtc_code', - code: code.code || code.id, - description: code.description || code.message || this.getDTCDescription(code.code), - severity: code.severity || this.getDTCSeverity(code.code), - }); + for (const code of matches) { + const upperCode = code.toUpperCase(); + codes.push({ + code: upperCode, + description: this.getDTCDescription(upperCode), + severity: this.getDTCSeverity(upperCode), + system: this.getDTCSystem(upperCode), + suggestedAction: this.getSuggestedAction(upperCode), + }); + } + } else if (typeof rawData === 'object' && rawData !== null) { + const dtcArray = rawData.dtc_codes || rawData.codes || rawData.faults || rawData.errors || []; + + if (Array.isArray(dtcArray)) { + for (const item of dtcArray) { + if (typeof item === 'string') { + const upperCode = item.toUpperCase(); + codes.push({ + code: upperCode, + description: this.getDTCDescription(upperCode), + severity: this.getDTCSeverity(upperCode), + system: this.getDTCSystem(upperCode), + suggestedAction: this.getSuggestedAction(upperCode), + }); + } else if (typeof item === 'object' && item !== null) { + const code = (item.code || item.id || '').toUpperCase(); + codes.push({ + code, + description: item.description || item.message || this.getDTCDescription(code), + severity: item.severity || this.getDTCSeverity(code), + system: item.system || this.getDTCSystem(code), + suggestedAction: item.suggestedAction || this.getSuggestedAction(code), + }); + } } } } - return items; + return codes; } /** - * Get DTC code description (simplified lookup) + * Parse DTC codes from scanner data (public method for controller) + */ + parseDTCCodes(rawData: Record): DiagnosticItemDto[] { + const parsedCodes = this.parseRawScannerData(rawData); + + return parsedCodes.map(code => ({ + itemType: 'dtc_code' as const, + code: code.code, + description: code.description, + severity: code.severity, + component: code.system, + notes: code.suggestedAction, + })); + } + + /** + * Get DTC code description (comprehensive diesel codes database) */ private getDTCDescription(code: string): string { - // Common diesel DTC codes const descriptions: Record = { + // Fuel System 'P0087': 'Fuel Rail/System Pressure - Too Low', 'P0088': 'Fuel Rail/System Pressure - Too High', + 'P0089': 'Fuel Pressure Regulator 1 Performance', + 'P0090': 'Fuel Pressure Regulator 1 Control Circuit', + 'P0091': 'Fuel Pressure Regulator 1 Control Circuit Low', + 'P0092': 'Fuel Pressure Regulator 1 Control Circuit High', 'P0093': 'Fuel System Leak Detected - Large Leak', + 'P0094': 'Fuel System Leak Detected - Small Leak', + + // Mass Air Flow 'P0100': 'Mass Air Flow Circuit Malfunction', 'P0101': 'Mass Air Flow Circuit Range/Performance', 'P0102': 'Mass Air Flow Circuit Low', 'P0103': 'Mass Air Flow Circuit High', + 'P0104': 'Mass Air Flow Circuit Intermittent', + + // Intake Air Temperature + 'P0110': 'Intake Air Temperature Sensor Circuit Malfunction', + 'P0111': 'Intake Air Temperature Sensor Circuit Range/Performance', + 'P0112': 'Intake Air Temperature Sensor Circuit Low', + 'P0113': 'Intake Air Temperature Sensor Circuit High', + + // Coolant Temperature + 'P0115': 'Engine Coolant Temperature Circuit Malfunction', + 'P0116': 'Engine Coolant Temperature Circuit Range/Performance', + 'P0117': 'Engine Coolant Temperature Circuit Low', + 'P0118': 'Engine Coolant Temperature Circuit High', + + // Crankshaft/Camshaft + 'P0335': 'Crankshaft Position Sensor A Circuit Malfunction', + 'P0336': 'Crankshaft Position Sensor A Circuit Range/Performance', + 'P0340': 'Camshaft Position Sensor A Circuit Malfunction', + 'P0341': 'Camshaft Position Sensor A Circuit Range/Performance', + + // Injector Circuits + 'P0200': 'Injector Circuit Malfunction', 'P0201': 'Injector Circuit/Open - Cylinder 1', 'P0202': 'Injector Circuit/Open - Cylinder 2', 'P0203': 'Injector Circuit/Open - Cylinder 3', 'P0204': 'Injector Circuit/Open - Cylinder 4', 'P0205': 'Injector Circuit/Open - Cylinder 5', 'P0206': 'Injector Circuit/Open - Cylinder 6', + 'P0207': 'Injector Circuit/Open - Cylinder 7', + 'P0208': 'Injector Circuit/Open - Cylinder 8', + 'P0261': 'Cylinder 1 Injector Circuit Low', + 'P0262': 'Cylinder 1 Injector Circuit High', + 'P0263': 'Cylinder 1 Contribution/Balance', + + // Turbocharger 'P0234': 'Turbocharger/Supercharger Overboost Condition', + 'P0235': 'Turbocharger Boost Sensor A Circuit', + 'P0236': 'Turbocharger Boost Sensor A Circuit Range/Performance', + 'P0237': 'Turbocharger Boost Sensor A Circuit Low', + 'P0238': 'Turbocharger Boost Sensor A Circuit High', 'P0299': 'Turbocharger/Supercharger Underboost', + + // EGR System + 'P0400': 'Exhaust Gas Recirculation Flow Malfunction', 'P0401': 'Exhaust Gas Recirculation Flow Insufficient', 'P0402': 'Exhaust Gas Recirculation Flow Excessive', + 'P0403': 'Exhaust Gas Recirculation Control Circuit', 'P0404': 'Exhaust Gas Recirculation Circuit Range/Performance', 'P0405': 'Exhaust Gas Recirculation Sensor A Circuit Low', + 'P0406': 'Exhaust Gas Recirculation Sensor A Circuit High', + + // Diesel Particulate Filter 'P2002': 'Diesel Particulate Filter Efficiency Below Threshold', 'P2003': 'Diesel Particulate Filter Efficiency Below Threshold Bank 2', + 'P2004': 'Intake Manifold Runner Control Stuck Open', + 'P244A': 'Diesel Particulate Filter Differential Pressure Too Low', + 'P244B': 'Diesel Particulate Filter Differential Pressure Too High', + 'P2458': 'Diesel Particulate Filter Regeneration Duration', 'P242F': 'Diesel Particulate Filter Restriction - Ash Accumulation', + + // Glow Plug System + 'P0380': 'Glow Plug/Heater Circuit A Malfunction', + 'P0381': 'Glow Plug/Heater Indicator Circuit', + 'P0382': 'Glow Plug/Heater Circuit B Malfunction', + 'P0670': 'Glow Plug Module Control Circuit', + 'P0671': 'Cylinder 1 Glow Plug Circuit', + 'P0672': 'Cylinder 2 Glow Plug Circuit', + 'P0673': 'Cylinder 3 Glow Plug Circuit', + 'P0674': 'Cylinder 4 Glow Plug Circuit', + 'P0675': 'Cylinder 5 Glow Plug Circuit', + 'P0676': 'Cylinder 6 Glow Plug Circuit', + + // NOx System + 'P2200': 'NOx Sensor Circuit Bank 1', + 'P2201': 'NOx Sensor Circuit Range/Performance Bank 1', + 'P2202': 'NOx Sensor Circuit Low Bank 1', + 'P2203': 'NOx Sensor Circuit High Bank 1', + + // SCR/DEF System + 'P2BAD': 'NOx Exceedance - SCR System', + 'P20BD': 'Reductant Heater Control Circuit', + 'P20E8': 'Reductant Pressure Too Low', + 'P20E9': 'Reductant Pressure Too High', + 'P207F': 'Reductant Quality Performance', }; return descriptions[code] || `Unknown code: ${code}`; } /** - * Determine DTC severity + * Determine DTC severity based on code type and category */ private getDTCSeverity(code: string): 'critical' | 'warning' | 'info' { - // P0xxx codes starting with certain numbers are more critical - if (code.startsWith('P0087') || code.startsWith('P0088') || code.startsWith('P0093')) { - return 'critical'; // Fuel system issues + const criticalPrefixes = ['P008', 'P009', 'P02', 'P033', 'P034']; + const warningPrefixes = ['P023', 'P029', 'P04', 'P2', 'P067']; + + for (const prefix of criticalPrefixes) { + if (code.startsWith(prefix)) return 'critical'; } - if (code.startsWith('P02')) { - return 'critical'; // Injector issues - } - if (code.startsWith('P0234') || code.startsWith('P0299')) { - return 'warning'; // Turbo issues - } - if (code.startsWith('P04')) { - return 'warning'; // EGR issues - } - if (code.startsWith('P2')) { - return 'warning'; // DPF issues + + for (const prefix of warningPrefixes) { + if (code.startsWith(prefix)) return 'warning'; } + if (code.startsWith('P0')) return 'warning'; + if (code.startsWith('P1')) return 'info'; + if (code.startsWith('P2')) return 'warning'; + if (code.startsWith('P3')) return 'warning'; + if (code.startsWith('C')) return 'warning'; + if (code.startsWith('B')) return 'info'; + if (code.startsWith('U')) return 'info'; + return 'info'; } /** - * Analyze injector test results + * Determine the vehicle system affected by a DTC code + */ + private getDTCSystem(code: string): string { + const systemMap: Record = { + 'P00': 'Fuel and Air Metering', + 'P01': 'Fuel and Air Metering', + 'P02': 'Fuel and Air Metering (Injector Circuit)', + 'P03': 'Ignition System', + 'P04': 'Auxiliary Emission Controls', + 'P05': 'Vehicle Speed/Idle Control', + 'P06': 'Computer Output Circuit', + 'P07': 'Transmission', + 'P08': 'Transmission', + 'P2': 'Generic Powertrain', + 'P3': 'Generic Powertrain', + 'C': 'Chassis', + 'B': 'Body', + 'U': 'Network', + }; + + for (const [prefix, system] of Object.entries(systemMap)) { + if (code.startsWith(prefix)) return system; + } + + return 'Unknown System'; + } + + /** + * Get suggested action for a DTC code + */ + private getSuggestedAction(code: string): string { + const actions: Record = { + 'P0087': 'Check fuel filter, fuel pump, and fuel lines for restrictions. Verify fuel pressure.', + 'P0088': 'Check fuel pressure regulator. Inspect for kinked return lines.', + 'P0093': 'Inspect all fuel lines, connections, and injectors for leaks immediately.', + 'P0201': 'Check injector wiring and connector for cylinder 1. Test injector resistance.', + 'P0202': 'Check injector wiring and connector for cylinder 2. Test injector resistance.', + 'P0203': 'Check injector wiring and connector for cylinder 3. Test injector resistance.', + 'P0204': 'Check injector wiring and connector for cylinder 4. Test injector resistance.', + 'P0234': 'Inspect wastegate operation. Check boost pressure sensor and hoses.', + 'P0299': 'Check for boost leaks, turbo damage, or wastegate issues.', + 'P0401': 'Clean or replace EGR valve. Check for carbon buildup in passages.', + 'P2002': 'Perform forced regeneration. Check DPF condition.', + 'P242F': 'DPF cleaning or replacement may be required.', + 'P0670': 'Check glow plug control module and wiring.', + }; + + if (actions[code]) return actions[code]; + + if (code.startsWith('P02')) { + return 'Inspect injector circuit wiring and connectors. Test injector resistance and spray pattern.'; + } + if (code.startsWith('P04')) { + return 'Inspect emission control system components. Check for vacuum leaks.'; + } + if (code.startsWith('P067')) { + return 'Test glow plug resistance and circuit continuity.'; + } + + return 'Perform detailed diagnostic testing. Consult service manual for specific procedures.'; + } + + /** + * Generate recommendations based on parsed DTC codes + */ + private generateRecommendationsFromCodes(codes: ParsedDTCCode[]): DiagnosticRecommendationDto[] { + const recommendations: DiagnosticRecommendationDto[] = []; + const processedSystems = new Set(); + + for (const code of codes) { + if (processedSystems.has(code.system)) continue; + processedSystems.add(code.system); + + const systemCodes = codes.filter(c => c.system === code.system); + const hasCritical = systemCodes.some(c => c.severity === 'critical'); + const hasWarning = systemCodes.some(c => c.severity === 'warning'); + + let priority: 'critical' | 'high' | 'medium' | 'low' = 'low'; + let urgency: 'immediate' | 'soon' | 'scheduled' | 'preventive' = 'preventive'; + + if (hasCritical) { + priority = 'critical'; + urgency = 'immediate'; + } else if (hasWarning) { + priority = 'high'; + urgency = 'soon'; + } + + const codeList = systemCodes.map(c => c.code).join(', '); + + recommendations.push({ + description: `${code.system} system requires attention. Codes: ${codeList}`, + priority, + urgency, + notes: systemCodes.map(c => `${c.code}: ${c.suggestedAction}`).join('\n'), + }); + } + + recommendations.sort((a, b) => { + const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; + return priorityOrder[a.priority] - priorityOrder[b.priority]; + }); + + return recommendations; + } + + // ============================================ + // INJECTOR TEST ANALYSIS + // ============================================ + + /** + * Analyze injector test results from raw data */ analyzeInjectorTest(rawData: Record): DiagnosticItemDto[] { const items: DiagnosticItemDto[] = []; @@ -258,27 +983,73 @@ export class DiagnosticService { for (let i = 0; i < injectors.length; i++) { const injector = injectors[i]; if (typeof injector === 'object' && injector !== null) { - // Return quantity test if (injector.return_qty !== undefined) { + const returnQty = Number(injector.return_qty); items.push({ itemType: 'measurement', parameter: 'Return Quantity', - value: injector.return_qty, + value: returnQty, unit: 'ml/min', minRef: 0, - maxRef: 50, // Typical max for healthy injector - status: injector.return_qty > 50 ? 'fail' : 'ok', + maxRef: 50, + status: returnQty > 50 ? 'fail' : returnQty > 40 ? 'warning' : 'ok', cylinder: i + 1, + notes: returnQty > 50 + ? 'Excessive return quantity indicates injector wear or seal damage' + : returnQty > 40 + ? 'Return quantity approaching limit, monitor closely' + : 'Return quantity within acceptable range', }); } - // Spray pattern if (injector.spray_pattern !== undefined) { + const pattern = String(injector.spray_pattern).toLowerCase(); items.push({ itemType: 'observation', description: `Spray pattern: ${injector.spray_pattern}`, - status: injector.spray_pattern === 'good' ? 'ok' : 'warning', + status: pattern === 'good' || pattern === 'ok' ? 'ok' : + pattern === 'fair' ? 'warning' : 'fail', cylinder: i + 1, + notes: pattern === 'good' || pattern === 'ok' + ? 'Spray pattern is uniform and properly atomized' + : 'Spray pattern indicates potential nozzle issues', + }); + } + + if (injector.opening_pressure !== undefined) { + const pressure = Number(injector.opening_pressure); + const minPressure = Number(injector.min_pressure) || 200; + const maxPressure = Number(injector.max_pressure) || 250; + + items.push({ + itemType: 'measurement', + parameter: 'Opening Pressure', + value: pressure, + unit: 'bar', + minRef: minPressure, + maxRef: maxPressure, + status: pressure < minPressure ? 'fail' : + pressure > maxPressure ? 'fail' : 'ok', + cylinder: i + 1, + notes: pressure < minPressure + ? 'Opening pressure too low - injector may need rebuild' + : pressure > maxPressure + ? 'Opening pressure too high - check for blockage' + : 'Opening pressure within specification', + }); + } + + if (injector.leakage !== undefined) { + const hasLeakage = injector.leakage === true || injector.leakage === 'yes'; + items.push({ + itemType: 'test_result', + parameter: 'Leakage Test', + description: hasLeakage ? 'FAILED - Leakage detected' : 'PASSED - No leakage', + status: hasLeakage ? 'fail' : 'ok', + cylinder: i + 1, + notes: hasLeakage + ? 'Injector leakage can cause hard starting and reduced efficiency' + : 'Injector seal integrity confirmed', }); } } @@ -287,4 +1058,172 @@ export class DiagnosticService { return items; } + + // ============================================ + // COMPRESSION TEST ANALYSIS + // ============================================ + + /** + * Analyze compression test results + */ + analyzeCompressionTest(rawData: Record): DiagnosticItemDto[] { + const items: DiagnosticItemDto[] = []; + const readings = rawData.readings || rawData.cylinders || rawData.compression || []; + + if (Array.isArray(readings)) { + const values: number[] = []; + + for (let i = 0; i < readings.length; i++) { + const reading = readings[i]; + let compressionValue: number; + + if (typeof reading === 'number') { + compressionValue = reading; + } else if (typeof reading === 'object' && reading !== null) { + compressionValue = Number(reading.value || reading.psi || reading.bar || 0); + } else { + continue; + } + + values.push(compressionValue); + } + + if (values.length > 0) { + const avgCompression = values.reduce((a, b) => a + b, 0) / values.length; + const minCompression = Math.min(...values); + const maxCompression = Math.max(...values); + const variation = ((maxCompression - minCompression) / avgCompression) * 100; + + for (let i = 0; i < values.length; i++) { + const value = values[i]; + const deviationFromAvg = ((value - avgCompression) / avgCompression) * 100; + + let status: 'ok' | 'warning' | 'fail' = 'ok'; + if (value < avgCompression * 0.85 || Math.abs(deviationFromAvg) > 15) { + status = 'fail'; + } else if (value < avgCompression * 0.9 || Math.abs(deviationFromAvg) > 10) { + status = 'warning'; + } + + items.push({ + itemType: 'measurement', + parameter: 'Compression', + value, + unit: 'PSI', + minRef: avgCompression * 0.85, + maxRef: avgCompression * 1.15, + status, + cylinder: i + 1, + notes: status === 'fail' + ? `Low compression - ${deviationFromAvg.toFixed(1)}% below average. Check rings, valves, or head gasket.` + : status === 'warning' + ? `Compression slightly low - monitor for degradation` + : `Compression within acceptable range`, + }); + } + + items.push({ + itemType: 'observation', + description: `Compression Balance Analysis`, + status: variation > 15 ? 'fail' : variation > 10 ? 'warning' : 'ok', + notes: `Average: ${avgCompression.toFixed(0)} PSI, Variation: ${variation.toFixed(1)}%. ` + + (variation > 15 + ? 'Significant imbalance detected - engine may have mechanical issues.' + : variation > 10 + ? 'Slight imbalance - monitor for changes.' + : 'Cylinders are well balanced.'), + }); + } + } + + return items; + } + + // ============================================ + // HELPER METHODS + // ============================================ + + /** + * Create a draft quote from diagnostic findings + */ + private async createDraftQuoteFromDiagnostic( + tenantId: string, + diagnostic: Diagnostic, + order: ServiceOrder + ): Promise { + const year = new Date().getFullYear(); + const prefix = `COT-${year}-`; + + const lastQuote = await this.quoteRepository.findOne({ + where: { tenantId }, + order: { createdAt: 'DESC' }, + }); + + let sequence = 1; + if (lastQuote?.quoteNumber?.startsWith(prefix)) { + const lastSeq = parseInt(lastQuote.quoteNumber.replace(prefix, ''), 10); + sequence = isNaN(lastSeq) ? 1 : lastSeq + 1; + } + + const quoteNumber = `${prefix}${sequence.toString().padStart(5, '0')}`; + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 15); + + const rawData = diagnostic.rawData || {}; + const estimatedCost = (rawData.estimatedCost as number) || 0; + + const notesFromDiagnostic: string[] = [`Generated from Diagnostic #${diagnostic.id}`]; + if (diagnostic.summary) { + notesFromDiagnostic.push(`Diagnostic Summary: ${diagnostic.summary}`); + } + if (rawData.recommendations) { + notesFromDiagnostic.push(`Recommendations: ${rawData.recommendations}`); + } + + const quote = this.quoteRepository.create({ + tenantId, + quoteNumber, + customerId: order.customerId, + vehicleId: order.vehicleId, + diagnosticId: diagnostic.id, + status: QuoteStatus.DRAFT, + validityDays: 15, + expiresAt, + laborTotal: estimatedCost * 0.6, + partsTotal: estimatedCost * 0.4, + tax: estimatedCost * 0.16, + grandTotal: estimatedCost * 1.16, + notes: notesFromDiagnostic.join('\n'), + }); + + return this.quoteRepository.save(quote); + } + + /** + * Create basic diagnostic (legacy compatibility) + */ + async create(tenantId: string, dto: { + vehicleId: string; + orderId?: string; + diagnosticType: DiagnosticType; + equipment?: string; + performedBy?: string; + summary?: string; + rawData?: Record; + }): Promise { + const diagnostic = this.diagnosticRepository.create({ + tenantId, + vehicleId: dto.vehicleId, + orderId: dto.orderId, + diagnosticType: dto.diagnosticType, + equipment: dto.equipment, + performedBy: dto.performedBy, + summary: dto.summary, + rawData: dto.rawData, + performedAt: new Date(), + }); + + return this.diagnosticRepository.save(diagnostic); + } } diff --git a/src/modules/service-management/services/quote.service.ts b/src/modules/service-management/services/quote.service.ts index a590b35..690894d 100644 --- a/src/modules/service-management/services/quote.service.ts +++ b/src/modules/service-management/services/quote.service.ts @@ -1,32 +1,63 @@ /** * Quote Service - * Mecánicas Diesel - ERP Suite + * Mecanicas Diesel - ERP Suite * * Business logic for quotations management. + * Handles the complete quotation workflow from creation to conversion. */ import { Repository, DataSource } from 'typeorm'; import { Quote, QuoteStatus } from '../entities/quote.entity'; +import { QuoteItem, QuoteItemType } from '../entities/quote-item.entity'; import { ServiceOrder, ServiceOrderStatus } from '../entities/service-order.entity'; +import { OrderItem, OrderItemType, OrderItemStatus } from '../entities/order-item.entity'; +/** + * Tax rate for Mexico (16% IVA) + */ +const TAX_RATE = 0.16; + +/** + * Default validity period in days + */ +const DEFAULT_VALIDITY_DAYS = 15; + +// ==================== // DTOs +// ==================== + export interface CreateQuoteDto { - customerId: string; - vehicleId: string; - diagnosticId?: string; + orderId: string; validityDays?: number; - terms?: string; + discountPercent?: number; notes?: string; + terms?: string; } -export interface QuoteItemDto { - itemType: 'service' | 'part'; +export interface AddQuoteItemDto { + type: QuoteItemType; + serviceId?: string; + partId?: string; description: string; quantity: number; unitPrice: number; - discountPct?: number; - serviceId?: string; - partId?: string; + discountPercent?: number; +} + +export interface UpdateQuoteItemDto { + description?: string; + quantity?: number; + unitPrice?: number; + discountPercent?: number; + isApproved?: boolean; +} + +export interface CustomerResponseDto { + approved: boolean; + notes?: string; + approvedByName?: string; + approvalSignature?: string; + approvalIp?: string; } export interface ApplyDiscountDto { @@ -35,15 +66,65 @@ export interface ApplyDiscountDto { discountReason?: string; } +export interface QuoteFilters { + status?: QuoteStatus; + customerId?: string; + vehicleId?: string; + orderId?: 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 QuoteWithItems extends Quote { + items?: QuoteItem[]; +} + +export interface SendQuoteResult { + quote: Quote; + notificationData: { + quoteNumber: string; + customerId: string; + vehicleId: string; + grandTotal: number; + expiresAt: Date | null; + items: QuoteItem[]; + }; +} + +// ==================== +// Service Class +// ==================== + export class QuoteService { private quoteRepository: Repository; + private quoteItemRepository: Repository; private orderRepository: Repository; + private orderItemRepository: Repository; constructor(private dataSource: DataSource) { this.quoteRepository = dataSource.getRepository(Quote); + this.quoteItemRepository = dataSource.getRepository(QuoteItem); this.orderRepository = dataSource.getRepository(ServiceOrder); + this.orderItemRepository = dataSource.getRepository(OrderItem); } + // ==================== + // Private Helper Methods + // ==================== + /** * Generate next quote number for tenant */ @@ -66,11 +147,69 @@ export class QuoteService { } /** - * Create a new quote + * Generate next order number for tenant */ - async create(tenantId: string, dto: CreateQuoteDto, userId?: string): Promise { + 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')}`; + } + + /** + * Round to 2 decimal places for financial calculations + */ + private roundToTwoDecimals(value: number): number { + return Math.round(value * 100) / 100; + } + + /** + * Calculate item subtotal + */ + private calculateItemSubtotal(quantity: number, unitPrice: number, discountPct: number): number { + const gross = quantity * unitPrice; + const discount = gross * (discountPct / 100); + return this.roundToTwoDecimals(gross - discount); + } + + // ==================== + // Core CRUD Methods + // ==================== + + /** + * Create a new quote for an order + * Validates order is in 'diagnosed' status before creating quote + */ + async createQuote( + tenantId: string, + dto: CreateQuoteDto, + userId?: string + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: dto.orderId, tenantId }, + }); + + if (!order) { + throw new Error('Order not found'); + } + + if (order.status !== ServiceOrderStatus.DIAGNOSED) { + throw new Error(`Order must be in 'diagnosed' status to create a quote. Current status: ${order.status}`); + } + const quoteNumber = await this.generateQuoteNumber(tenantId); - const validityDays = dto.validityDays || 15; + const validityDays = dto.validityDays || DEFAULT_VALIDITY_DAYS; const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + validityDays); @@ -78,18 +217,30 @@ export class QuoteService { const quote = this.quoteRepository.create({ tenantId, quoteNumber, - customerId: dto.customerId, - vehicleId: dto.vehicleId, - diagnosticId: dto.diagnosticId, + customerId: order.customerId, + vehicleId: order.vehicleId, status: QuoteStatus.DRAFT, validityDays, expiresAt, + discountPercent: dto.discountPercent || 0, + discountAmount: 0, + laborTotal: 0, + partsTotal: 0, + tax: 0, + grandTotal: 0, terms: dto.terms, notes: dto.notes, createdBy: userId, }); - return this.quoteRepository.save(quote); + const savedQuote = await this.quoteRepository.save(quote); + + await this.orderRepository.update( + { id: dto.orderId }, + { status: ServiceOrderStatus.QUOTED, quoteId: savedQuote.id } + ); + + return savedQuote; } /** @@ -101,6 +252,24 @@ export class QuoteService { }); } + /** + * Find quote by ID with items + */ + async findByIdWithItems(tenantId: string, id: string): Promise { + const quote = await this.quoteRepository.findOne({ + where: { id, tenantId }, + }); + + if (!quote) return null; + + const items = await this.quoteItemRepository.find({ + where: { quoteId: id }, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + }); + + return { ...quote, items }; + } + /** * Find quote by number */ @@ -115,16 +284,11 @@ export class QuoteService { */ async findAll( tenantId: string, - filters: { - status?: QuoteStatus; - customerId?: string; - vehicleId?: string; - fromDate?: Date; - toDate?: Date; - } = {}, - pagination = { page: 1, limit: 20 } - ) { - const queryBuilder = this.quoteRepository.createQueryBuilder('quote') + filters: QuoteFilters = {}, + pagination: PaginationOptions = { page: 1, limit: 20 } + ): Promise> { + const queryBuilder = this.quoteRepository + .createQueryBuilder('quote') .where('quote.tenant_id = :tenantId', { tenantId }); if (filters.status) { @@ -160,26 +324,551 @@ export class QuoteService { }; } + // ==================== + // Quote Item Methods + // ==================== + + /** + * Add item to quote + */ + async addQuoteItem( + tenantId: string, + quoteId: string, + dto: AddQuoteItemDto + ): Promise { + const quote = await this.findById(tenantId, quoteId); + if (!quote) { + throw new Error('Quote not found'); + } + + if (quote.status !== QuoteStatus.DRAFT) { + throw new Error('Can only add items to draft quotes'); + } + + const discountPct = dto.discountPercent || 0; + const subtotal = this.calculateItemSubtotal(dto.quantity, dto.unitPrice, discountPct); + + const maxSortOrder = await this.quoteItemRepository + .createQueryBuilder('item') + .select('MAX(item.sort_order)', 'max') + .where('item.quote_id = :quoteId', { quoteId }) + .getRawOne(); + + const sortOrder = (maxSortOrder?.max || 0) + 1; + + const item = this.quoteItemRepository.create({ + quoteId, + itemType: dto.type, + serviceId: dto.serviceId, + partId: dto.partId, + description: dto.description, + quantity: dto.quantity, + unitPrice: dto.unitPrice, + discountPct, + subtotal, + isApproved: true, + sortOrder, + }); + + const savedItem = await this.quoteItemRepository.save(item); + + await this.recalculateQuoteTotals(tenantId, quoteId); + + return savedItem; + } + + /** + * Update quote item + */ + async updateQuoteItem( + tenantId: string, + quoteId: string, + itemId: string, + dto: UpdateQuoteItemDto + ): Promise { + const quote = await this.findById(tenantId, quoteId); + if (!quote) { + throw new Error('Quote not found'); + } + + if (quote.status !== QuoteStatus.DRAFT) { + throw new Error('Can only update items in draft quotes'); + } + + const item = await this.quoteItemRepository.findOne({ + where: { id: itemId, quoteId }, + }); + + if (!item) { + return null; + } + + if (dto.description !== undefined) { + item.description = dto.description; + } + if (dto.quantity !== undefined) { + item.quantity = dto.quantity; + } + if (dto.unitPrice !== undefined) { + item.unitPrice = dto.unitPrice; + } + if (dto.discountPercent !== undefined) { + item.discountPct = dto.discountPercent; + } + if (dto.isApproved !== undefined) { + item.isApproved = dto.isApproved; + } + + item.subtotal = this.calculateItemSubtotal( + item.quantity, + item.unitPrice, + item.discountPct + ); + + const savedItem = await this.quoteItemRepository.save(item); + + await this.recalculateQuoteTotals(tenantId, quoteId); + + return savedItem; + } + + /** + * Remove quote item + */ + async removeQuoteItem( + tenantId: string, + quoteId: string, + itemId: string + ): Promise { + const quote = await this.findById(tenantId, quoteId); + if (!quote) { + throw new Error('Quote not found'); + } + + if (quote.status !== QuoteStatus.DRAFT) { + throw new Error('Can only remove items from draft quotes'); + } + + const item = await this.quoteItemRepository.findOne({ + where: { id: itemId, quoteId }, + }); + + if (!item) { + return false; + } + + await this.quoteItemRepository.remove(item); + + await this.recalculateQuoteTotals(tenantId, quoteId); + + return true; + } + + /** + * Get all items for a quote + */ + async getQuoteItems(quoteId: string): Promise { + return this.quoteItemRepository.find({ + where: { quoteId }, + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + }); + } + + // ==================== + // Total Calculation Methods + // ==================== + + /** + * Recalculate all quote totals + */ + async recalculateQuoteTotals(tenantId: string, quoteId: string): Promise { + const quote = await this.findById(tenantId, quoteId); + if (!quote) { + return null; + } + + const items = await this.getQuoteItems(quoteId); + + let laborTotal = 0; + let partsTotal = 0; + + for (const item of items) { + if (!item.isApproved) continue; + + const itemSubtotal = Number(item.subtotal); + if (item.itemType === QuoteItemType.SERVICE) { + laborTotal += itemSubtotal; + } else { + partsTotal += itemSubtotal; + } + } + + laborTotal = this.roundToTwoDecimals(laborTotal); + partsTotal = this.roundToTwoDecimals(partsTotal); + + const subtotal = laborTotal + partsTotal; + + let discountAmount = 0; + if (Number(quote.discountPercent) > 0) { + discountAmount = this.roundToTwoDecimals(subtotal * (Number(quote.discountPercent) / 100)); + } + + const taxableAmount = subtotal - discountAmount; + const tax = this.roundToTwoDecimals(taxableAmount * TAX_RATE); + const grandTotal = this.roundToTwoDecimals(taxableAmount + tax); + + quote.laborTotal = laborTotal; + quote.partsTotal = partsTotal; + quote.discountAmount = discountAmount; + quote.tax = tax; + quote.grandTotal = grandTotal; + + return this.quoteRepository.save(quote); + } + + // ==================== + // Quote Workflow Methods + // ==================== + /** * Send quote to customer + * Returns data needed for WhatsApp/email notification */ - async send(tenantId: string, id: string, channel: 'email' | 'whatsapp'): Promise { - const quote = await this.findById(tenantId, id); - if (!quote) return null; + async sendQuoteToCustomer( + tenantId: string, + quoteId: string + ): Promise { + const quote = await this.findById(tenantId, quoteId); + if (!quote) { + return null; + } if (quote.status !== QuoteStatus.DRAFT) { throw new Error('Quote has already been sent'); } + const items = await this.getQuoteItems(quoteId); + + if (items.length === 0) { + throw new Error('Cannot send quote without items'); + } + quote.status = QuoteStatus.SENT; quote.sentAt = new Date(); - // TODO: Integrate with notification service - // await notificationService.sendQuote(quote, channel); + const savedQuote = await this.quoteRepository.save(quote); + + return { + quote: savedQuote, + notificationData: { + quoteNumber: quote.quoteNumber, + customerId: quote.customerId, + vehicleId: quote.vehicleId, + grandTotal: Number(quote.grandTotal), + expiresAt: quote.expiresAt || null, + items, + }, + }; + } + + /** + * Record customer response to quote + */ + async customerResponse( + tenantId: string, + quoteId: string, + dto: CustomerResponseDto + ): Promise { + const quote = await this.findById(tenantId, quoteId); + if (!quote) { + return null; + } + + if (quote.status === QuoteStatus.EXPIRED) { + throw new Error('Quote has expired'); + } + + if (quote.status === QuoteStatus.APPROVED || quote.status === QuoteStatus.CONVERTED) { + throw new Error('Quote has already been approved'); + } + + if (quote.status === QuoteStatus.REJECTED) { + throw new Error('Quote was already rejected'); + } + + quote.respondedAt = new Date(); + + if (dto.approved) { + quote.status = QuoteStatus.APPROVED; + quote.approvedByName = dto.approvedByName; + quote.approvalSignature = dto.approvalSignature; + quote.approvalIp = dto.approvalIp; + + const order = await this.orderRepository.findOne({ + where: { tenantId, quoteId: quote.id }, + }); + + if (order) { + await this.orderRepository.update( + { id: order.id }, + { status: ServiceOrderStatus.APPROVED } + ); + } + } else { + quote.status = QuoteStatus.REJECTED; + } + + if (dto.notes) { + quote.notes = quote.notes + ? `${quote.notes}\n\nCustomer response: ${dto.notes}` + : `Customer response: ${dto.notes}`; + } return this.quoteRepository.save(quote); } + /** + * Convert approved quote to work order + * Copies quote items to order items and updates order totals + */ + async convertToOrder( + tenantId: string, + quoteId: string, + userId?: string + ): Promise { + const quote = await this.findById(tenantId, quoteId); + if (!quote) { + return null; + } + + if (quote.status !== QuoteStatus.APPROVED) { + throw new Error('Quote must be approved before conversion'); + } + + let order = await this.orderRepository.findOne({ + where: { tenantId, quoteId: quote.id }, + }); + + if (!order) { + const orderNumber = await this.generateOrderNumber(tenantId); + + order = this.orderRepository.create({ + tenantId, + orderNumber, + customerId: quote.customerId, + vehicleId: quote.vehicleId, + quoteId: quote.id, + status: ServiceOrderStatus.APPROVED, + laborTotal: quote.laborTotal, + partsTotal: quote.partsTotal, + discountAmount: quote.discountAmount, + discountPercent: quote.discountPercent, + tax: quote.tax, + grandTotal: quote.grandTotal, + customerNotes: quote.notes, + createdBy: userId, + receivedAt: new Date(), + }); + + order = await this.orderRepository.save(order); + } else { + order.status = ServiceOrderStatus.APPROVED; + order.laborTotal = quote.laborTotal; + order.partsTotal = quote.partsTotal; + order.discountAmount = quote.discountAmount; + order.discountPercent = quote.discountPercent; + order.tax = quote.tax; + order.grandTotal = quote.grandTotal; + + order = await this.orderRepository.save(order); + } + + const quoteItems = await this.getQuoteItems(quoteId); + + for (const quoteItem of quoteItems) { + if (!quoteItem.isApproved) continue; + + const orderItem = this.orderItemRepository.create({ + orderId: order.id, + itemType: quoteItem.itemType === QuoteItemType.SERVICE + ? OrderItemType.SERVICE + : OrderItemType.PART, + serviceId: quoteItem.serviceId, + partId: quoteItem.partId, + description: quoteItem.description, + quantity: quoteItem.quantity, + unitPrice: quoteItem.unitPrice, + discountPct: quoteItem.discountPct, + subtotal: quoteItem.subtotal, + status: OrderItemStatus.PENDING, + sortOrder: quoteItem.sortOrder, + }); + + await this.orderItemRepository.save(orderItem); + } + + quote.status = QuoteStatus.CONVERTED; + quote.convertedOrderId = order.id; + await this.quoteRepository.save(quote); + + return order; + } + + // ==================== + // Quote History & Duplication + // ==================== + + /** + * Get all quotes for an order + */ + async getQuotesByOrder(tenantId: string, orderId: string): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + return []; + } + + return this.quoteRepository.find({ + where: { + tenantId, + customerId: order.customerId, + vehicleId: order.vehicleId, + }, + order: { createdAt: 'DESC' }, + }); + } + + /** + * Duplicate a quote (create new version) + * Creates a new draft quote with the same items + */ + async duplicateQuote( + tenantId: string, + quoteId: string, + userId?: string + ): Promise { + const originalQuote = await this.findById(tenantId, quoteId); + if (!originalQuote) { + throw new Error('Quote not found'); + } + + const originalItems = await this.getQuoteItems(quoteId); + + const quoteNumber = await this.generateQuoteNumber(tenantId); + const validityDays = originalQuote.validityDays || DEFAULT_VALIDITY_DAYS; + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + validityDays); + + const newQuote = this.quoteRepository.create({ + tenantId, + quoteNumber, + customerId: originalQuote.customerId, + vehicleId: originalQuote.vehicleId, + diagnosticId: originalQuote.diagnosticId, + status: QuoteStatus.DRAFT, + validityDays, + expiresAt, + discountPercent: originalQuote.discountPercent, + discountAmount: 0, + discountReason: originalQuote.discountReason, + laborTotal: 0, + partsTotal: 0, + tax: 0, + grandTotal: 0, + terms: originalQuote.terms, + notes: `Duplicated from ${originalQuote.quoteNumber}`, + createdBy: userId, + }); + + const savedQuote = await this.quoteRepository.save(newQuote); + + for (const item of originalItems) { + const newItem = this.quoteItemRepository.create({ + quoteId: savedQuote.id, + itemType: item.itemType, + serviceId: item.serviceId, + partId: item.partId, + description: item.description, + quantity: item.quantity, + unitPrice: item.unitPrice, + discountPct: item.discountPct, + subtotal: item.subtotal, + isApproved: true, + sortOrder: item.sortOrder, + }); + + await this.quoteItemRepository.save(newItem); + } + + await this.recalculateQuoteTotals(tenantId, savedQuote.id); + + const finalQuote = await this.findById(tenantId, savedQuote.id); + if (!finalQuote) { + throw new Error('Failed to retrieve duplicated quote'); + } + + return finalQuote; + } + + // ==================== + // Legacy Methods (maintained for backwards compatibility) + // ==================== + + /** + * Create a new quote (legacy method) + * @deprecated Use createQuote instead + */ + async create( + tenantId: string, + dto: { + customerId: string; + vehicleId: string; + diagnosticId?: string; + validityDays?: number; + terms?: string; + notes?: string; + }, + userId?: string + ): Promise { + const quoteNumber = await this.generateQuoteNumber(tenantId); + const validityDays = dto.validityDays || DEFAULT_VALIDITY_DAYS; + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + validityDays); + + const quote = this.quoteRepository.create({ + tenantId, + quoteNumber, + customerId: dto.customerId, + vehicleId: dto.vehicleId, + diagnosticId: dto.diagnosticId, + status: QuoteStatus.DRAFT, + validityDays, + expiresAt, + terms: dto.terms, + notes: dto.notes, + createdBy: userId, + }); + + return this.quoteRepository.save(quote); + } + + /** + * Send quote to customer (legacy method) + * @deprecated Use sendQuoteToCustomer instead + */ + async send( + tenantId: string, + id: string, + channel: 'email' | 'whatsapp' + ): Promise { + const result = await this.sendQuoteToCustomer(tenantId, id); + return result?.quote || null; + } + /** * Mark quote as viewed */ @@ -199,7 +888,8 @@ export class QuoteService { } /** - * Approve quote (customer action) + * Approve quote (legacy method) + * @deprecated Use customerResponse instead */ async approve( tenantId: string, @@ -210,128 +900,56 @@ export class QuoteService { approvalIp?: string; } ): Promise { - const quote = await this.findById(tenantId, id); - if (!quote) return null; - - if (quote.status === QuoteStatus.EXPIRED) { - throw new Error('Quote has expired'); - } - if (quote.status === QuoteStatus.REJECTED) { - throw new Error('Quote was rejected'); - } - if (quote.status === QuoteStatus.APPROVED || quote.status === QuoteStatus.CONVERTED) { - throw new Error('Quote has already been approved'); - } - - quote.status = QuoteStatus.APPROVED; - quote.respondedAt = new Date(); - quote.approvedByName = approvalData.approvedByName; - quote.approvalSignature = approvalData.approvalSignature; - quote.approvalIp = approvalData.approvalIp; - - return this.quoteRepository.save(quote); + return this.customerResponse(tenantId, id, { + approved: true, + approvedByName: approvalData.approvedByName, + approvalSignature: approvalData.approvalSignature, + approvalIp: approvalData.approvalIp, + }); } /** - * Reject quote + * Reject quote (legacy method) + * @deprecated Use customerResponse instead */ async reject(tenantId: string, id: string, reason?: string): Promise { - const quote = await this.findById(tenantId, id); - if (!quote) return null; - - quote.status = QuoteStatus.REJECTED; - quote.respondedAt = new Date(); - if (reason) { - quote.notes = `${quote.notes || ''}\n\nRejection reason: ${reason}`.trim(); - } - - return this.quoteRepository.save(quote); - } - - /** - * Convert quote to service order - */ - async convertToOrder(tenantId: string, id: string, userId?: string): Promise { - const quote = await this.findById(tenantId, id); - if (!quote) return null; - - if (quote.status !== QuoteStatus.APPROVED) { - throw new Error('Quote must be approved before conversion'); - } - - // Generate order number - const year = new Date().getFullYear(); - const prefix = `OS-${year}-`; - const lastOrder = await this.orderRepository.findOne({ - where: { tenantId }, - order: { createdAt: 'DESC' }, + return this.customerResponse(tenantId, id, { + approved: false, + notes: reason, }); - - let sequence = 1; - if (lastOrder?.orderNumber?.startsWith(prefix)) { - const lastSeq = parseInt(lastOrder.orderNumber.replace(prefix, ''), 10); - sequence = isNaN(lastSeq) ? 1 : lastSeq + 1; - } - - const orderNumber = `${prefix}${sequence.toString().padStart(5, '0')}`; - - // Create service order - const order = this.orderRepository.create({ - tenantId, - orderNumber, - customerId: quote.customerId, - vehicleId: quote.vehicleId, - quoteId: quote.id, - status: ServiceOrderStatus.APPROVED, - laborTotal: quote.laborTotal, - partsTotal: quote.partsTotal, - discountAmount: quote.discountAmount, - discountPercent: quote.discountPercent, - tax: quote.tax, - grandTotal: quote.grandTotal, - customerNotes: quote.notes, - createdBy: userId, - receivedAt: new Date(), - }); - - const savedOrder = await this.orderRepository.save(order); - - // Update quote - quote.status = QuoteStatus.CONVERTED; - quote.convertedOrderId = savedOrder.id; - await this.quoteRepository.save(quote); - - return savedOrder; } /** * Apply discount to quote */ - async applyDiscount(tenantId: string, id: string, dto: ApplyDiscountDto): Promise { + async applyDiscount( + tenantId: string, + id: string, + dto: ApplyDiscountDto + ): Promise { const quote = await this.findById(tenantId, id); if (!quote) return null; + if (quote.status !== QuoteStatus.DRAFT) { + throw new Error('Can only apply discount to draft quotes'); + } + if (dto.discountPercent !== undefined) { quote.discountPercent = dto.discountPercent; - const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal); - quote.discountAmount = subtotal * (dto.discountPercent / 100); } else if (dto.discountAmount !== undefined) { - quote.discountAmount = dto.discountAmount; const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal); - quote.discountPercent = subtotal > 0 ? (dto.discountAmount / subtotal) * 100 : 0; + quote.discountPercent = subtotal > 0 + ? this.roundToTwoDecimals((dto.discountAmount / subtotal) * 100) + : 0; } if (dto.discountReason) { quote.discountReason = dto.discountReason; } - // Recalculate totals - const subtotal = Number(quote.laborTotal) + Number(quote.partsTotal); - const taxableAmount = subtotal - Number(quote.discountAmount); - quote.tax = taxableAmount * 0.16; // 16% IVA - quote.grandTotal = taxableAmount + quote.tax; + await this.quoteRepository.save(quote); - return this.quoteRepository.save(quote); + return this.recalculateQuoteTotals(tenantId, id); } /** @@ -386,7 +1004,9 @@ export class QuoteService { ]); const totalResponded = approved + rejected + converted; - const conversionRate = totalResponded > 0 ? ((approved + converted) / totalResponded) * 100 : 0; + const conversionRate = totalResponded > 0 + ? this.roundToTwoDecimals(((approved + converted) / totalResponded) * 100) + : 0; return { total, diff --git a/src/modules/service-management/services/vehicle-reception.service.ts b/src/modules/service-management/services/vehicle-reception.service.ts new file mode 100644 index 0000000..081dfaa --- /dev/null +++ b/src/modules/service-management/services/vehicle-reception.service.ts @@ -0,0 +1,736 @@ +/** + * Vehicle Reception Service + * Mecanicas Diesel - ERP Suite + * + * Handles the complete vehicle reception workflow including: + * - Receiving vehicles for service + * - Quick reception for walk-in customers + * - Pre-filling reception forms + * - Work bay management + */ + +import { DataSource, Repository } from 'typeorm'; +import { + ServiceOrder, + ServiceOrderStatus, + ServiceOrderPriority, +} from '../entities/service-order.entity'; +import { WorkBay, BayStatus } from '../entities/work-bay.entity'; +import { Vehicle, VehicleType, VehicleStatus } from '../../vehicle-management/entities/vehicle.entity'; +import { Customer, CustomerType } from '../../customers/entities/customer.entity'; + +/** + * DTO for creating a vehicle reception + */ +export interface CreateVehicleReceptionDto { + customerId?: string; + vehicleId?: string; + customerSymptoms: string; + odometerIn: number; + priority?: ServiceOrderPriority; + bayId?: string; + internalNotes?: string; + promisedAt?: Date; + newCustomer?: NewCustomerInfo; + newVehicle?: NewVehicleInfo; +} + +/** + * New customer info for quick reception + */ +export interface NewCustomerInfo { + name: string; + phone: string; + email?: string; + customerType?: CustomerType; +} + +/** + * New vehicle info for quick reception + */ +export interface NewVehicleInfo { + licensePlate: string; + make: string; + model: string; + year: number; + vehicleType?: VehicleType; + vin?: string; + color?: string; +} + +/** + * DTO for quick reception (walk-in customers) + */ +export interface QuickReceptionDto { + customerName: string; + customerPhone: string; + customerEmail?: string; + vehiclePlate: string; + vehicleMake: string; + vehicleModel: string; + vehicleYear: number; + vehicleType?: VehicleType; + symptoms: string; + odometer: number; + priority?: ServiceOrderPriority; + bayId?: string; +} + +/** + * Reception form data returned by getReceptionForm + */ +export interface ReceptionFormData { + vehicle: Vehicle | null; + customer: Customer | null; + availableBays: WorkBay[]; + serviceHistory: ServiceOrderSummary[]; + lastOdometer: number | null; +} + +/** + * Summary of a service order for history display + */ +export interface ServiceOrderSummary { + id: string; + orderNumber: string; + status: ServiceOrderStatus; + receivedAt: Date; + completedAt: Date | null; + grandTotal: number; + customerSymptoms: string | null; +} + +/** + * Result of vehicle reception + */ +export interface VehicleReceptionResult { + order: ServiceOrder; + vehicle: Vehicle; + customer: Customer; + bay: WorkBay | null; + orderNumber: string; +} + +/** + * Plate validation result + */ +export interface PlateValidationResult { + exists: boolean; + vehicle: Vehicle | null; + customer: Customer | null; +} + +export class VehicleReceptionService { + private orderRepository: Repository; + private vehicleRepository: Repository; + private customerRepository: Repository; + private bayRepository: Repository; + + constructor(private dataSource: DataSource) { + this.orderRepository = dataSource.getRepository(ServiceOrder); + this.vehicleRepository = dataSource.getRepository(Vehicle); + this.customerRepository = dataSource.getRepository(Customer); + this.bayRepository = dataSource.getRepository(WorkBay); + } + + /** + * Generate next order number for tenant + * Format: OS-YYYY-NNNNN + */ + private async generateOrderNumber(tenantId: string): Promise { + const year = new Date().getFullYear(); + const prefix = `OS-${year}-`; + + const lastOrder = await this.orderRepository + .createQueryBuilder('order') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('order.order_number LIKE :prefix', { prefix: `${prefix}%` }) + .orderBy('order.order_number', 'DESC') + .getOne(); + + 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')}`; + } + + /** + * Main method to receive a vehicle for service + * + * @param tenantId - Tenant ID for multi-tenancy + * @param dto - Reception data + * @param userId - User performing the reception + * @returns Created service order with vehicle and customer info + */ + async receiveVehicle( + tenantId: string, + dto: CreateVehicleReceptionDto, + userId?: string + ): Promise { + return this.dataSource.transaction(async (transactionalEntityManager) => { + const orderRepo = transactionalEntityManager.getRepository(ServiceOrder); + const vehicleRepo = transactionalEntityManager.getRepository(Vehicle); + const customerRepo = transactionalEntityManager.getRepository(Customer); + const bayRepo = transactionalEntityManager.getRepository(WorkBay); + + let vehicle: Vehicle; + let customer: Customer; + + // Step 1: Resolve or create customer + if (dto.customerId) { + const existingCustomer = await customerRepo.findOne({ + where: { id: dto.customerId, tenantId }, + }); + if (!existingCustomer) { + throw new Error(`Customer with ID ${dto.customerId} not found`); + } + customer = existingCustomer; + } else if (dto.newCustomer) { + const newCustomer = customerRepo.create({ + tenantId, + name: dto.newCustomer.name, + phone: dto.newCustomer.phone, + email: dto.newCustomer.email, + customerType: dto.newCustomer.customerType || CustomerType.INDIVIDUAL, + isActive: true, + createdBy: userId, + }); + customer = await customerRepo.save(newCustomer); + } else { + throw new Error('Either customerId or newCustomer must be provided'); + } + + // Step 2: Resolve or create vehicle + if (dto.vehicleId) { + const existingVehicle = await vehicleRepo.findOne({ + where: { id: dto.vehicleId, tenantId }, + }); + if (!existingVehicle) { + throw new Error(`Vehicle with ID ${dto.vehicleId} not found`); + } + vehicle = existingVehicle; + + // Verify vehicle belongs to customer + if (vehicle.customerId !== customer.id) { + throw new Error('Vehicle does not belong to the specified customer'); + } + } else if (dto.newVehicle) { + // Check if plate already exists + const existingByPlate = await vehicleRepo.findOne({ + where: { tenantId, licensePlate: dto.newVehicle.licensePlate }, + }); + if (existingByPlate) { + throw new Error(`Vehicle with plate ${dto.newVehicle.licensePlate} already exists`); + } + + const newVehicle = vehicleRepo.create({ + tenantId, + customerId: customer.id, + licensePlate: dto.newVehicle.licensePlate, + make: dto.newVehicle.make, + model: dto.newVehicle.model, + year: dto.newVehicle.year, + vehicleType: dto.newVehicle.vehicleType || VehicleType.TRUCK, + vin: dto.newVehicle.vin, + color: dto.newVehicle.color, + currentOdometer: dto.odometerIn, + odometerUpdatedAt: new Date(), + status: VehicleStatus.ACTIVE, + }); + vehicle = await vehicleRepo.save(newVehicle); + } else { + throw new Error('Either vehicleId or newVehicle must be provided'); + } + + // Step 3: Validate and update odometer + if (dto.odometerIn < (vehicle.currentOdometer || 0)) { + throw new Error( + `Odometer reading (${dto.odometerIn}) cannot be less than current odometer (${vehicle.currentOdometer || 0})` + ); + } + + // Update vehicle odometer + vehicle.currentOdometer = dto.odometerIn; + vehicle.odometerUpdatedAt = new Date(); + await vehicleRepo.save(vehicle); + + // Step 4: Assign work bay (optional) + let bay: WorkBay | null = null; + if (dto.bayId) { + bay = await bayRepo.findOne({ + where: { id: dto.bayId, tenantId }, + }); + + if (!bay) { + throw new Error(`Work bay with ID ${dto.bayId} not found`); + } + + if (bay.status !== BayStatus.AVAILABLE) { + throw new Error(`Work bay ${bay.name} is not available (current status: ${bay.status})`); + } + } + + // Step 5: Generate order number + const orderNumber = await this.generateOrderNumber(tenantId); + + // Step 6: Create service order + const order = orderRepo.create({ + tenantId, + orderNumber, + customerId: customer.id, + vehicleId: vehicle.id, + customerSymptoms: dto.customerSymptoms, + odometerIn: dto.odometerIn, + priority: dto.priority || ServiceOrderPriority.NORMAL, + status: ServiceOrderStatus.RECEIVED, + bayId: dto.bayId, + internalNotes: dto.internalNotes, + promisedAt: dto.promisedAt, + receivedAt: new Date(), + createdBy: userId, + }); + + const savedOrder = await orderRepo.save(order); + + // Step 7: Update bay status if assigned + if (bay) { + bay.status = BayStatus.OCCUPIED; + bay.currentOrderId = savedOrder.id; + await bayRepo.save(bay); + } + + // Step 8: Update customer last visit + customer.lastVisitAt = new Date(); + await customerRepo.save(customer); + + return { + order: savedOrder, + vehicle, + customer, + bay, + orderNumber, + }; + }); + } + + /** + * Quick reception for walk-in customers + * Creates customer and vehicle if they don't exist, then receives the vehicle + * + * @param tenantId - Tenant ID + * @param dto - Quick reception data + * @param userId - User performing the reception + */ + async createQuickReception( + tenantId: string, + dto: QuickReceptionDto, + userId?: string + ): Promise { + // Check if vehicle plate exists + const existingVehicle = await this.vehicleRepository.findOne({ + where: { tenantId, licensePlate: dto.vehiclePlate }, + }); + + let customerId: string | undefined; + let vehicleId: string | undefined; + let newCustomer: NewCustomerInfo | undefined; + let newVehicle: NewVehicleInfo | undefined; + + if (existingVehicle) { + // Vehicle exists, use existing customer + vehicleId = existingVehicle.id; + customerId = existingVehicle.customerId; + } else { + // Check if customer exists by phone + const existingCustomer = await this.customerRepository.findOne({ + where: { tenantId, phone: dto.customerPhone }, + }); + + if (existingCustomer) { + customerId = existingCustomer.id; + } else { + // Create new customer + newCustomer = { + name: dto.customerName, + phone: dto.customerPhone, + email: dto.customerEmail, + customerType: CustomerType.INDIVIDUAL, + }; + } + + // Create new vehicle + newVehicle = { + licensePlate: dto.vehiclePlate, + make: dto.vehicleMake, + model: dto.vehicleModel, + year: dto.vehicleYear, + vehicleType: dto.vehicleType || VehicleType.TRUCK, + }; + } + + const receptionDto: CreateVehicleReceptionDto = { + customerId, + vehicleId, + newCustomer, + newVehicle, + customerSymptoms: dto.symptoms, + odometerIn: dto.odometer, + priority: dto.priority, + bayId: dto.bayId, + }; + + return this.receiveVehicle(tenantId, receptionDto, userId); + } + + /** + * Get pre-filled data for reception form + * + * @param tenantId - Tenant ID + * @param vehicleId - Optional vehicle ID to pre-fill data + */ + async getReceptionForm(tenantId: string, vehicleId?: string): Promise { + let vehicle: Vehicle | null = null; + let customer: Customer | null = null; + let lastOdometer: number | null = null; + let serviceHistory: ServiceOrderSummary[] = []; + + if (vehicleId) { + vehicle = await this.vehicleRepository.findOne({ + where: { id: vehicleId, tenantId }, + }); + + if (vehicle) { + customer = await this.customerRepository.findOne({ + where: { id: vehicle.customerId, tenantId }, + }); + + lastOdometer = vehicle.currentOdometer || null; + + // Get recent service history (last 10 orders) + const orders = await this.orderRepository.find({ + where: { tenantId, vehicleId }, + order: { receivedAt: 'DESC' }, + take: 10, + }); + + serviceHistory = orders.map((order) => ({ + id: order.id, + orderNumber: order.orderNumber, + status: order.status, + receivedAt: order.receivedAt, + completedAt: order.completedAt || null, + grandTotal: Number(order.grandTotal), + customerSymptoms: order.customerSymptoms || null, + })); + } + } + + // Get available work bays + const availableBays = await this.getAvailableWorkBays(tenantId); + + return { + vehicle, + customer, + availableBays, + serviceHistory, + lastOdometer, + }; + } + + /** + * Validate if a vehicle plate already exists + * + * @param tenantId - Tenant ID + * @param plate - License plate to validate + */ + async validateVehiclePlate(tenantId: string, plate: string): Promise { + const vehicle = await this.vehicleRepository.findOne({ + where: { tenantId, licensePlate: plate.toUpperCase().trim() }, + }); + + if (!vehicle) { + return { + exists: false, + vehicle: null, + customer: null, + }; + } + + const customer = await this.customerRepository.findOne({ + where: { id: vehicle.customerId, tenantId }, + }); + + return { + exists: true, + vehicle, + customer, + }; + } + + /** + * Get list of available work bays + * + * @param tenantId - Tenant ID + */ + async getAvailableWorkBays(tenantId: string): Promise { + return this.bayRepository.find({ + where: { + tenantId, + status: BayStatus.AVAILABLE, + isActive: true, + }, + order: { name: 'ASC' }, + }); + } + + /** + * Get all work bays with their current status + * + * @param tenantId - Tenant ID + */ + async getAllWorkBays(tenantId: string): Promise { + return this.bayRepository.find({ + where: { tenantId, isActive: true }, + order: { name: 'ASC' }, + }); + } + + /** + * Assign a vehicle/order to a work bay + * + * @param tenantId - Tenant ID + * @param orderId - Service order ID + * @param bayId - Work bay ID + */ + async assignToBay(tenantId: string, orderId: string, bayId: string): Promise { + return this.dataSource.transaction(async (transactionalEntityManager) => { + const orderRepo = transactionalEntityManager.getRepository(ServiceOrder); + const bayRepo = transactionalEntityManager.getRepository(WorkBay); + + const order = await orderRepo.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order with ID ${orderId} not found`); + } + + const bay = await bayRepo.findOne({ + where: { id: bayId, tenantId }, + }); + + if (!bay) { + throw new Error(`Work bay with ID ${bayId} not found`); + } + + if (bay.status !== BayStatus.AVAILABLE) { + throw new Error(`Work bay ${bay.name} is not available (current status: ${bay.status})`); + } + + // If order was already in another bay, release that bay + if (order.bayId) { + const previousBay = await bayRepo.findOne({ + where: { id: order.bayId, tenantId }, + }); + if (previousBay && previousBay.currentOrderId === order.id) { + previousBay.status = BayStatus.AVAILABLE; + previousBay.currentOrderId = undefined; + await bayRepo.save(previousBay); + } + } + + // Assign to new bay + bay.status = BayStatus.OCCUPIED; + bay.currentOrderId = order.id; + await bayRepo.save(bay); + + // Update order + order.bayId = bay.id; + await orderRepo.save(order); + + return bay; + }); + } + + /** + * Release a work bay + * + * @param tenantId - Tenant ID + * @param bayId - Work bay ID + */ + async releaseBay(tenantId: string, bayId: string): Promise { + const bay = await this.bayRepository.findOne({ + where: { id: bayId, tenantId }, + }); + + if (!bay) { + throw new Error(`Work bay with ID ${bayId} not found`); + } + + // Clear the order's bay reference if there is one + if (bay.currentOrderId) { + const order = await this.orderRepository.findOne({ + where: { id: bay.currentOrderId, tenantId }, + }); + if (order && order.bayId === bayId) { + order.bayId = undefined; + await this.orderRepository.save(order); + } + } + + bay.status = BayStatus.AVAILABLE; + bay.currentOrderId = undefined; + return this.bayRepository.save(bay); + } + + /** + * Get today's receptions + * + * @param tenantId - Tenant ID + */ + async getTodayReceptions(tenantId: string): Promise { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + return this.orderRepository + .createQueryBuilder('order') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('order.received_at >= :today', { today }) + .andWhere('order.received_at < :tomorrow', { tomorrow }) + .orderBy('order.received_at', 'DESC') + .getMany(); + } + + /** + * Get pending receptions (received but not yet diagnosed) + * + * @param tenantId - Tenant ID + */ + async getPendingReceptions(tenantId: string): Promise { + return this.orderRepository.find({ + where: { + tenantId, + status: ServiceOrderStatus.RECEIVED, + }, + order: { receivedAt: 'ASC' }, + }); + } + + /** + * Get reception statistics + * + * @param tenantId - Tenant ID + */ + async getReceptionStats(tenantId: string): Promise<{ + todayCount: number; + pendingCount: number; + availableBays: number; + totalBays: number; + averageWaitTime: number | null; + }> { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const [todayCount, pendingCount, bayStats, avgWaitResult] = await Promise.all([ + this.orderRepository + .createQueryBuilder('order') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('order.received_at >= :today', { today }) + .getCount(), + this.orderRepository.count({ + where: { tenantId, status: ServiceOrderStatus.RECEIVED }, + }), + this.bayRepository + .createQueryBuilder('bay') + .select('COUNT(*)', 'total') + .addSelect('SUM(CASE WHEN bay.status = :available THEN 1 ELSE 0 END)', 'available') + .where('bay.tenant_id = :tenantId', { tenantId }) + .andWhere('bay.is_active = true') + .setParameter('available', BayStatus.AVAILABLE) + .getRawOne(), + this.orderRepository + .createQueryBuilder('order') + .select('AVG(EXTRACT(EPOCH FROM (order.started_at - order.received_at)))', 'avgWait') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('order.started_at IS NOT NULL') + .andWhere('order.received_at >= :thirtyDaysAgo', { + thirtyDaysAgo: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }) + .getRawOne(), + ]); + + const avgWaitSeconds = parseFloat(avgWaitResult?.avgWait) || null; + const averageWaitTime = avgWaitSeconds ? Math.round(avgWaitSeconds / 60) : null; // Convert to minutes + + return { + todayCount, + pendingCount, + availableBays: parseInt(bayStats?.available || '0', 10), + totalBays: parseInt(bayStats?.total || '0', 10), + averageWaitTime, + }; + } + + /** + * Search for customer by phone or name for quick lookup during reception + * + * @param tenantId - Tenant ID + * @param query - Search query + */ + async searchCustomer(tenantId: string, query: string): Promise { + return this.customerRepository + .createQueryBuilder('customer') + .where('customer.tenant_id = :tenantId', { tenantId }) + .andWhere('customer.is_active = true') + .andWhere( + '(customer.name ILIKE :query OR customer.phone ILIKE :query OR customer.email ILIKE :query)', + { query: `%${query}%` } + ) + .orderBy('customer.name', 'ASC') + .take(10) + .getMany(); + } + + /** + * Search for vehicle by plate or VIN for quick lookup during reception + * + * @param tenantId - Tenant ID + * @param query - Search query (plate or VIN) + */ + async searchVehicle(tenantId: string, query: string): Promise { + return this.vehicleRepository + .createQueryBuilder('vehicle') + .where('vehicle.tenant_id = :tenantId', { tenantId }) + .andWhere('vehicle.status = :status', { status: VehicleStatus.ACTIVE }) + .andWhere( + '(vehicle.license_plate ILIKE :query OR vehicle.vin ILIKE :query OR vehicle.economic_number ILIKE :query)', + { query: `%${query}%` } + ) + .orderBy('vehicle.license_plate', 'ASC') + .take(10) + .getMany(); + } + + /** + * Get customer's vehicles for selection during reception + * + * @param tenantId - Tenant ID + * @param customerId - Customer ID + */ + async getCustomerVehicles(tenantId: string, customerId: string): Promise { + return this.vehicleRepository.find({ + where: { + tenantId, + customerId, + status: VehicleStatus.ACTIVE, + }, + order: { licensePlate: 'ASC' }, + }); + } +} diff --git a/src/shared/middleware/rbac.middleware.ts b/src/shared/middleware/rbac.middleware.ts index b2a8da9..72cce6e 100644 --- a/src/shared/middleware/rbac.middleware.ts +++ b/src/shared/middleware/rbac.middleware.ts @@ -78,7 +78,7 @@ export function requirePerm(code: string) { return; } - const roles = req.user.roles || []; + const roles = req.user.role ? [req.user.role] : []; const hasPermission = await userHasPermission(roles, resource, action); if (hasPermission) { @@ -109,7 +109,7 @@ export function requireAnyPerm(...codes: string[]) { return; } - const roles = req.user.roles || []; + const roles = req.user.role ? [req.user.role] : []; // Super admin bypass if (roles.includes('super_admin')) { @@ -149,7 +149,7 @@ export function requireAllPerms(...codes: string[]) { return; } - const roles = req.user.roles || []; + const roles = req.user.role ? [req.user.role] : []; // Super admin bypass if (roles.includes('super_admin')) { @@ -198,7 +198,7 @@ export function requireAccess(options: { roles?: string[]; permission?: string } return; } - const roles = req.user.roles || []; + const roles = req.user.role ? [req.user.role] : []; // Super admin bypass if (roles.includes('super_admin')) { @@ -256,8 +256,8 @@ export function requirePermOrOwner(code: string, ownerField: string = 'userId') return; } - const userId = req.user.sub || req.user.userId; - const roles = req.user.roles || []; + const userId = req.user.userId; + const roles = req.user.role ? [req.user.role] : []; const resourceOwnerId = req.params[ownerField] || req.body?.[ownerField]; // Super admin bypass