feat(quality): Add missing services for quality module

Implement four new services for the quality module (MAI-009):
- CorrectiveActionService: CAPA (Corrective and Preventive Actions)
  workflow with create, start, complete, verify, and reopen operations
- NonConformityService: NC lifecycle management with severity-based
  SLA, contractor assignment, and status workflow
- InspectionResultService: Recording and batch recording of inspection
  results with summary statistics
- TicketAssignmentService: Ticket assignment, reassignment, scheduling,
  and technician workload tracking

All services follow the ServiceContext pattern with tenantId.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-01-30 19:27:35 -06:00
parent 6a64edf4c8
commit e5f63495e8
5 changed files with 1749 additions and 1 deletions

View File

@ -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<CorrectiveAction>,
private readonly nonConformityRepository: Repository<NonConformity>
) {}
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<CorrectiveAction | null> {
return this.actionRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<CorrectiveAction>,
});
}
async findWithDetails(ctx: ServiceContext, id: string): Promise<CorrectiveAction | null> {
return this.actionRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<CorrectiveAction>,
relations: ['nonConformity', 'responsible', 'createdBy', 'verifiedBy'],
});
}
async findByNonConformity(ctx: ServiceContext, nonConformityId: string): Promise<CorrectiveAction[]> {
return this.actionRepository.find({
where: {
tenantId: ctx.tenantId,
nonConformityId,
} as FindOptionsWhere<CorrectiveAction>,
relations: ['responsible', 'verifiedBy'],
order: { dueDate: 'ASC' },
});
}
async create(ctx: ServiceContext, dto: CreateCorrectiveActionDto): Promise<CorrectiveAction> {
// Validate non-conformity exists
const nc = await this.nonConformityRepository.findOne({
where: {
id: dto.nonConformityId,
tenantId: ctx.tenantId,
} as FindOptionsWhere<NonConformity>,
});
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<CorrectiveAction | null> {
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<CorrectiveAction | null> {
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<CorrectiveAction | null> {
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<CorrectiveAction | null> {
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<CorrectiveAction | null> {
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<boolean> {
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<CorrectiveAction[]> {
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<CorrectiveAction>,
});
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<void> {
const actions = await this.actionRepository.find({
where: {
tenantId: ctx.tenantId,
nonConformityId,
} as FindOptionsWhere<CorrectiveAction>,
});
// 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<NonConformity>,
});
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);
}
}
}
}

View File

@ -1,8 +1,28 @@
/** /**
* Quality Services Index * Quality Services Index
* @module Quality *
* Barrel file exporting all quality module services.
*
* @module Quality (MAI-009)
*/ */
// Checklist management
export * from './checklist.service'; export * from './checklist.service';
// Inspection management
export * from './inspection.service'; 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'; export * from './ticket.service';
// Ticket assignments
export * from './ticket-assignment.service';

View File

@ -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<InspectionResult>,
private readonly inspectionRepository: Repository<Inspection>,
private readonly checklistItemRepository: Repository<ChecklistItem>
) {}
async findByInspection(ctx: ServiceContext, inspectionId: string): Promise<InspectionResult[]> {
return this.resultRepository.find({
where: {
tenantId: ctx.tenantId,
inspectionId,
} as FindOptionsWhere<InspectionResult>,
relations: ['checklistItem'],
order: { createdAt: 'ASC' },
});
}
async findById(ctx: ServiceContext, id: string): Promise<InspectionResult | null> {
return this.resultRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<InspectionResult>,
relations: ['checklistItem', 'inspection'],
});
}
async findByInspectionAndItem(
ctx: ServiceContext,
inspectionId: string,
checklistItemId: string
): Promise<InspectionResult | null> {
return this.resultRepository.findOne({
where: {
tenantId: ctx.tenantId,
inspectionId,
checklistItemId,
} as FindOptionsWhere<InspectionResult>,
relations: ['checklistItem'],
});
}
async recordResult(
ctx: ServiceContext,
inspectionId: string,
dto: RecordInspectionResultDto
): Promise<InspectionResult> {
// Validate inspection exists and is in valid state
const inspection = await this.inspectionRepository.findOne({
where: {
id: inspectionId,
tenantId: ctx.tenantId,
} as FindOptionsWhere<Inspection>,
});
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<ChecklistItem>,
});
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<InspectionResult[]> {
// Validate inspection
const inspection = await this.inspectionRepository.findOne({
where: {
id: inspectionId,
tenantId: ctx.tenantId,
} as FindOptionsWhere<Inspection>,
});
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<InspectionResult | null> {
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<Inspection>,
});
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<boolean> {
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<Inspection>,
});
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<Inspection>,
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<InspectionResult>,
});
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<InspectionResult[]> {
return this.resultRepository.find({
where: {
tenantId: ctx.tenantId,
inspectionId,
result: 'failed' as InspectionResultStatus,
} as FindOptionsWhere<InspectionResult>,
relations: ['checklistItem'],
order: { createdAt: 'ASC' },
});
}
async getCriticalFailures(ctx: ServiceContext, inspectionId: string): Promise<InspectionResult[]> {
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<string, { total: number; passed: number; failed: number }>();
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<InspectionResult[]> {
return this.resultRepository.find({
where: {
tenantId: ctx.tenantId,
inspectionId,
} as FindOptionsWhere<InspectionResult>,
relations: ['checklistItem'],
order: { createdAt: 'ASC' },
}).then(results => results.filter(r => r.photoUrl));
}
}

View File

@ -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<NCSeverity, number> = {
critical: 3,
major: 7,
minor: 15,
};
export class NonConformityService {
constructor(
private readonly ncRepository: Repository<NonConformity>,
private readonly actionRepository: Repository<CorrectiveAction>
) {}
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<NonConformity | null> {
return this.ncRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<NonConformity>,
});
}
async findWithDetails(ctx: ServiceContext, id: string): Promise<NonConformity | null> {
return this.ncRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<NonConformity>,
relations: ['inspection', 'correctiveActions', 'correctiveActions.responsible', 'createdBy', 'closedBy', 'verifiedBy'],
});
}
async findByLote(ctx: ServiceContext, loteId: string): Promise<NonConformity[]> {
return this.ncRepository.find({
where: {
tenantId: ctx.tenantId,
loteId,
} as FindOptionsWhere<NonConformity>,
relations: ['correctiveActions'],
order: { detectionDate: 'DESC' },
});
}
async findByInspection(ctx: ServiceContext, inspectionId: string): Promise<NonConformity[]> {
return this.ncRepository.find({
where: {
tenantId: ctx.tenantId,
inspectionId,
} as FindOptionsWhere<NonConformity>,
relations: ['correctiveActions'],
order: { severity: 'ASC' },
});
}
async findByNumber(ctx: ServiceContext, ncNumber: string): Promise<NonConformity | null> {
return this.ncRepository.findOne({
where: {
ncNumber,
tenantId: ctx.tenantId,
} as FindOptionsWhere<NonConformity>,
relations: ['correctiveActions', 'createdBy'],
});
}
async create(ctx: ServiceContext, dto: CreateNonConformityDto): Promise<NonConformity> {
// 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<NonConformity | null> {
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<NonConformity | null> {
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<NonConformity | null> {
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<NonConformity | null> {
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<NonConformity | null> {
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<NonConformity | null> {
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<NonConformity[]> {
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<NCSeverity, number>;
avgResolutionDays: number;
}> {
const ncs = await this.ncRepository.find({
where: {
tenantId: ctx.tenantId,
contractorId,
} as FindOptionsWhere<NonConformity>,
});
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<NCSeverity, number> = {
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<NCStatus, number>;
bySeverity: Record<NCSeverity, number>;
overdue: number;
avgResolutionDays: number;
totalCorrectiveActions: number;
}> {
const ncs = await this.ncRepository.find({
where: { tenantId: ctx.tenantId } as FindOptionsWhere<NonConformity>,
});
const actions = await this.actionRepository.find({
where: { tenantId: ctx.tenantId } as FindOptionsWhere<CorrectiveAction>,
});
const now = new Date();
const total = ncs.length;
const byStatus: Record<NCStatus, number> = {
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<NCSeverity, number> = {
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,
};
}
}

View File

@ -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<TicketAssignment>,
private readonly ticketRepository: Repository<PostSaleTicket>
) {}
async findByTicket(ctx: ServiceContext, ticketId: string): Promise<TicketAssignment[]> {
return this.assignmentRepository.find({
where: {
tenantId: ctx.tenantId,
ticketId,
} as FindOptionsWhere<TicketAssignment>,
relations: ['technician', 'assignedBy'],
order: { assignedAt: 'DESC' },
});
}
async findCurrentByTicket(ctx: ServiceContext, ticketId: string): Promise<TicketAssignment | null> {
return this.assignmentRepository.findOne({
where: {
tenantId: ctx.tenantId,
ticketId,
isCurrent: true,
} as FindOptionsWhere<TicketAssignment>,
relations: ['technician', 'assignedBy'],
});
}
async findById(ctx: ServiceContext, id: string): Promise<TicketAssignment | null> {
return this.assignmentRepository.findOne({
where: {
id,
tenantId: ctx.tenantId,
} as FindOptionsWhere<TicketAssignment>,
relations: ['technician', 'assignedBy', 'ticket'],
});
}
async findByTechnician(
ctx: ServiceContext,
technicianId: string,
filters: { status?: AssignmentStatus; isCurrent?: boolean } = {}
): Promise<TicketAssignment[]> {
const where: FindOptionsWhere<TicketAssignment> = {
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<TicketAssignment> {
// Validate ticket exists
const ticket = await this.ticketRepository.findOne({
where: {
id: ticketId,
tenantId: ctx.tenantId,
} as FindOptionsWhere<PostSaleTicket>,
});
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<TicketAssignment | null> {
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<TicketAssignment | null> {
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<PostSaleTicket>,
});
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<TicketAssignment | null> {
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<TicketAssignment> {
// 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<PostSaleTicket>,
});
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<TicketAssignment | null> {
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<TicketAssignment[]> {
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<TicketAssignment>,
});
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 };
});
}
}