erp-construccion-backend/dist/modules/quality/services/ticket.service.js

294 lines
11 KiB
JavaScript

"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