diff --git a/src/modules/quality/controllers/inspection.controller.ts b/src/modules/quality/controllers/inspection.controller.ts index d98ba82..c246f17 100644 --- a/src/modules/quality/controllers/inspection.controller.ts +++ b/src/modules/quality/controllers/inspection.controller.ts @@ -19,7 +19,11 @@ import { AuthService } from '../../auth/services/auth.service'; import { User } from '../../core/entities/user.entity'; import { Tenant } from '../../core/entities/tenant.entity'; import { RefreshToken } from '../../auth/entities/refresh-token.entity'; -import { ServiceContext } from '../../../shared/services/base.service'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} export function createInspectionController(dataSource: DataSource): Router { const router = Router(); @@ -73,7 +77,7 @@ export function createInspectionController(dataSource: DataSource): Router { const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const result = await service.findWithFilters(getContext(req), filters, page, limit); - res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + res.status(200).json({ success: true, data: result.data, pagination: { total: result.total, page: result.page, limit: result.limit } }); } catch (error) { next(error); } diff --git a/src/modules/quality/controllers/ticket.controller.ts b/src/modules/quality/controllers/ticket.controller.ts index 2ad5364..ec52b0a 100644 --- a/src/modules/quality/controllers/ticket.controller.ts +++ b/src/modules/quality/controllers/ticket.controller.ts @@ -17,7 +17,11 @@ import { AuthService } from '../../auth/services/auth.service'; import { User } from '../../core/entities/user.entity'; import { Tenant } from '../../core/entities/tenant.entity'; import { RefreshToken } from '../../auth/entities/refresh-token.entity'; -import { ServiceContext } from '../../../shared/services/base.service'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} export function createTicketController(dataSource: DataSource): Router { const router = Router(); @@ -72,7 +76,7 @@ export function createTicketController(dataSource: DataSource): Router { const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const result = await service.findWithFilters(getContext(req), filters, page, limit); - res.status(200).json({ success: true, data: result.data, pagination: result.meta }); + res.status(200).json({ success: true, data: result.data, pagination: { total: result.total, page: result.page, limit: result.limit } }); } catch (error) { next(error); } diff --git a/src/modules/quality/entities/checklist-item.entity.ts b/src/modules/quality/entities/checklist-item.entity.ts index bb68941..4472105 100644 --- a/src/modules/quality/entities/checklist-item.entity.ts +++ b/src/modules/quality/entities/checklist-item.entity.ts @@ -19,7 +19,7 @@ import { import { Tenant } from '../../core/entities/tenant.entity'; import { Checklist } from './checklist.entity'; -@Entity({ schema: 'quality', name: 'checklist_items' }) +@Entity({ schema: 'construction', name: 'checklist_items' }) @Index(['tenantId', 'checklistId', 'sequenceNumber']) export class ChecklistItem { @PrimaryGeneratedColumn('uuid') diff --git a/src/modules/quality/entities/checklist.entity.ts b/src/modules/quality/entities/checklist.entity.ts index acefd6f..2ddb373 100644 --- a/src/modules/quality/entities/checklist.entity.ts +++ b/src/modules/quality/entities/checklist.entity.ts @@ -24,7 +24,7 @@ import { Inspection } from './inspection.entity'; export type ChecklistStage = 'foundation' | 'structure' | 'installations' | 'finishes' | 'delivery' | 'custom'; -@Entity({ schema: 'quality', name: 'checklists' }) +@Entity({ schema: 'construction', name: 'checklists' }) @Index(['tenantId', 'code'], { unique: true }) export class Checklist { @PrimaryGeneratedColumn('uuid') diff --git a/src/modules/quality/entities/corrective-action.entity.ts b/src/modules/quality/entities/corrective-action.entity.ts index 9afb879..27f68e0 100644 --- a/src/modules/quality/entities/corrective-action.entity.ts +++ b/src/modules/quality/entities/corrective-action.entity.ts @@ -23,7 +23,7 @@ import { NonConformity } from './non-conformity.entity'; export type ActionType = 'corrective' | 'preventive' | 'improvement'; export type ActionStatus = 'pending' | 'in_progress' | 'completed' | 'verified'; -@Entity({ schema: 'quality', name: 'corrective_actions' }) +@Entity({ schema: 'construction', name: 'acciones_correctivas' }) @Index(['tenantId', 'nonConformityId']) export class CorrectiveAction { @PrimaryGeneratedColumn('uuid') @@ -32,7 +32,7 @@ export class CorrectiveAction { @Column({ name: 'tenant_id', type: 'uuid' }) tenantId: string; - @Column({ name: 'non_conformity_id', type: 'uuid' }) + @Column({ name: 'no_conformidad_id', type: 'uuid' }) nonConformityId: string; @Column({ name: 'action_type', type: 'varchar', length: 20 }) @@ -83,7 +83,7 @@ export class CorrectiveAction { tenant: Tenant; @ManyToOne(() => NonConformity, (nc) => nc.correctiveActions, { onDelete: 'CASCADE' }) - @JoinColumn({ name: 'non_conformity_id' }) + @JoinColumn({ name: 'no_conformidad_id' }) nonConformity: NonConformity; @ManyToOne(() => User) diff --git a/src/modules/quality/entities/inspection-result.entity.ts b/src/modules/quality/entities/inspection-result.entity.ts index 79a938f..83c8fec 100644 --- a/src/modules/quality/entities/inspection-result.entity.ts +++ b/src/modules/quality/entities/inspection-result.entity.ts @@ -22,7 +22,7 @@ import { ChecklistItem } from './checklist-item.entity'; export type InspectionResultStatus = 'pending' | 'passed' | 'failed' | 'not_applicable'; -@Entity({ schema: 'quality', name: 'inspection_results' }) +@Entity({ schema: 'construction', name: 'inspeccion_resultados' }) @Index(['tenantId', 'inspectionId', 'checklistItemId'], { unique: true }) export class InspectionResult { @PrimaryGeneratedColumn('uuid') diff --git a/src/modules/quality/entities/inspection.entity.ts b/src/modules/quality/entities/inspection.entity.ts index a0c9c30..f805913 100644 --- a/src/modules/quality/entities/inspection.entity.ts +++ b/src/modules/quality/entities/inspection.entity.ts @@ -25,7 +25,7 @@ import { NonConformity } from './non-conformity.entity'; export type InspectionStatus = 'pending' | 'in_progress' | 'completed' | 'approved' | 'rejected'; -@Entity({ schema: 'quality', name: 'inspections' }) +@Entity({ schema: 'construction', name: 'inspecciones' }) @Index(['tenantId', 'inspectionNumber'], { unique: true }) export class Inspection { @PrimaryGeneratedColumn('uuid') diff --git a/src/modules/quality/entities/non-conformity.entity.ts b/src/modules/quality/entities/non-conformity.entity.ts index fac8d63..839af3a 100644 --- a/src/modules/quality/entities/non-conformity.entity.ts +++ b/src/modules/quality/entities/non-conformity.entity.ts @@ -25,7 +25,7 @@ import { CorrectiveAction } from './corrective-action.entity'; export type NCSeverity = 'minor' | 'major' | 'critical'; export type NCStatus = 'open' | 'in_progress' | 'closed' | 'verified'; -@Entity({ schema: 'quality', name: 'non_conformities' }) +@Entity({ schema: 'construction', name: 'no_conformidades' }) @Index(['tenantId', 'ncNumber'], { unique: true }) export class NonConformity { @PrimaryGeneratedColumn('uuid') diff --git a/src/modules/quality/entities/post-sale-ticket.entity.ts b/src/modules/quality/entities/post-sale-ticket.entity.ts index 127ad69..d5623c2 100644 --- a/src/modules/quality/entities/post-sale-ticket.entity.ts +++ b/src/modules/quality/entities/post-sale-ticket.entity.ts @@ -25,7 +25,7 @@ export type TicketPriority = 'urgent' | 'high' | 'medium' | 'low'; export type TicketStatus = 'created' | 'assigned' | 'in_progress' | 'resolved' | 'closed' | 'cancelled'; export type TicketCategory = 'plumbing' | 'electrical' | 'finishes' | 'carpentry' | 'structural' | 'other'; -@Entity({ schema: 'quality', name: 'post_sale_tickets' }) +@Entity({ schema: 'construction', name: 'tickets_postventa' }) @Index(['tenantId', 'ticketNumber'], { unique: true }) export class PostSaleTicket { @PrimaryGeneratedColumn('uuid') diff --git a/src/modules/quality/entities/ticket-assignment.entity.ts b/src/modules/quality/entities/ticket-assignment.entity.ts index 0086afe..89fc5b5 100644 --- a/src/modules/quality/entities/ticket-assignment.entity.ts +++ b/src/modules/quality/entities/ticket-assignment.entity.ts @@ -22,7 +22,7 @@ import { PostSaleTicket } from './post-sale-ticket.entity'; export type AssignmentStatus = 'assigned' | 'accepted' | 'in_progress' | 'completed' | 'reassigned'; -@Entity({ schema: 'quality', name: 'ticket_assignments' }) +@Entity({ schema: 'construction', name: 'ticket_asignaciones' }) @Index(['tenantId', 'ticketId', 'technicianId']) export class TicketAssignment { @PrimaryGeneratedColumn('uuid') diff --git a/src/modules/quality/services/checklist.service.ts b/src/modules/quality/services/checklist.service.ts new file mode 100644 index 0000000..deab64f --- /dev/null +++ b/src/modules/quality/services/checklist.service.ts @@ -0,0 +1,409 @@ +/** + * ChecklistService - Servicio de plantillas de inspección + * + * Gestión de checklists y sus items para inspecciones de calidad. + * + * @module Quality (MAI-009) + */ + +import { Repository } from 'typeorm'; +import { Checklist, ChecklistStage } from '../entities/checklist.entity'; +import { ChecklistItem } from '../entities/checklist-item.entity'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface CreateChecklistDto { + code: string; + name: string; + description?: string; + stage: ChecklistStage; + prototypeId?: string; +} + +export interface UpdateChecklistDto { + name?: string; + description?: string; + stage?: ChecklistStage; + prototypeId?: string; + isActive?: boolean; +} + +export interface CreateChecklistItemDto { + sequenceNumber: number; + category: string; + description: string; + isCritical?: boolean; + requiresPhoto?: boolean; + acceptanceCriteria?: string; +} + +export interface UpdateChecklistItemDto { + sequenceNumber?: number; + category?: string; + description?: string; + isCritical?: boolean; + requiresPhoto?: boolean; + acceptanceCriteria?: string; + isActive?: boolean; +} + +export interface ChecklistFilters { + stage?: ChecklistStage; + prototypeId?: string; + isActive?: boolean; + search?: string; + page?: number; + limit?: number; +} + +export class ChecklistService { + constructor( + private readonly checklistRepository: Repository, + private readonly itemRepository: Repository, + ) {} + + async findAll( + ctx: ServiceContext, + filters: ChecklistFilters = {}, + ): Promise<{ data: Checklist[]; total: number; page: number; limit: number }> { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.checklistRepository + .createQueryBuilder('cl') + .leftJoinAndSelect('cl.items', 'items', 'items.is_active = true') + .where('cl.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cl.deleted_at IS NULL'); + + if (filters.stage) { + queryBuilder.andWhere('cl.stage = :stage', { stage: filters.stage }); + } + + if (filters.prototypeId) { + queryBuilder.andWhere('cl.prototype_id = :prototypeId', { + prototypeId: filters.prototypeId, + }); + } + + if (filters.isActive !== undefined) { + queryBuilder.andWhere('cl.is_active = :isActive', { isActive: filters.isActive }); + } + + if (filters.search) { + queryBuilder.andWhere( + '(cl.name ILIKE :search OR cl.code ILIKE :search OR cl.description ILIKE :search)', + { search: `%${filters.search}%` }, + ); + } + + const [data, total] = await queryBuilder + .orderBy('cl.stage', 'ASC') + .addOrderBy('cl.name', 'ASC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { data, total, page, limit }; + } + + async findById(ctx: ServiceContext, id: string): Promise { + return this.checklistRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + }); + } + + async findWithItems(ctx: ServiceContext, id: string): Promise { + return this.checklistRepository.findOne({ + where: { + id, + tenantId: ctx.tenantId, + }, + relations: ['items', 'createdBy'], + order: { items: { sequenceNumber: 'ASC' } }, + }); + } + + async findByCode(ctx: ServiceContext, code: string): Promise { + return this.checklistRepository.findOne({ + where: { + code, + tenantId: ctx.tenantId, + }, + }); + } + + async findByStage(ctx: ServiceContext, stage: ChecklistStage): Promise { + return this.checklistRepository + .createQueryBuilder('cl') + .leftJoinAndSelect('cl.items', 'items', 'items.is_active = true') + .where('cl.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('cl.deleted_at IS NULL') + .andWhere('cl.is_active = true') + .andWhere('cl.stage = :stage', { stage }) + .orderBy('cl.name', 'ASC') + .addOrderBy('items.sequence_number', 'ASC') + .getMany(); + } + + async create(ctx: ServiceContext, dto: CreateChecklistDto): Promise { + // Check code uniqueness + const existing = await this.findByCode(ctx, dto.code); + if (existing) { + throw new Error(`Checklist with code ${dto.code} already exists`); + } + + const checklist = this.checklistRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + code: dto.code, + name: dto.name, + description: dto.description, + stage: dto.stage, + prototypeId: dto.prototypeId, + isActive: true, + version: 1, + }); + + return this.checklistRepository.save(checklist); + } + + async update( + ctx: ServiceContext, + id: string, + dto: UpdateChecklistDto, + ): Promise { + const checklist = await this.findById(ctx, id); + if (!checklist) { + return null; + } + + Object.assign(checklist, { + ...dto, + updatedById: ctx.userId, + }); + + return this.checklistRepository.save(checklist); + } + + async incrementVersion(ctx: ServiceContext, id: string): Promise { + const checklist = await this.findById(ctx, id); + if (!checklist) { + return null; + } + + checklist.version += 1; + if (ctx.userId) { + checklist.updatedById = ctx.userId; + } + + return this.checklistRepository.save(checklist); + } + + async activate(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { isActive: true }); + } + + async deactivate(ctx: ServiceContext, id: string): Promise { + return this.update(ctx, id, { isActive: false }); + } + + async duplicate(ctx: ServiceContext, id: string, newCode: string, newName: string): Promise { + const original = await this.findWithItems(ctx, id); + if (!original) { + return null; + } + + // Check new code uniqueness + const existing = await this.findByCode(ctx, newCode); + if (existing) { + throw new Error(`Checklist with code ${newCode} already exists`); + } + + // Create new checklist + const newChecklist = this.checklistRepository.create({ + tenantId: ctx.tenantId, + createdById: ctx.userId, + code: newCode, + name: newName, + description: original.description, + stage: original.stage, + prototypeId: original.prototypeId, + isActive: true, + version: 1, + }); + + const savedChecklist = await this.checklistRepository.save(newChecklist); + + // Duplicate items + if (original.items && original.items.length > 0) { + const newItems = original.items.map((item) => + this.itemRepository.create({ + tenantId: ctx.tenantId, + checklistId: savedChecklist.id, + sequenceNumber: item.sequenceNumber, + category: item.category, + description: item.description, + isCritical: item.isCritical, + requiresPhoto: item.requiresPhoto, + acceptanceCriteria: item.acceptanceCriteria, + isActive: true, + }), + ); + await this.itemRepository.save(newItems); + } + + return this.findWithItems(ctx, savedChecklist.id); + } + + async softDelete(ctx: ServiceContext, id: string): Promise { + const checklist = await this.findById(ctx, id); + if (!checklist) { + return false; + } + + const updateData: { deletedAt: Date; deletedById?: string } = { deletedAt: new Date() }; + if (ctx.userId) { + updateData.deletedById = ctx.userId; + } + await this.checklistRepository.update( + { id, tenantId: ctx.tenantId }, + updateData, + ); + + return true; + } + + // Item management + + async addItem( + ctx: ServiceContext, + checklistId: string, + dto: CreateChecklistItemDto, + ): Promise { + const checklist = await this.findById(ctx, checklistId); + if (!checklist) { + throw new Error('Checklist not found'); + } + + const item = this.itemRepository.create({ + tenantId: ctx.tenantId, + checklistId, + sequenceNumber: dto.sequenceNumber, + category: dto.category, + description: dto.description, + isCritical: dto.isCritical ?? false, + requiresPhoto: dto.requiresPhoto ?? false, + acceptanceCriteria: dto.acceptanceCriteria, + isActive: true, + }); + + const savedItem = await this.itemRepository.save(item); + + // Increment checklist version + await this.incrementVersion(ctx, checklistId); + + return savedItem; + } + + async updateItem( + ctx: ServiceContext, + itemId: string, + dto: UpdateChecklistItemDto, + ): Promise { + const item = await this.itemRepository.findOne({ + where: { id: itemId, tenantId: ctx.tenantId }, + }); + + if (!item) { + return null; + } + + Object.assign(item, dto); + + const savedItem = await this.itemRepository.save(item); + + // Increment checklist version + await this.incrementVersion(ctx, item.checklistId); + + return savedItem; + } + + async removeItem(ctx: ServiceContext, itemId: string): Promise { + const item = await this.itemRepository.findOne({ + where: { id: itemId, tenantId: ctx.tenantId }, + }); + + if (!item) { + return false; + } + + await this.itemRepository.delete({ id: itemId, tenantId: ctx.tenantId }); + + // Increment checklist version + await this.incrementVersion(ctx, item.checklistId); + + return true; + } + + async reorderItems( + ctx: ServiceContext, + checklistId: string, + itemOrders: { itemId: string; sequenceNumber: number }[], + ): Promise { + const checklist = await this.findById(ctx, checklistId); + if (!checklist) { + return false; + } + + for (const order of itemOrders) { + await this.itemRepository.update( + { id: order.itemId, tenantId: ctx.tenantId, checklistId }, + { sequenceNumber: order.sequenceNumber }, + ); + } + + await this.incrementVersion(ctx, checklistId); + + return true; + } + + async getStatistics(ctx: ServiceContext): Promise<{ + totalChecklists: number; + activeChecklists: number; + byStage: Record; + totalItems: number; + criticalItems: number; + }> { + const checklists = await this.checklistRepository.find({ + where: { tenantId: ctx.tenantId, deletedAt: undefined }, + relations: ['items'], + }); + + const byStage: Record = {}; + let totalItems = 0; + let criticalItems = 0; + + for (const cl of checklists) { + byStage[cl.stage] = (byStage[cl.stage] || 0) + 1; + if (cl.items) { + totalItems += cl.items.length; + criticalItems += cl.items.filter((i) => i.isCritical).length; + } + } + + return { + totalChecklists: checklists.length, + activeChecklists: checklists.filter((c) => c.isActive).length, + byStage, + totalItems, + criticalItems, + }; + } +} diff --git a/src/modules/quality/services/index.ts b/src/modules/quality/services/index.ts index c401230..5963861 100644 --- a/src/modules/quality/services/index.ts +++ b/src/modules/quality/services/index.ts @@ -3,5 +3,6 @@ * @module Quality */ +export * from './checklist.service'; export * from './inspection.service'; export * from './ticket.service'; diff --git a/src/modules/quality/services/inspection.service.ts b/src/modules/quality/services/inspection.service.ts index d10f5d0..b6e642b 100644 --- a/src/modules/quality/services/inspection.service.ts +++ b/src/modules/quality/services/inspection.service.ts @@ -11,7 +11,11 @@ import { Inspection, InspectionStatus } from '../entities/inspection.entity'; import { InspectionResult } from '../entities/inspection-result.entity'; import { NonConformity } from '../entities/non-conformity.entity'; import { Checklist } from '../entities/checklist.entity'; -import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} export interface CreateInspectionDto { checklistId: string; @@ -66,7 +70,7 @@ export class InspectionService { filters: InspectionFilters = {}, page: number = 1, limit: number = 20 - ): Promise> { + ): Promise<{ data: Inspection[]; total: number; page: number; limit: number }> { const skip = (page - 1) * limit; const queryBuilder = this.inspectionRepository @@ -106,15 +110,7 @@ export class InspectionService { const [data, total] = await queryBuilder.getManyAndCount(); - return { - data, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }; + return { data, total, page, limit }; } async findById(ctx: ServiceContext, id: string): Promise { diff --git a/src/modules/quality/services/ticket.service.ts b/src/modules/quality/services/ticket.service.ts index 48466f8..8ef6342 100644 --- a/src/modules/quality/services/ticket.service.ts +++ b/src/modules/quality/services/ticket.service.ts @@ -9,7 +9,11 @@ import { Repository, FindOptionsWhere, LessThan } from 'typeorm'; import { PostSaleTicket, TicketStatus, TicketPriority, TicketCategory } from '../entities/post-sale-ticket.entity'; import { TicketAssignment } from '../entities/ticket-assignment.entity'; -import { ServiceContext, PaginatedResult } from '../../../shared/services/base.service'; + +interface ServiceContext { + tenantId: string; + userId?: string; +} export interface CreateTicketDto { loteId: string; @@ -82,7 +86,7 @@ export class TicketService { filters: TicketFilters = {}, page: number = 1, limit: number = 20 - ): Promise> { + ): Promise<{ data: PostSaleTicket[]; total: number; page: number; limit: number }> { const skip = (page - 1) * limit; const queryBuilder = this.ticketRepository @@ -137,15 +141,7 @@ export class TicketService { const [data, total] = await queryBuilder.getManyAndCount(); - return { - data, - meta: { - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }, - }; + return { data, total, page, limit }; } async findById(ctx: ServiceContext, id: string): Promise {