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:
parent
6a64edf4c8
commit
e5f63495e8
380
src/modules/quality/services/corrective-action.service.ts
Normal file
380
src/modules/quality/services/corrective-action.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
358
src/modules/quality/services/inspection-result.service.ts
Normal file
358
src/modules/quality/services/inspection-result.service.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
518
src/modules/quality/services/non-conformity.service.ts
Normal file
518
src/modules/quality/services/non-conformity.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
472
src/modules/quality/services/ticket-assignment.service.ts
Normal file
472
src/modules/quality/services/ticket-assignment.service.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user