diff --git a/src/modules/quality/services/corrective-action.service.ts b/src/modules/quality/services/corrective-action.service.ts new file mode 100644 index 0000000..76ca030 --- /dev/null +++ b/src/modules/quality/services/corrective-action.service.ts @@ -0,0 +1,380 @@ +/** + * CorrectiveActionService - Servicio de acciones correctivas y preventivas (CAPA) + * + * Gestiona el ciclo de vida de acciones correctivas asociadas a no conformidades. + * + * @module Quality (MAI-009) + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { CorrectiveAction, ActionType, ActionStatus } from '../entities/corrective-action.entity'; +import { NonConformity } from '../entities/non-conformity.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateCorrectiveActionDto { + nonConformityId: string; + actionType: ActionType; + description: string; + responsibleId: string; + dueDate: Date; +} + +export interface UpdateCorrectiveActionDto { + actionType?: ActionType; + description?: string; + responsibleId?: string; + dueDate?: Date; +} + +export interface CompleteActionDto { + completionNotes: string; +} + +export interface VerifyActionDto { + effectivenessVerified: boolean; + verificationNotes?: string; +} + +export interface CorrectiveActionFilters { + nonConformityId?: string; + responsibleId?: string; + actionType?: ActionType; + status?: ActionStatus; + effectivenessVerified?: boolean; + dueDateFrom?: Date; + dueDateTo?: Date; + search?: string; +} + +export class CorrectiveActionService { + constructor( + private readonly actionRepository: Repository, + private readonly nonConformityRepository: Repository + ) {} + + async findWithFilters( + ctx: ServiceContext, + filters: CorrectiveActionFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise<{ data: CorrectiveAction[]; total: number; page: number; limit: number }> { + const skip = (page - 1) * limit; + + const queryBuilder = this.actionRepository + .createQueryBuilder('ca') + .leftJoinAndSelect('ca.nonConformity', 'nc') + .leftJoinAndSelect('ca.responsible', 'responsible') + .leftJoinAndSelect('ca.createdBy', 'createdBy') + .leftJoinAndSelect('ca.verifiedBy', 'verifiedBy') + .where('ca.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.nonConformityId) { + queryBuilder.andWhere('ca.no_conformidad_id = :nonConformityId', { + nonConformityId: filters.nonConformityId, + }); + } + + if (filters.responsibleId) { + queryBuilder.andWhere('ca.responsible_id = :responsibleId', { + responsibleId: filters.responsibleId, + }); + } + + if (filters.actionType) { + queryBuilder.andWhere('ca.action_type = :actionType', { actionType: filters.actionType }); + } + + if (filters.status) { + queryBuilder.andWhere('ca.status = :status', { status: filters.status }); + } + + if (filters.effectivenessVerified !== undefined) { + queryBuilder.andWhere('ca.effectiveness_verified = :effectivenessVerified', { + effectivenessVerified: filters.effectivenessVerified, + }); + } + + if (filters.dueDateFrom) { + queryBuilder.andWhere('ca.due_date >= :dueDateFrom', { dueDateFrom: filters.dueDateFrom }); + } + + if (filters.dueDateTo) { + queryBuilder.andWhere('ca.due_date <= :dueDateTo', { dueDateTo: filters.dueDateTo }); + } + + if (filters.search) { + queryBuilder.andWhere('ca.description ILIKE :search', { search: `%${filters.search}%` }); + } + + queryBuilder + .orderBy('ca.due_date', 'ASC') + .addOrderBy('ca.created_at', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.actionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.actionRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['nonConformity', 'responsible', 'createdBy', 'verifiedBy'], + }); + } + + async findByNonConformity(ctx: ServiceContext, nonConformityId: string): Promise { + return this.actionRepository.find({ + where: { + tenantId: ctx.tenantId, + nonConformityId, + } as FindOptionsWhere, + relations: ['responsible', 'verifiedBy'], + order: { dueDate: 'ASC' }, + }); + } + + async create(ctx: ServiceContext, dto: CreateCorrectiveActionDto): Promise { + // Validate non-conformity exists + const nc = await this.nonConformityRepository.findOne({ + where: { + id: dto.nonConformityId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (!nc) { + throw new Error('Non-conformity not found'); + } + + if (nc.status === 'verified') { + throw new Error('Cannot add corrective actions to verified non-conformities'); + } + + const action = this.actionRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + nonConformityId: dto.nonConformityId, + actionType: dto.actionType, + description: dto.description, + responsibleId: dto.responsibleId, + dueDate: dto.dueDate, + status: 'pending', + effectivenessVerified: false, + }); + + const savedAction = await this.actionRepository.save(action); + + // Update non-conformity status to in_progress if it's open + if (nc.status === 'open') { + nc.status = 'in_progress'; + nc.updatedById = ctx.userId || ''; + await this.nonConformityRepository.save(nc); + } + + return savedAction; + } + + async update(ctx: ServiceContext, id: string, dto: UpdateCorrectiveActionDto): Promise { + const action = await this.findById(ctx, id); + if (!action) { + return null; + } + + if (action.status === 'completed' || action.status === 'verified') { + throw new Error('Cannot update completed or verified actions'); + } + + Object.assign(action, { + ...dto, + updatedById: ctx.userId, + }); + + return this.actionRepository.save(action); + } + + async startWork(ctx: ServiceContext, id: string): Promise { + const action = await this.findById(ctx, id); + if (!action) { + return null; + } + + if (action.status !== 'pending') { + throw new Error('Can only start work on pending actions'); + } + + action.status = 'in_progress'; + action.updatedById = ctx.userId || ''; + + return this.actionRepository.save(action); + } + + async complete(ctx: ServiceContext, id: string, dto: CompleteActionDto): Promise { + const action = await this.findById(ctx, id); + if (!action) { + return null; + } + + if (action.status !== 'pending' && action.status !== 'in_progress') { + throw new Error('Can only complete pending or in-progress actions'); + } + + action.status = 'completed'; + action.completedAt = new Date(); + action.completionNotes = dto.completionNotes; + action.updatedById = ctx.userId || ''; + + return this.actionRepository.save(action); + } + + async verify(ctx: ServiceContext, id: string, dto: VerifyActionDto): Promise { + const action = await this.findWithDetails(ctx, id); + if (!action) { + return null; + } + + if (action.status !== 'completed') { + throw new Error('Can only verify completed actions'); + } + + action.status = 'verified'; + action.verifiedAt = new Date(); + action.verifiedById = ctx.userId || ''; + action.effectivenessVerified = dto.effectivenessVerified; + if (dto.verificationNotes) { + action.completionNotes = `${action.completionNotes || ''}\n\n[VERIFICATION]: ${dto.verificationNotes}`; + } + action.updatedById = ctx.userId || ''; + + const savedAction = await this.actionRepository.save(action); + + // Check if all actions for the NC are verified + await this.checkAndCloseNonConformity(ctx, action.nonConformityId); + + return savedAction; + } + + async reopen(ctx: ServiceContext, id: string, reason: string): Promise { + const action = await this.findById(ctx, id); + if (!action) { + return null; + } + + if (action.status !== 'completed' && action.status !== 'verified') { + throw new Error('Can only reopen completed or verified actions'); + } + + action.status = 'in_progress'; + action.completionNotes = `${action.completionNotes || ''}\n\n[REOPENED]: ${reason}`; + action.verifiedAt = null as unknown as Date; + action.verifiedById = null as unknown as string; + action.effectivenessVerified = false; + action.updatedById = ctx.userId || ''; + + return this.actionRepository.save(action); + } + + async delete(ctx: ServiceContext, id: string): Promise { + const action = await this.findById(ctx, id); + if (!action) { + return false; + } + + if (action.status !== 'pending') { + throw new Error('Can only delete pending actions'); + } + + await this.actionRepository.delete({ id, tenantId: ctx.tenantId }); + return true; + } + + async getOverdueActions(ctx: ServiceContext): Promise { + return this.actionRepository + .createQueryBuilder('ca') + .leftJoinAndSelect('ca.nonConformity', 'nc') + .leftJoinAndSelect('ca.responsible', 'responsible') + .where('ca.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ca.status IN (:...statuses)', { statuses: ['pending', 'in_progress'] }) + .andWhere('ca.due_date < :now', { now: new Date() }) + .orderBy('ca.due_date', 'ASC') + .getMany(); + } + + async getStatsByResponsible(ctx: ServiceContext, responsibleId: string): Promise<{ + total: number; + pending: number; + inProgress: number; + completed: number; + verified: number; + overdue: number; + effectivenessRate: number; + }> { + const actions = await this.actionRepository.find({ + where: { + tenantId: ctx.tenantId, + responsibleId, + } as FindOptionsWhere, + }); + + const now = new Date(); + const total = actions.length; + const pending = actions.filter(a => a.status === 'pending').length; + const inProgress = actions.filter(a => a.status === 'in_progress').length; + const completed = actions.filter(a => a.status === 'completed').length; + const verified = actions.filter(a => a.status === 'verified').length; + const overdue = actions.filter( + a => (a.status === 'pending' || a.status === 'in_progress') && a.dueDate < now + ).length; + const effectiveCount = actions.filter(a => a.effectivenessVerified).length; + const effectivenessRate = verified > 0 ? (effectiveCount / verified) * 100 : 0; + + return { total, pending, inProgress, completed, verified, overdue, effectivenessRate }; + } + + private async checkAndCloseNonConformity(ctx: ServiceContext, nonConformityId: string): Promise { + const actions = await this.actionRepository.find({ + where: { + tenantId: ctx.tenantId, + nonConformityId, + } as FindOptionsWhere, + }); + + // If all actions are verified, close the NC + const allVerified = actions.length > 0 && actions.every(a => a.status === 'verified'); + const allEffective = actions.every(a => a.effectivenessVerified); + + if (allVerified && allEffective) { + const nc = await this.nonConformityRepository.findOne({ + where: { + id: nonConformityId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (nc && nc.status === 'in_progress') { + nc.status = 'closed'; + nc.closedAt = new Date(); + nc.closedById = ctx.userId || ''; + nc.updatedById = ctx.userId || ''; + await this.nonConformityRepository.save(nc); + } + } + } +} diff --git a/src/modules/quality/services/index.ts b/src/modules/quality/services/index.ts index 5963861..35c8716 100644 --- a/src/modules/quality/services/index.ts +++ b/src/modules/quality/services/index.ts @@ -1,8 +1,28 @@ /** * Quality Services Index - * @module Quality + * + * Barrel file exporting all quality module services. + * + * @module Quality (MAI-009) */ +// Checklist management export * from './checklist.service'; + +// Inspection management export * from './inspection.service'; + +// Inspection results +export * from './inspection-result.service'; + +// Non-conformities management +export * from './non-conformity.service'; + +// Corrective actions (CAPA) +export * from './corrective-action.service'; + +// Post-sale tickets export * from './ticket.service'; + +// Ticket assignments +export * from './ticket-assignment.service'; diff --git a/src/modules/quality/services/inspection-result.service.ts b/src/modules/quality/services/inspection-result.service.ts new file mode 100644 index 0000000..035e516 --- /dev/null +++ b/src/modules/quality/services/inspection-result.service.ts @@ -0,0 +1,358 @@ +/** + * InspectionResultService - Servicio de resultados de inspección + * + * Gestiona el registro y consulta de resultados de items de inspección. + * + * @module Quality (MAI-009) + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { InspectionResult, InspectionResultStatus } from '../entities/inspection-result.entity'; +import { Inspection } from '../entities/inspection.entity'; +import { ChecklistItem } from '../entities/checklist-item.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface RecordInspectionResultDto { + checklistItemId: string; + result: InspectionResultStatus; + observations?: string; + photoUrl?: string; +} + +export interface UpdateInspectionResultDto { + result?: InspectionResultStatus; + observations?: string; + photoUrl?: string; +} + +export interface BatchRecordInspectionResultDto { + items: RecordInspectionResultDto[]; +} + +export interface ResultFilters { + inspectionId?: string; + checklistItemId?: string; + result?: InspectionResultStatus; + hasObservations?: boolean; + hasPhoto?: boolean; +} + +export class InspectionResultService { + constructor( + private readonly resultRepository: Repository, + private readonly inspectionRepository: Repository, + private readonly checklistItemRepository: Repository + ) {} + + async findByInspection(ctx: ServiceContext, inspectionId: string): Promise { + return this.resultRepository.find({ + where: { + tenantId: ctx.tenantId, + inspectionId, + } as FindOptionsWhere, + relations: ['checklistItem'], + order: { createdAt: 'ASC' }, + }); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.resultRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['checklistItem', 'inspection'], + }); + } + + async findByInspectionAndItem( + ctx: ServiceContext, + inspectionId: string, + checklistItemId: string + ): Promise { + return this.resultRepository.findOne({ + where: { + tenantId: ctx.tenantId, + inspectionId, + checklistItemId, + } as FindOptionsWhere, + relations: ['checklistItem'], + }); + } + + async recordResult( + ctx: ServiceContext, + inspectionId: string, + dto: RecordInspectionResultDto + ): Promise { + // Validate inspection exists and is in valid state + const inspection = await this.inspectionRepository.findOne({ + where: { + id: inspectionId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (!inspection) { + throw new Error('Inspection not found'); + } + + if (inspection.status !== 'in_progress') { + throw new Error('Can only record results for in-progress inspections'); + } + + // Validate checklist item exists + const item = await this.checklistItemRepository.findOne({ + where: { + id: dto.checklistItemId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (!item) { + throw new Error('Checklist item not found'); + } + + // Check if item requires photo + if (item.requiresPhoto && dto.result === 'passed' && !dto.photoUrl) { + throw new Error('This checklist item requires a photo for passed results'); + } + + // Check if result already exists + let result = await this.findByInspectionAndItem(ctx, inspectionId, dto.checklistItemId); + + if (result) { + // Update existing result + result.result = dto.result; + result.observations = dto.observations || result.observations; + result.photoUrl = dto.photoUrl || result.photoUrl; + result.inspectedAt = new Date(); + } else { + // Create new result + result = this.resultRepository.create({ + tenantId: ctx.tenantId, + inspectionId, + checklistItemId: dto.checklistItemId, + result: dto.result, + observations: dto.observations, + photoUrl: dto.photoUrl, + inspectedAt: new Date(), + }); + } + + return this.resultRepository.save(result); + } + + async recordBatchResults( + ctx: ServiceContext, + inspectionId: string, + dto: BatchRecordInspectionResultDto + ): Promise { + // Validate inspection + const inspection = await this.inspectionRepository.findOne({ + where: { + id: inspectionId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (!inspection) { + throw new Error('Inspection not found'); + } + + if (inspection.status !== 'in_progress') { + throw new Error('Can only record results for in-progress inspections'); + } + + const results: InspectionResult[] = []; + + for (const item of dto.items) { + const result = await this.recordResult(ctx, inspectionId, item); + results.push(result); + } + + return results; + } + + async updateResult( + ctx: ServiceContext, + id: string, + dto: UpdateInspectionResultDto + ): Promise { + const result = await this.findById(ctx, id); + if (!result) { + return null; + } + + // Check inspection is still editable + const inspection = await this.inspectionRepository.findOne({ + where: { + id: result.inspectionId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (!inspection || inspection.status !== 'in_progress') { + throw new Error('Cannot update results for non-in-progress inspections'); + } + + Object.assign(result, dto); + result.inspectedAt = new Date(); + + return this.resultRepository.save(result); + } + + async deleteResult(ctx: ServiceContext, id: string): Promise { + const result = await this.findById(ctx, id); + if (!result) { + return false; + } + + // Check inspection is still editable + const inspection = await this.inspectionRepository.findOne({ + where: { + id: result.inspectionId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (!inspection || inspection.status !== 'in_progress') { + throw new Error('Cannot delete results for non-in-progress inspections'); + } + + await this.resultRepository.delete({ id, tenantId: ctx.tenantId }); + return true; + } + + async getInspectionSummary(ctx: ServiceContext, inspectionId: string): Promise<{ + totalItems: number; + inspectedItems: number; + passed: number; + failed: number; + notApplicable: number; + pending: number; + passRate: number; + completionRate: number; + }> { + // Get inspection with checklist + const inspection = await this.inspectionRepository.findOne({ + where: { + id: inspectionId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['checklist', 'checklist.items'], + }); + + if (!inspection) { + throw new Error('Inspection not found'); + } + + const totalItems = inspection.checklist?.items?.filter(i => i.isActive).length || 0; + + // Get results + const results = await this.resultRepository.find({ + where: { + tenantId: ctx.tenantId, + inspectionId, + } as FindOptionsWhere, + }); + + const passed = results.filter(r => r.result === 'passed').length; + const failed = results.filter(r => r.result === 'failed').length; + const notApplicable = results.filter(r => r.result === 'not_applicable').length; + const pending = results.filter(r => r.result === 'pending').length; + const inspectedItems = passed + failed + notApplicable; + + const applicableItems = inspectedItems - notApplicable; + const passRate = applicableItems > 0 ? (passed / applicableItems) * 100 : 0; + const completionRate = totalItems > 0 ? (inspectedItems / totalItems) * 100 : 0; + + return { + totalItems, + inspectedItems, + passed, + failed, + notApplicable, + pending, + passRate: Math.round(passRate * 100) / 100, + completionRate: Math.round(completionRate * 100) / 100, + }; + } + + async getFailedItemsWithDetails(ctx: ServiceContext, inspectionId: string): Promise { + return this.resultRepository.find({ + where: { + tenantId: ctx.tenantId, + inspectionId, + result: 'failed' as InspectionResultStatus, + } as FindOptionsWhere, + relations: ['checklistItem'], + order: { createdAt: 'ASC' }, + }); + } + + async getCriticalFailures(ctx: ServiceContext, inspectionId: string): Promise { + return this.resultRepository + .createQueryBuilder('ir') + .leftJoinAndSelect('ir.checklistItem', 'item') + .where('ir.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ir.inspection_id = :inspectionId', { inspectionId }) + .andWhere('ir.result = :result', { result: 'failed' }) + .andWhere('item.is_critical = true') + .orderBy('ir.created_at', 'ASC') + .getMany(); + } + + async getResultsByCategory(ctx: ServiceContext, inspectionId: string): Promise<{ + category: string; + total: number; + passed: number; + failed: number; + passRate: number; + }[]> { + const results = await this.resultRepository + .createQueryBuilder('ir') + .leftJoinAndSelect('ir.checklistItem', 'item') + .where('ir.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ir.inspection_id = :inspectionId', { inspectionId }) + .andWhere('ir.result != :na', { na: 'not_applicable' }) + .getMany(); + + // Group by category + const categoryMap = new Map(); + + for (const result of results) { + const category = result.checklistItem?.category || 'Unknown'; + if (!categoryMap.has(category)) { + categoryMap.set(category, { total: 0, passed: 0, failed: 0 }); + } + const stats = categoryMap.get(category)!; + stats.total++; + if (result.result === 'passed') stats.passed++; + if (result.result === 'failed') stats.failed++; + } + + return Array.from(categoryMap.entries()).map(([category, stats]) => ({ + category, + total: stats.total, + passed: stats.passed, + failed: stats.failed, + passRate: stats.total > 0 ? Math.round((stats.passed / stats.total) * 100 * 100) / 100 : 0, + })); + } + + async getResultsWithPhotos(ctx: ServiceContext, inspectionId: string): Promise { + return this.resultRepository.find({ + where: { + tenantId: ctx.tenantId, + inspectionId, + } as FindOptionsWhere, + relations: ['checklistItem'], + order: { createdAt: 'ASC' }, + }).then(results => results.filter(r => r.photoUrl)); + } +} diff --git a/src/modules/quality/services/non-conformity.service.ts b/src/modules/quality/services/non-conformity.service.ts new file mode 100644 index 0000000..cd52c79 --- /dev/null +++ b/src/modules/quality/services/non-conformity.service.ts @@ -0,0 +1,518 @@ +/** + * NonConformityService - Servicio de no conformidades + * + * Gestiona el ciclo de vida de no conformidades detectadas en inspecciones. + * + * @module Quality (MAI-009) + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { NonConformity, NCSeverity, NCStatus } from '../entities/non-conformity.entity'; +import { CorrectiveAction } from '../entities/corrective-action.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateNonConformityDto { + inspectionId?: string; + loteId: string; + detectionDate: Date; + category: string; + severity: NCSeverity; + description: string; + rootCause?: string; + photoUrl?: string; + contractorId?: string; + dueDate?: Date; +} + +export interface UpdateNonConformityDto { + loteId?: string; + category?: string; + severity?: NCSeverity; + description?: string; + rootCause?: string; + photoUrl?: string; + contractorId?: string; + dueDate?: Date; +} + +export interface CloseNonConformityDto { + closurePhotoUrl?: string; + closureNotes?: string; +} + +export interface VerifyNonConformityDto { + verificationNotes?: string; +} + +export interface NonConformityFilters { + inspectionId?: string; + loteId?: string; + contractorId?: string; + severity?: NCSeverity; + status?: NCStatus; + category?: string; + detectionDateFrom?: Date; + detectionDateTo?: Date; + dueDateFrom?: Date; + dueDateTo?: Date; + search?: string; +} + +// SLA days by severity +const SLA_DAYS: Record = { + critical: 3, + major: 7, + minor: 15, +}; + +export class NonConformityService { + constructor( + private readonly ncRepository: Repository, + private readonly actionRepository: Repository + ) {} + + private generateNCNumber(): string { + 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, 6).toUpperCase(); + return `NC-${year}${month}-${random}`; + } + + async findWithFilters( + ctx: ServiceContext, + filters: NonConformityFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise<{ data: NonConformity[]; total: number; page: number; limit: number }> { + const skip = (page - 1) * limit; + + const queryBuilder = this.ncRepository + .createQueryBuilder('nc') + .leftJoinAndSelect('nc.inspection', 'inspection') + .leftJoinAndSelect('nc.createdBy', 'createdBy') + .leftJoinAndSelect('nc.closedBy', 'closedBy') + .leftJoinAndSelect('nc.verifiedBy', 'verifiedBy') + .where('nc.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.inspectionId) { + queryBuilder.andWhere('nc.inspection_id = :inspectionId', { inspectionId: filters.inspectionId }); + } + + if (filters.loteId) { + queryBuilder.andWhere('nc.lote_id = :loteId', { loteId: filters.loteId }); + } + + if (filters.contractorId) { + queryBuilder.andWhere('nc.contractor_id = :contractorId', { contractorId: filters.contractorId }); + } + + if (filters.severity) { + queryBuilder.andWhere('nc.severity = :severity', { severity: filters.severity }); + } + + if (filters.status) { + queryBuilder.andWhere('nc.status = :status', { status: filters.status }); + } + + if (filters.category) { + queryBuilder.andWhere('nc.category ILIKE :category', { category: `%${filters.category}%` }); + } + + if (filters.detectionDateFrom) { + queryBuilder.andWhere('nc.detection_date >= :detectionDateFrom', { + detectionDateFrom: filters.detectionDateFrom, + }); + } + + if (filters.detectionDateTo) { + queryBuilder.andWhere('nc.detection_date <= :detectionDateTo', { + detectionDateTo: filters.detectionDateTo, + }); + } + + if (filters.dueDateFrom) { + queryBuilder.andWhere('nc.due_date >= :dueDateFrom', { dueDateFrom: filters.dueDateFrom }); + } + + if (filters.dueDateTo) { + queryBuilder.andWhere('nc.due_date <= :dueDateTo', { dueDateTo: filters.dueDateTo }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(nc.nc_number ILIKE :search OR nc.description ILIKE :search OR nc.category ILIKE :search)', + { search: `%${filters.search}%` } + ); + } + + queryBuilder + .orderBy('nc.severity', 'ASC') + .addOrderBy('nc.detection_date', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.ncRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + } + + async findWithDetails(ctx: ServiceContext, id: string): Promise { + return this.ncRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['inspection', 'correctiveActions', 'correctiveActions.responsible', 'createdBy', 'closedBy', 'verifiedBy'], + }); + } + + async findByLote(ctx: ServiceContext, loteId: string): Promise { + return this.ncRepository.find({ + where: { + tenantId: ctx.tenantId, + loteId, + } as FindOptionsWhere, + relations: ['correctiveActions'], + order: { detectionDate: 'DESC' }, + }); + } + + async findByInspection(ctx: ServiceContext, inspectionId: string): Promise { + return this.ncRepository.find({ + where: { + tenantId: ctx.tenantId, + inspectionId, + } as FindOptionsWhere, + relations: ['correctiveActions'], + order: { severity: 'ASC' }, + }); + } + + async findByNumber(ctx: ServiceContext, ncNumber: string): Promise { + return this.ncRepository.findOne({ + where: { + ncNumber, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['correctiveActions', 'createdBy'], + }); + } + + async create(ctx: ServiceContext, dto: CreateNonConformityDto): Promise { + // Calculate due date based on severity if not provided + let dueDate = dto.dueDate; + if (!dueDate) { + dueDate = new Date(); + dueDate.setDate(dueDate.getDate() + SLA_DAYS[dto.severity]); + } + + const nc = this.ncRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + inspectionId: dto.inspectionId, + loteId: dto.loteId, + ncNumber: this.generateNCNumber(), + detectionDate: dto.detectionDate, + category: dto.category, + severity: dto.severity, + description: dto.description, + rootCause: dto.rootCause, + photoUrl: dto.photoUrl, + contractorId: dto.contractorId, + dueDate, + status: 'open', + }); + + return this.ncRepository.save(nc); + } + + async update(ctx: ServiceContext, id: string, dto: UpdateNonConformityDto): Promise { + const nc = await this.findById(ctx, id); + if (!nc) { + return null; + } + + if (nc.status === 'closed' || nc.status === 'verified') { + throw new Error('Cannot update closed or verified non-conformities'); + } + + // If severity changes, recalculate due date + if (dto.severity && dto.severity !== nc.severity && !dto.dueDate) { + const newDueDate = new Date(nc.detectionDate); + newDueDate.setDate(newDueDate.getDate() + SLA_DAYS[dto.severity]); + dto.dueDate = newDueDate; + } + + Object.assign(nc, { + ...dto, + updatedById: ctx.userId, + }); + + return this.ncRepository.save(nc); + } + + async assignContractor(ctx: ServiceContext, id: string, contractorId: string): Promise { + const nc = await this.findById(ctx, id); + if (!nc) { + return null; + } + + if (nc.status === 'closed' || nc.status === 'verified') { + throw new Error('Cannot assign contractor to closed or verified non-conformities'); + } + + nc.contractorId = contractorId; + nc.updatedById = ctx.userId || ''; + + // Move to in_progress if open + if (nc.status === 'open') { + nc.status = 'in_progress'; + } + + return this.ncRepository.save(nc); + } + + async setRootCause(ctx: ServiceContext, id: string, rootCause: string): Promise { + const nc = await this.findById(ctx, id); + if (!nc) { + return null; + } + + if (nc.status === 'verified') { + throw new Error('Cannot modify verified non-conformities'); + } + + nc.rootCause = rootCause; + nc.updatedById = ctx.userId || ''; + + return this.ncRepository.save(nc); + } + + async close(ctx: ServiceContext, id: string, dto: CloseNonConformityDto): Promise { + const nc = await this.findWithDetails(ctx, id); + if (!nc) { + return null; + } + + if (nc.status !== 'in_progress') { + throw new Error('Can only close in-progress non-conformities'); + } + + // Check that all corrective actions are completed + const pendingActions = nc.correctiveActions?.filter( + a => a.status === 'pending' || a.status === 'in_progress' + ); + + if (pendingActions && pendingActions.length > 0) { + throw new Error('Cannot close non-conformity with pending corrective actions'); + } + + nc.status = 'closed'; + nc.closedAt = new Date(); + nc.closedById = ctx.userId || ''; + nc.closurePhotoUrl = dto.closurePhotoUrl || nc.closurePhotoUrl; + nc.closureNotes = dto.closureNotes || nc.closureNotes; + nc.updatedById = ctx.userId || ''; + + return this.ncRepository.save(nc); + } + + async verify(ctx: ServiceContext, id: string, dto: VerifyNonConformityDto): Promise { + const nc = await this.findWithDetails(ctx, id); + if (!nc) { + return null; + } + + if (nc.status !== 'closed') { + throw new Error('Can only verify closed non-conformities'); + } + + // Check that all corrective actions are verified + const unverifiedActions = nc.correctiveActions?.filter(a => a.status !== 'verified'); + + if (unverifiedActions && unverifiedActions.length > 0) { + throw new Error('Cannot verify non-conformity with unverified corrective actions'); + } + + nc.status = 'verified'; + nc.verifiedAt = new Date(); + nc.verifiedById = ctx.userId || ''; + if (dto.verificationNotes) { + nc.closureNotes = `${nc.closureNotes || ''}\n\n[VERIFICATION]: ${dto.verificationNotes}`; + } + nc.updatedById = ctx.userId || ''; + + return this.ncRepository.save(nc); + } + + async reopen(ctx: ServiceContext, id: string, reason: string): Promise { + const nc = await this.findById(ctx, id); + if (!nc) { + return null; + } + + if (nc.status !== 'closed' && nc.status !== 'verified') { + throw new Error('Can only reopen closed or verified non-conformities'); + } + + nc.status = 'in_progress'; + nc.closedAt = null as unknown as Date; + nc.closedById = null as unknown as string; + nc.verifiedAt = null as unknown as Date; + nc.verifiedById = null as unknown as string; + nc.closureNotes = `${nc.closureNotes || ''}\n\n[REOPENED]: ${reason}`; + nc.updatedById = ctx.userId || ''; + + // Extend due date + const newDueDate = new Date(); + newDueDate.setDate(newDueDate.getDate() + SLA_DAYS[nc.severity]); + nc.dueDate = newDueDate; + + return this.ncRepository.save(nc); + } + + async getOpenBySeverity(ctx: ServiceContext): Promise<{ severity: NCSeverity; count: number }[]> { + const result = await this.ncRepository + .createQueryBuilder('nc') + .select('nc.severity', 'severity') + .addSelect('COUNT(*)', 'count') + .where('nc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('nc.status IN (:...statuses)', { statuses: ['open', 'in_progress'] }) + .groupBy('nc.severity') + .getRawMany(); + + return result.map(r => ({ + severity: r.severity as NCSeverity, + count: parseInt(r.count, 10), + })); + } + + async getOverdueNCs(ctx: ServiceContext): Promise { + return this.ncRepository + .createQueryBuilder('nc') + .leftJoinAndSelect('nc.correctiveActions', 'ca') + .where('nc.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('nc.status IN (:...statuses)', { statuses: ['open', 'in_progress'] }) + .andWhere('nc.due_date < :now', { now: new Date() }) + .orderBy('nc.severity', 'ASC') + .addOrderBy('nc.due_date', 'ASC') + .getMany(); + } + + async getStatsByContractor(ctx: ServiceContext, contractorId: string): Promise<{ + total: number; + open: number; + inProgress: number; + closed: number; + verified: number; + bySeverity: Record; + avgResolutionDays: number; + }> { + const ncs = await this.ncRepository.find({ + where: { + tenantId: ctx.tenantId, + contractorId, + } as FindOptionsWhere, + }); + + const total = ncs.length; + const open = ncs.filter(n => n.status === 'open').length; + const inProgress = ncs.filter(n => n.status === 'in_progress').length; + const closed = ncs.filter(n => n.status === 'closed').length; + const verified = ncs.filter(n => n.status === 'verified').length; + + const bySeverity: Record = { + minor: ncs.filter(n => n.severity === 'minor').length, + major: ncs.filter(n => n.severity === 'major').length, + critical: ncs.filter(n => n.severity === 'critical').length, + }; + + // Calculate average resolution time for closed/verified NCs + const resolvedNCs = ncs.filter(n => n.closedAt); + let avgResolutionDays = 0; + if (resolvedNCs.length > 0) { + const totalDays = resolvedNCs.reduce((sum, nc) => { + const detection = new Date(nc.detectionDate); + const closure = new Date(nc.closedAt); + const days = Math.ceil((closure.getTime() - detection.getTime()) / (1000 * 60 * 60 * 24)); + return sum + days; + }, 0); + avgResolutionDays = Math.round(totalDays / resolvedNCs.length); + } + + return { total, open, inProgress, closed, verified, bySeverity, avgResolutionDays }; + } + + async getStatistics(ctx: ServiceContext): Promise<{ + total: number; + byStatus: Record; + bySeverity: Record; + overdue: number; + avgResolutionDays: number; + totalCorrectiveActions: number; + }> { + const ncs = await this.ncRepository.find({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + const actions = await this.actionRepository.find({ + where: { tenantId: ctx.tenantId } as FindOptionsWhere, + }); + + const now = new Date(); + const total = ncs.length; + + const byStatus: Record = { + open: ncs.filter(n => n.status === 'open').length, + in_progress: ncs.filter(n => n.status === 'in_progress').length, + closed: ncs.filter(n => n.status === 'closed').length, + verified: ncs.filter(n => n.status === 'verified').length, + }; + + const bySeverity: Record = { + minor: ncs.filter(n => n.severity === 'minor').length, + major: ncs.filter(n => n.severity === 'major').length, + critical: ncs.filter(n => n.severity === 'critical').length, + }; + + const overdue = ncs.filter( + n => (n.status === 'open' || n.status === 'in_progress') && n.dueDate && n.dueDate < now + ).length; + + // Calculate average resolution time + const resolvedNCs = ncs.filter(n => n.closedAt); + let avgResolutionDays = 0; + if (resolvedNCs.length > 0) { + const totalDays = resolvedNCs.reduce((sum, nc) => { + const detection = new Date(nc.detectionDate); + const closure = new Date(nc.closedAt); + const days = Math.ceil((closure.getTime() - detection.getTime()) / (1000 * 60 * 60 * 24)); + return sum + days; + }, 0); + avgResolutionDays = Math.round(totalDays / resolvedNCs.length); + } + + return { + total, + byStatus, + bySeverity, + overdue, + avgResolutionDays, + totalCorrectiveActions: actions.length, + }; + } +} diff --git a/src/modules/quality/services/ticket-assignment.service.ts b/src/modules/quality/services/ticket-assignment.service.ts new file mode 100644 index 0000000..817ef9a --- /dev/null +++ b/src/modules/quality/services/ticket-assignment.service.ts @@ -0,0 +1,472 @@ +/** + * TicketAssignmentService - Servicio de asignaciones de tickets + * + * Gestiona la asignación y reasignación de tickets a técnicos. + * + * @module Quality (MAI-009) + */ + +import { Repository, FindOptionsWhere } from 'typeorm'; +import { TicketAssignment, AssignmentStatus } from '../entities/ticket-assignment.entity'; +import { PostSaleTicket } from '../entities/post-sale-ticket.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateAssignmentDto { + technicianId: string; + scheduledDate?: Date; + scheduledTime?: string; +} + +export interface UpdateAssignmentDto { + scheduledDate?: Date; + scheduledTime?: string; + workNotes?: string; +} + +export interface ReassignDto { + newTechnicianId: string; + reassignmentReason: string; + scheduledDate?: Date; + scheduledTime?: string; +} + +export interface AssignmentFilters { + ticketId?: string; + technicianId?: string; + status?: AssignmentStatus; + isCurrent?: boolean; + scheduledDateFrom?: Date; + scheduledDateTo?: Date; +} + +export class TicketAssignmentService { + constructor( + private readonly assignmentRepository: Repository, + private readonly ticketRepository: Repository + ) {} + + async findByTicket(ctx: ServiceContext, ticketId: string): Promise { + return this.assignmentRepository.find({ + where: { + tenantId: ctx.tenantId, + ticketId, + } as FindOptionsWhere, + relations: ['technician', 'assignedBy'], + order: { assignedAt: 'DESC' }, + }); + } + + async findCurrentByTicket(ctx: ServiceContext, ticketId: string): Promise { + return this.assignmentRepository.findOne({ + where: { + tenantId: ctx.tenantId, + ticketId, + isCurrent: true, + } as FindOptionsWhere, + relations: ['technician', 'assignedBy'], + }); + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.assignmentRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + relations: ['technician', 'assignedBy', 'ticket'], + }); + } + + async findByTechnician( + ctx: ServiceContext, + technicianId: string, + filters: { status?: AssignmentStatus; isCurrent?: boolean } = {} + ): Promise { + const where: FindOptionsWhere = { + tenantId: ctx.tenantId, + technicianId, + }; + + if (filters.status) { + where.status = filters.status; + } + + if (filters.isCurrent !== undefined) { + where.isCurrent = filters.isCurrent; + } + + return this.assignmentRepository.find({ + where, + relations: ['ticket'], + order: { scheduledDate: 'ASC', assignedAt: 'DESC' }, + }); + } + + async findWithFilters( + ctx: ServiceContext, + filters: AssignmentFilters = {}, + page: number = 1, + limit: number = 20 + ): Promise<{ data: TicketAssignment[]; total: number; page: number; limit: number }> { + const skip = (page - 1) * limit; + + const queryBuilder = this.assignmentRepository + .createQueryBuilder('ta') + .leftJoinAndSelect('ta.ticket', 'ticket') + .leftJoinAndSelect('ta.technician', 'technician') + .leftJoinAndSelect('ta.assignedBy', 'assignedBy') + .where('ta.tenant_id = :tenantId', { tenantId: ctx.tenantId }); + + if (filters.ticketId) { + queryBuilder.andWhere('ta.ticket_id = :ticketId', { ticketId: filters.ticketId }); + } + + if (filters.technicianId) { + queryBuilder.andWhere('ta.technician_id = :technicianId', { technicianId: filters.technicianId }); + } + + if (filters.status) { + queryBuilder.andWhere('ta.status = :status', { status: filters.status }); + } + + if (filters.isCurrent !== undefined) { + queryBuilder.andWhere('ta.is_current = :isCurrent', { isCurrent: filters.isCurrent }); + } + + if (filters.scheduledDateFrom) { + queryBuilder.andWhere('ta.scheduled_date >= :scheduledDateFrom', { + scheduledDateFrom: filters.scheduledDateFrom, + }); + } + + if (filters.scheduledDateTo) { + queryBuilder.andWhere('ta.scheduled_date <= :scheduledDateTo', { + scheduledDateTo: filters.scheduledDateTo, + }); + } + + queryBuilder + .orderBy('ta.scheduled_date', 'ASC') + .addOrderBy('ta.assigned_at', 'DESC') + .skip(skip) + .take(limit); + + const [data, total] = await queryBuilder.getManyAndCount(); + + return { data, total, page, limit }; + } + + async assignTicket( + ctx: ServiceContext, + ticketId: string, + dto: CreateAssignmentDto + ): Promise { + // Validate ticket exists + const ticket = await this.ticketRepository.findOne({ + where: { + id: ticketId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (!ticket) { + throw new Error('Ticket not found'); + } + + if (ticket.status === 'closed' || ticket.status === 'cancelled') { + throw new Error('Cannot assign closed or cancelled tickets'); + } + + // Mark previous current assignment as not current + const currentAssignment = await this.findCurrentByTicket(ctx, ticketId); + if (currentAssignment) { + currentAssignment.isCurrent = false; + currentAssignment.status = 'reassigned'; + await this.assignmentRepository.save(currentAssignment); + } + + // Create new assignment + const assignment = this.assignmentRepository.create({ + tenantId: ctx.tenantId, + ticketId, + technicianId: dto.technicianId, + assignedAt: new Date(), + assignedById: ctx.userId || '', + scheduledDate: dto.scheduledDate, + scheduledTime: dto.scheduledTime, + status: 'assigned', + isCurrent: true, + }); + + const savedAssignment = await this.assignmentRepository.save(assignment); + + // Update ticket status + ticket.status = 'assigned'; + ticket.assignedAt = new Date(); + ticket.updatedById = ctx.userId || ''; + await this.ticketRepository.save(ticket); + + return savedAssignment; + } + + async acceptAssignment(ctx: ServiceContext, id: string): Promise { + const assignment = await this.findById(ctx, id); + if (!assignment) { + return null; + } + + if (!assignment.isCurrent) { + throw new Error('Can only accept current assignment'); + } + + if (assignment.status !== 'assigned') { + throw new Error('Can only accept assigned assignments'); + } + + assignment.status = 'accepted'; + assignment.acceptedAt = new Date(); + + return this.assignmentRepository.save(assignment); + } + + async startWork(ctx: ServiceContext, id: string): Promise { + const assignment = await this.findById(ctx, id); + if (!assignment) { + return null; + } + + if (!assignment.isCurrent) { + throw new Error('Can only start work on current assignment'); + } + + if (assignment.status !== 'assigned' && assignment.status !== 'accepted') { + throw new Error('Can only start work on assigned or accepted assignments'); + } + + assignment.status = 'in_progress'; + if (!assignment.acceptedAt) { + assignment.acceptedAt = new Date(); + } + + const savedAssignment = await this.assignmentRepository.save(assignment); + + // Update ticket status + const ticket = await this.ticketRepository.findOne({ + where: { + id: assignment.ticketId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (ticket) { + ticket.status = 'in_progress'; + ticket.updatedById = ctx.userId || ''; + await this.ticketRepository.save(ticket); + } + + return savedAssignment; + } + + async completeAssignment(ctx: ServiceContext, id: string, workNotes: string): Promise { + const assignment = await this.findById(ctx, id); + if (!assignment) { + return null; + } + + if (!assignment.isCurrent) { + throw new Error('Can only complete current assignment'); + } + + if (assignment.status !== 'in_progress') { + throw new Error('Can only complete in-progress assignments'); + } + + assignment.status = 'completed'; + assignment.completedAt = new Date(); + assignment.workNotes = workNotes; + + return this.assignmentRepository.save(assignment); + } + + async reassign(ctx: ServiceContext, ticketId: string, dto: ReassignDto): Promise { + // Get current assignment + const currentAssignment = await this.findCurrentByTicket(ctx, ticketId); + if (currentAssignment) { + currentAssignment.isCurrent = false; + currentAssignment.status = 'reassigned'; + currentAssignment.reassignmentReason = dto.reassignmentReason; + await this.assignmentRepository.save(currentAssignment); + } + + // Create new assignment + const newAssignment = this.assignmentRepository.create({ + tenantId: ctx.tenantId, + ticketId, + technicianId: dto.newTechnicianId, + assignedAt: new Date(), + assignedById: ctx.userId || '', + scheduledDate: dto.scheduledDate, + scheduledTime: dto.scheduledTime, + status: 'assigned', + isCurrent: true, + }); + + const savedAssignment = await this.assignmentRepository.save(newAssignment); + + // Update ticket status back to assigned + const ticket = await this.ticketRepository.findOne({ + where: { + id: ticketId, + tenantId: ctx.tenantId, + } as FindOptionsWhere, + }); + + if (ticket && ticket.status === 'in_progress') { + ticket.status = 'assigned'; + ticket.updatedById = ctx.userId || ''; + await this.ticketRepository.save(ticket); + } + + return savedAssignment; + } + + async updateSchedule( + ctx: ServiceContext, + id: string, + dto: UpdateAssignmentDto + ): Promise { + const assignment = await this.findById(ctx, id); + if (!assignment) { + return null; + } + + if (!assignment.isCurrent) { + throw new Error('Can only update current assignment'); + } + + if (assignment.status === 'completed' || assignment.status === 'reassigned') { + throw new Error('Cannot update completed or reassigned assignments'); + } + + if (dto.scheduledDate !== undefined) { + assignment.scheduledDate = dto.scheduledDate; + } + if (dto.scheduledTime !== undefined) { + assignment.scheduledTime = dto.scheduledTime; + } + if (dto.workNotes !== undefined) { + assignment.workNotes = dto.workNotes; + } + + return this.assignmentRepository.save(assignment); + } + + async getTechnicianSchedule( + ctx: ServiceContext, + technicianId: string, + dateFrom: Date, + dateTo: Date + ): Promise { + return this.assignmentRepository + .createQueryBuilder('ta') + .leftJoinAndSelect('ta.ticket', 'ticket') + .where('ta.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('ta.technician_id = :technicianId', { technicianId }) + .andWhere('ta.is_current = true') + .andWhere('ta.status IN (:...statuses)', { statuses: ['assigned', 'accepted', 'in_progress'] }) + .andWhere('ta.scheduled_date >= :dateFrom', { dateFrom }) + .andWhere('ta.scheduled_date <= :dateTo', { dateTo }) + .orderBy('ta.scheduled_date', 'ASC') + .addOrderBy('ta.scheduled_time', 'ASC') + .getMany(); + } + + async getTechnicianWorkload(ctx: ServiceContext, technicianId: string): Promise<{ + assigned: number; + accepted: number; + inProgress: number; + completedToday: number; + completedThisWeek: number; + averageCompletionTime: number; + }> { + const assignments = await this.assignmentRepository.find({ + where: { + tenantId: ctx.tenantId, + technicianId, + } as FindOptionsWhere, + }); + + const now = new Date(); + const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const startOfWeek = new Date(startOfDay); + startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); + + const currentAssignments = assignments.filter(a => a.isCurrent); + const assigned = currentAssignments.filter(a => a.status === 'assigned').length; + const accepted = currentAssignments.filter(a => a.status === 'accepted').length; + const inProgress = currentAssignments.filter(a => a.status === 'in_progress').length; + + const completedToday = assignments.filter( + a => a.status === 'completed' && a.completedAt && a.completedAt >= startOfDay + ).length; + + const completedThisWeek = assignments.filter( + a => a.status === 'completed' && a.completedAt && a.completedAt >= startOfWeek + ).length; + + // Calculate average completion time (in hours) + const completedWithTime = assignments.filter( + a => a.status === 'completed' && a.completedAt && a.acceptedAt + ); + + let averageCompletionTime = 0; + if (completedWithTime.length > 0) { + const totalHours = completedWithTime.reduce((sum, a) => { + const accepted = new Date(a.acceptedAt).getTime(); + const completed = new Date(a.completedAt).getTime(); + return sum + (completed - accepted) / (1000 * 60 * 60); + }, 0); + averageCompletionTime = Math.round((totalHours / completedWithTime.length) * 100) / 100; + } + + return { + assigned, + accepted, + inProgress, + completedToday, + completedThisWeek, + averageCompletionTime, + }; + } + + async getAssignmentHistory(ctx: ServiceContext, ticketId: string): Promise<{ + assignment: TicketAssignment; + duration?: number; + }[]> { + const assignments = await this.findByTicket(ctx, ticketId); + + return assignments.map((assignment, index) => { + let duration: number | undefined; + + if (assignment.completedAt && assignment.acceptedAt) { + // Time from acceptance to completion + duration = Math.round( + (assignment.completedAt.getTime() - assignment.acceptedAt.getTime()) / (1000 * 60 * 60) + ); + } else if (assignment.status === 'reassigned' && assignments[index + 1]) { + // Time until reassignment + const nextAssignment = assignments[index + 1]; + const startTime = assignment.acceptedAt || assignment.assignedAt; + duration = Math.round( + (nextAssignment.assignedAt.getTime() - startTime.getTime()) / (1000 * 60 * 60) + ); + } + + return { assignment, duration }; + }); + } +}