"use strict"; /** * TicketService - Servicio de tickets postventa * * Gestión de tickets de garantía con SLA. * * @module Quality */ Object.defineProperty(exports, "__esModule", { value: true }); exports.TicketService = void 0; const typeorm_1 = require("typeorm"); // SLA hours by priority const SLA_HOURS = { urgent: 24, high: 48, medium: 168, // 7 days low: 360, // 15 days }; // Auto-priority by category const CATEGORY_PRIORITY = { plumbing: 'high', electrical: 'high', structural: 'urgent', finishes: 'medium', carpentry: 'medium', other: 'low', }; class TicketService { ticketRepository; assignmentRepository; constructor(ticketRepository, assignmentRepository) { this.ticketRepository = ticketRepository; this.assignmentRepository = assignmentRepository; } generateTicketNumber() { const now = new Date(); const year = now.getFullYear().toString().slice(-2); const month = (now.getMonth() + 1).toString().padStart(2, '0'); const random = Math.random().toString(36).substring(2, 8).toUpperCase(); return `TKT-${year}${month}-${random}`; } async findWithFilters(ctx, filters = {}, page = 1, limit = 20) { const skip = (page - 1) * limit; const queryBuilder = this.ticketRepository .createQueryBuilder('tkt') .leftJoinAndSelect('tkt.assignments', 'assignments', 'assignments.is_current = true') .leftJoinAndSelect('assignments.technician', 'technician') .where('tkt.tenant_id = :tenantId', { tenantId: ctx.tenantId }); if (filters.loteId) { queryBuilder.andWhere('tkt.lote_id = :loteId', { loteId: filters.loteId }); } if (filters.derechohabienteId) { queryBuilder.andWhere('tkt.derechohabiente_id = :derechohabienteId', { derechohabienteId: filters.derechohabienteId, }); } if (filters.category) { queryBuilder.andWhere('tkt.category = :category', { category: filters.category }); } if (filters.priority) { queryBuilder.andWhere('tkt.priority = :priority', { priority: filters.priority }); } if (filters.status) { queryBuilder.andWhere('tkt.status = :status', { status: filters.status }); } if (filters.slaBreached !== undefined) { queryBuilder.andWhere('tkt.sla_breached = :slaBreached', { slaBreached: filters.slaBreached }); } if (filters.assignedTo) { queryBuilder.andWhere('assignments.technician_id = :technicianId', { technicianId: filters.assignedTo }); } if (filters.dateFrom) { queryBuilder.andWhere('tkt.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); } if (filters.dateTo) { queryBuilder.andWhere('tkt.created_at <= :dateTo', { dateTo: filters.dateTo }); } queryBuilder .orderBy('tkt.priority', 'ASC') .addOrderBy('tkt.sla_due_at', 'ASC') .skip(skip) .take(limit); const [data, total] = await queryBuilder.getManyAndCount(); return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit), }, }; } async findById(ctx, id) { return this.ticketRepository.findOne({ where: { id, tenantId: ctx.tenantId, }, }); } async findWithDetails(ctx, id) { return this.ticketRepository.findOne({ where: { id, tenantId: ctx.tenantId, }, relations: ['assignments', 'assignments.technician', 'assignments.assignedBy', 'createdBy'], }); } async create(ctx, dto) { // Determine priority based on category const priority = CATEGORY_PRIORITY[dto.category]; const slaHours = SLA_HOURS[priority]; const slaDueAt = new Date(); slaDueAt.setHours(slaDueAt.getHours() + slaHours); const ticket = this.ticketRepository.create({ tenantId: ctx.tenantId, createdById: ctx.userId, loteId: dto.loteId, derechohabienteId: dto.derechohabienteId, ticketNumber: this.generateTicketNumber(), category: dto.category, priority, title: dto.title, description: dto.description, photoUrl: dto.photoUrl, contactName: dto.contactName, contactPhone: dto.contactPhone, status: 'created', slaHours, slaDueAt, }); return this.ticketRepository.save(ticket); } async assign(ctx, id, dto) { const ticket = await this.findWithDetails(ctx, id); if (!ticket) { return null; } if (ticket.status === 'closed' || ticket.status === 'cancelled') { throw new Error('Cannot assign closed or cancelled tickets'); } // Mark previous assignments as not current if (ticket.assignments && ticket.assignments.length > 0) { for (const assignment of ticket.assignments) { if (assignment.isCurrent) { assignment.isCurrent = false; assignment.status = 'reassigned'; await this.assignmentRepository.save(assignment); } } } // Create new assignment const assignment = this.assignmentRepository.create({ tenantId: ctx.tenantId, ticketId: id, technicianId: dto.technicianId, assignedAt: new Date(), assignedById: ctx.userId || '', scheduledDate: dto.scheduledDate, scheduledTime: dto.scheduledTime, status: 'assigned', isCurrent: true, }); await this.assignmentRepository.save(assignment); // Update ticket status ticket.status = 'assigned'; ticket.assignedAt = new Date(); ticket.updatedById = ctx.userId || ''; return this.ticketRepository.save(ticket); } async startWork(ctx, id) { const ticket = await this.findWithDetails(ctx, id); if (!ticket) { return null; } if (ticket.status !== 'assigned') { throw new Error('Can only start work on assigned tickets'); } // Update current assignment const currentAssignment = ticket.assignments?.find(a => a.isCurrent); if (currentAssignment) { currentAssignment.status = 'in_progress'; currentAssignment.acceptedAt = new Date(); await this.assignmentRepository.save(currentAssignment); } ticket.status = 'in_progress'; ticket.updatedById = ctx.userId || ''; return this.ticketRepository.save(ticket); } async resolve(ctx, id, dto) { const ticket = await this.findWithDetails(ctx, id); if (!ticket) { return null; } if (ticket.status !== 'in_progress') { throw new Error('Can only resolve in-progress tickets'); } // Update current assignment const currentAssignment = ticket.assignments?.find(a => a.isCurrent); if (currentAssignment) { currentAssignment.status = 'completed'; currentAssignment.completedAt = new Date(); currentAssignment.workNotes = dto.resolutionNotes; await this.assignmentRepository.save(currentAssignment); } ticket.status = 'resolved'; ticket.resolvedAt = new Date(); ticket.resolutionNotes = dto.resolutionNotes; if (dto.resolutionPhotoUrl) { ticket.resolutionPhotoUrl = dto.resolutionPhotoUrl; } ticket.updatedById = ctx.userId || ''; // Check if SLA was breached if (new Date() > ticket.slaDueAt) { ticket.slaBreached = true; } return this.ticketRepository.save(ticket); } async close(ctx, id, rating, comment) { const ticket = await this.findById(ctx, id); if (!ticket) { return null; } if (ticket.status !== 'resolved') { throw new Error('Can only close resolved tickets'); } ticket.status = 'closed'; ticket.closedAt = new Date(); if (rating !== undefined) { ticket.satisfactionRating = rating; } if (comment) { ticket.satisfactionComment = comment; } ticket.updatedById = ctx.userId || ''; return this.ticketRepository.save(ticket); } async cancel(ctx, id, reason) { const ticket = await this.findById(ctx, id); if (!ticket) { return null; } if (ticket.status === 'closed') { throw new Error('Cannot cancel closed tickets'); } ticket.status = 'cancelled'; ticket.resolutionNotes = `[CANCELLED] ${reason}`; ticket.updatedById = ctx.userId || ''; return this.ticketRepository.save(ticket); } async checkSlaBreaches(ctx) { const result = await this.ticketRepository.update({ tenantId: ctx.tenantId, slaBreached: false, slaDueAt: (0, typeorm_1.LessThan)(new Date()), status: 'created', }, { slaBreached: true }); // Also check assigned and in_progress await this.ticketRepository.update({ tenantId: ctx.tenantId, slaBreached: false, slaDueAt: (0, typeorm_1.LessThan)(new Date()), status: 'assigned', }, { slaBreached: true }); await this.ticketRepository.update({ tenantId: ctx.tenantId, slaBreached: false, slaDueAt: (0, typeorm_1.LessThan)(new Date()), status: 'in_progress', }, { slaBreached: true }); return result.affected || 0; } async getSlaStats(ctx) { const total = await this.ticketRepository.count({ where: { tenantId: ctx.tenantId, status: 'closed', }, }); const breached = await this.ticketRepository.count({ where: { tenantId: ctx.tenantId, status: 'closed', slaBreached: true, }, }); const complianceRate = total > 0 ? ((total - breached) / total) * 100 : 100; return { total, breached, complianceRate }; } } exports.TicketService = TicketService; //# sourceMappingURL=ticket.service.js.map