294 lines
11 KiB
JavaScript
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
|