# ET-CAL-001: Backend - Control de Calidad **Épica:** MAI-006 - Control de Calidad en Construcción **Módulo:** Backend API (NestJS) **Responsable Técnico:** Backend Team **Fecha:** 2025-12-06 **Versión:** 1.0 --- ## 1. Objetivo Técnico Implementar el backend del módulo de Control de Calidad con: - Sistema de inspecciones con checklists dinámicos - Gestión de no conformidades (NC) con workflow CAPA - Control de pruebas de laboratorio y certificaciones - Endpoints REST para apps móviles MOB-003 y MOB-004 - Generación de reportes PDF con evidencias fotográficas - Integración con geolocalización y almacenamiento de archivos --- ## 2. Stack Tecnológico ### Backend Core ```typescript - NestJS 10+ con TypeScript - TypeORM para PostgreSQL - PostgreSQL 15+ (schema: quality) - PostGIS para georreferenciación - EventEmitter2 para eventos de dominio - Bull/BullMQ para procesamiento asíncrono ``` ### Librerías Específicas ```typescript - PDFKit: Generación de reportes PDF - Sharp: Procesamiento de imágenes - @aws-sdk/client-s3: Almacenamiento de archivos - @nestjs/schedule: Cron jobs para SLA y alertas - class-validator: Validación de DTOs - class-transformer: Transformación de datos ``` ### Integraciones ```typescript - MOB-003 (App Supervisor): Captura de inspecciones - MOB-004 (App Capataz): Registro de muestreos - MAI-002: Estructura de proyectos - MAI-003: Vinculación a partidas presupuestales - MAI-005: Sincronización con avances de obra - MGN-008: Notificaciones de NC críticas ``` --- ## 3. Modelo de Datos SQL ### 3.1 Schema Principal ```sql -- ===================================================== -- SCHEMA: quality -- Descripción: Control de Calidad en Construcción -- ===================================================== CREATE SCHEMA IF NOT EXISTS quality; -- Habilitar PostGIS para geolocalización CREATE EXTENSION IF NOT EXISTS postgis; -- ===================================================== -- TIPOS ENUMERADOS -- ===================================================== CREATE TYPE quality.inspection_status AS ENUM ( 'draft', 'in_progress', 'completed', 'approved', 'rejected', 'cancelled' ); CREATE TYPE quality.nc_severity AS ENUM ( 'minor', 'major', 'critical' ); CREATE TYPE quality.nc_status AS ENUM ( 'open', 'assigned', 'in_correction', 'pending_verification', 'verified', 'closed', 'rejected' ); CREATE TYPE quality.test_status AS ENUM ( 'pending', 'in_progress', 'completed', 'approved', 'rejected' ); CREATE TYPE quality.specimen_type AS ENUM ( 'concrete_cylinder', 'concrete_beam', 'steel_rebar', 'soil_sample', 'aggregate_sample', 'other' ); -- ===================================================== -- TABLE: quality.checklists -- Descripción: Templates de inspección configurables -- ===================================================== CREATE TABLE quality.checklists ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Identificación code VARCHAR(50) NOT NULL UNIQUE, -- CHK-CIMENTACION name VARCHAR(200) NOT NULL, description TEXT, -- Categorización category VARCHAR(100), -- Estructura, Acabados, Instalaciones construction_stage VARCHAR(100), -- Cimentación, Estructura, Acabados -- Normativa aplicable standards VARCHAR[], -- ["NMX-C-414", "ACI-318"] -- Configuración is_active BOOLEAN DEFAULT true, requires_photos BOOLEAN DEFAULT true, requires_signature BOOLEAN DEFAULT true, min_photos INTEGER DEFAULT 0, -- Scoring has_scoring BOOLEAN DEFAULT true, passing_score DECIMAL(5,2), -- 80.00 = 80% -- Multi-tenancy tenant_id UUID NOT NULL REFERENCES tenants.tenants(id), -- Metadata created_by UUID REFERENCES auth.users(id), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_checklists_tenant ON quality.checklists(tenant_id); CREATE INDEX idx_checklists_category ON quality.checklists(category); CREATE INDEX idx_checklists_stage ON quality.checklists(construction_stage); CREATE INDEX idx_checklists_active ON quality.checklists(is_active) WHERE is_active = true; -- ===================================================== -- TABLE: quality.checklist_items -- Descripción: Ítems de verificación por checklist -- ===================================================== CREATE TABLE quality.checklist_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones checklist_id UUID NOT NULL REFERENCES quality.checklists(id) ON DELETE CASCADE, -- Identificación item_code VARCHAR(50), -- CIMEN-001 sequence_order INTEGER NOT NULL, -- Contenido question TEXT NOT NULL, description TEXT, expected_result TEXT, -- Criterios de aceptación acceptance_criteria JSONB, /* { "type": "boolean|numeric|text", "min_value": 150, "max_value": 200, "unit": "kg/cm2", "tolerance": 5 } */ -- Normativa específica del ítem standard_reference VARCHAR(100), -- NMX-C-414-ONNCCE Sección 5.3 -- Configuración is_mandatory BOOLEAN DEFAULT true, is_critical BOOLEAN DEFAULT false, -- Si falla, rechaza toda la inspección weight DECIMAL(5,2) DEFAULT 1.00, -- Peso para scoring -- Evidencia requerida requires_photo BOOLEAN DEFAULT false, requires_measurement BOOLEAN DEFAULT false, -- Estado is_active BOOLEAN DEFAULT true, -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_checklist_sequence UNIQUE(checklist_id, sequence_order) ); CREATE INDEX idx_checklist_items_checklist ON quality.checklist_items(checklist_id); CREATE INDEX idx_checklist_items_sequence ON quality.checklist_items(checklist_id, sequence_order); CREATE INDEX idx_checklist_items_critical ON quality.checklist_items(is_critical) WHERE is_critical = true; -- ===================================================== -- TABLE: quality.inspections -- Descripción: Inspecciones de calidad realizadas -- ===================================================== CREATE TABLE quality.inspections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones checklist_id UUID NOT NULL REFERENCES quality.checklists(id), project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, stage_id UUID REFERENCES projects.stages(id), workfront_id UUID REFERENCES projects.workfronts(id), unit_id UUID REFERENCES projects.units(id), -- Vivienda/lote específico -- Identificación inspection_number VARCHAR(50) NOT NULL UNIQUE, -- INSP-2025-00001 inspection_date DATE NOT NULL, -- Inspector inspector_id UUID NOT NULL REFERENCES auth.users(id), inspector_signature TEXT, -- Base64 de firma digital signed_at TIMESTAMP, -- Responsable de la obra responsible_id UUID REFERENCES auth.users(id), responsible_signature TEXT, responsible_signed_at TIMESTAMP, -- Ubicación location_description TEXT, geolocation GEOMETRY(POINT, 4326), -- PostGIS geo_accuracy DECIMAL(8,2), -- precisión en metros -- Resultados status quality.inspection_status DEFAULT 'draft', total_items INTEGER DEFAULT 0, items_passed INTEGER DEFAULT 0, items_failed INTEGER DEFAULT 0, items_na INTEGER DEFAULT 0, -- No aplica -- Scoring score DECIMAL(5,2), -- 85.50 = 85.5% weighted_score DECIMAL(5,2), is_approved BOOLEAN, -- Observaciones general_notes TEXT, recommendations TEXT, -- No conformidades generadas nc_count INTEGER DEFAULT 0, critical_nc_count INTEGER DEFAULT 0, -- Metadata de captura captured_via VARCHAR(20) NOT NULL, -- web, mobile_android, mobile_ios device_info JSONB, -- Workflow submitted_at TIMESTAMP, reviewed_by UUID REFERENCES auth.users(id), reviewed_at TIMESTAMP, approval_notes TEXT, -- Multi-tenancy tenant_id UUID NOT NULL REFERENCES tenants.tenants(id), -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_inspections_project ON quality.inspections(project_id); CREATE INDEX idx_inspections_unit ON quality.inspections(unit_id); CREATE INDEX idx_inspections_date ON quality.inspections(inspection_date); CREATE INDEX idx_inspections_status ON quality.inspections(status); CREATE INDEX idx_inspections_inspector ON quality.inspections(inspector_id); CREATE INDEX idx_inspections_tenant ON quality.inspections(tenant_id); CREATE INDEX idx_inspections_geolocation ON quality.inspections USING GIST(geolocation); -- ===================================================== -- TABLE: quality.inspection_results -- Descripción: Resultados por ítem de inspección -- ===================================================== CREATE TABLE quality.inspection_results ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones inspection_id UUID NOT NULL REFERENCES quality.inspections(id) ON DELETE CASCADE, checklist_item_id UUID NOT NULL REFERENCES quality.checklist_items(id), -- Resultado result_value VARCHAR(20) NOT NULL, -- pass, fail, na -- Mediciones measured_value DECIMAL(12,4), -- Valor medido expected_value DECIMAL(12,4), -- Valor esperado unit VARCHAR(20), -- kg/cm2, mm, etc. variance_pct DECIMAL(5,2), -- % de desviación -- Observaciones notes TEXT, failure_reason TEXT, -- Si falló, por qué -- Evidencias photo_urls VARCHAR[], -- URLs de fotos del ítem -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_inspection_item UNIQUE(inspection_id, checklist_item_id) ); CREATE INDEX idx_inspection_results_inspection ON quality.inspection_results(inspection_id); CREATE INDEX idx_inspection_results_item ON quality.inspection_results(checklist_item_id); CREATE INDEX idx_inspection_results_result ON quality.inspection_results(result_value); -- ===================================================== -- TABLE: quality.non_conformities -- Descripción: No Conformidades detectadas -- ===================================================== CREATE TABLE quality.non_conformities ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, inspection_id UUID REFERENCES quality.inspections(id), inspection_result_id UUID REFERENCES quality.inspection_results(id), unit_id UUID REFERENCES projects.units(id), budget_item_id UUID REFERENCES budgets.budget_items(id), -- Partida afectada -- Identificación nc_number VARCHAR(50) NOT NULL UNIQUE, -- NC-2025-00001 nc_date DATE NOT NULL, -- Clasificación severity quality.nc_severity NOT NULL, category VARCHAR(100), -- Estructural, Acabados, Instalaciones subcategory VARCHAR(100), -- Descripción title VARCHAR(200) NOT NULL, description TEXT NOT NULL, location_description TEXT, -- Evidencias photo_urls VARCHAR[], geolocation GEOMETRY(POINT, 4326), -- Detección detected_by UUID NOT NULL REFERENCES auth.users(id), detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Asignación assigned_to UUID REFERENCES auth.users(id), assigned_contractor_id UUID REFERENCES contractors.contractors(id), assigned_at TIMESTAMP, -- SLA (Service Level Agreement) sla_deadline TIMESTAMP NOT NULL, sla_hours INTEGER NOT NULL, -- 24, 72, 168 horas según severidad is_overdue BOOLEAN GENERATED ALWAYS AS ( CASE WHEN status IN ('open', 'assigned', 'in_correction') AND CURRENT_TIMESTAMP > sla_deadline THEN true ELSE false END ) STORED, -- Estado status quality.nc_status DEFAULT 'open', -- Análisis de Causa Raíz (RCA) root_cause TEXT, root_cause_category VARCHAR(100), -- Material, Proceso, Personal, Equipo -- Acciones Correctivas corrective_action TEXT, corrective_action_by UUID REFERENCES auth.users(id), corrective_action_date TIMESTAMP, correction_photo_urls VARCHAR[], -- Acciones Preventivas preventive_action TEXT, preventive_action_responsible UUID REFERENCES auth.users(id), preventive_action_deadline DATE, -- Verificación de Cierre verification_notes TEXT, verification_photo_urls VARCHAR[], verified_by UUID REFERENCES auth.users(id), verified_at TIMESTAMP, -- Cierre closure_notes TEXT, closed_by UUID REFERENCES auth.users(id), closed_at TIMESTAMP, -- Costo estimado de corrección estimated_cost DECIMAL(15,2), actual_cost DECIMAL(15,2), -- Recurrencia is_recurring BOOLEAN DEFAULT false, previous_nc_id UUID REFERENCES quality.non_conformities(id), -- Multi-tenancy tenant_id UUID NOT NULL REFERENCES tenants.tenants(id), -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_nc_project ON quality.non_conformities(project_id); CREATE INDEX idx_nc_inspection ON quality.non_conformities(inspection_id); CREATE INDEX idx_nc_unit ON quality.non_conformities(unit_id); CREATE INDEX idx_nc_severity ON quality.non_conformities(severity); CREATE INDEX idx_nc_status ON quality.non_conformities(status); CREATE INDEX idx_nc_assigned ON quality.non_conformities(assigned_to); CREATE INDEX idx_nc_sla_deadline ON quality.non_conformities(sla_deadline); CREATE INDEX idx_nc_overdue ON quality.non_conformities(is_overdue) WHERE is_overdue = true; CREATE INDEX idx_nc_tenant ON quality.non_conformities(tenant_id); CREATE INDEX idx_nc_date ON quality.non_conformities(nc_date); -- ===================================================== -- TABLE: quality.laboratory_tests -- Descripción: Pruebas de laboratorio -- ===================================================== CREATE TABLE quality.laboratory_tests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, purchase_order_id UUID REFERENCES purchases.purchase_orders(id), budget_item_id UUID REFERENCES budgets.budget_items(id), -- Identificación test_number VARCHAR(50) NOT NULL UNIQUE, -- LAB-2025-00001 test_date DATE NOT NULL, -- Tipo de prueba test_type VARCHAR(100) NOT NULL, -- Compresión, Flexión, Granulometría material_type VARCHAR(100) NOT NULL, -- Concreto, Acero, Agregados standard_reference VARCHAR(100), -- NMX-C-083-ONNCCE -- Muestreo sampling_date DATE NOT NULL, sampling_location TEXT, sampled_by UUID REFERENCES auth.users(id), -- Laboratorio laboratory_name VARCHAR(200), laboratory_accreditation VARCHAR(100), -- EMA-123/2023 laboratory_technician VARCHAR(200), -- Especificaciones specification JSONB NOT NULL, /* { "property": "resistencia_compresion", "min_value": 200, "max_value": null, "target_value": 250, "unit": "kg/cm2", "age_days": 28 } */ -- Resultados test_results JSONB, /* { "measured_value": 245, "unit": "kg/cm2", "age_days": 28, "temperature": 23, "humidity": 65 } */ result_value DECIMAL(12,4), result_unit VARCHAR(20), -- Evaluación is_compliant BOOLEAN, compliance_percentage DECIMAL(5,2), -- Estado status quality.test_status DEFAULT 'pending', -- Certificado certificate_url VARCHAR(500), certificate_number VARCHAR(100), certificate_date DATE, -- Observaciones observations TEXT, recommendations TEXT, -- Workflow reviewed_by UUID REFERENCES auth.users(id), reviewed_at TIMESTAMP, approved_by UUID REFERENCES auth.users(id), approved_at TIMESTAMP, -- Multi-tenancy tenant_id UUID NOT NULL REFERENCES tenants.tenants(id), -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_lab_tests_project ON quality.laboratory_tests(project_id); CREATE INDEX idx_lab_tests_test_type ON quality.laboratory_tests(test_type); CREATE INDEX idx_lab_tests_material ON quality.laboratory_tests(material_type); CREATE INDEX idx_lab_tests_status ON quality.laboratory_tests(status); CREATE INDEX idx_lab_tests_compliant ON quality.laboratory_tests(is_compliant); CREATE INDEX idx_lab_tests_tenant ON quality.laboratory_tests(tenant_id); CREATE INDEX idx_lab_tests_sampling_date ON quality.laboratory_tests(sampling_date); -- ===================================================== -- TABLE: quality.test_specimens -- Descripción: Especímenes/muestras de pruebas -- ===================================================== CREATE TABLE quality.test_specimens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones lab_test_id UUID NOT NULL REFERENCES quality.laboratory_tests(id) ON DELETE CASCADE, -- Identificación specimen_number VARCHAR(50) NOT NULL UNIQUE, -- SPEC-2025-00001 specimen_type quality.specimen_type NOT NULL, -- Información del espécimen batch_number VARCHAR(100), -- Número de lote del material production_date DATE, -- Dimensiones (para cilindros, vigas, etc.) diameter_cm DECIMAL(8,2), height_cm DECIMAL(8,2), length_cm DECIMAL(8,2), width_cm DECIMAL(8,2), weight_kg DECIMAL(8,2), -- Curado (para concreto) curing_method VARCHAR(100), -- Húmedo, Membrana curing_start_date DATE, test_age_days INTEGER, -- 7, 14, 28 días -- Ubicación en proyecto unit_id UUID REFERENCES projects.units(id), element_description TEXT, -- "Columna C-1, Eje A" -- Evidencias photo_urls VARCHAR[], -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_specimens_lab_test ON quality.test_specimens(lab_test_id); CREATE INDEX idx_specimens_type ON quality.test_specimens(specimen_type); CREATE INDEX idx_specimens_batch ON quality.test_specimens(batch_number); CREATE INDEX idx_specimens_unit ON quality.test_specimens(unit_id); -- ===================================================== -- TABLE: quality.certifications -- Descripción: Certificados de materiales y procesos -- ===================================================== CREATE TABLE quality.certifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE, supplier_id UUID REFERENCES suppliers.suppliers(id), purchase_order_id UUID REFERENCES purchases.purchase_orders(id), -- Identificación certification_number VARCHAR(100) NOT NULL, certification_type VARCHAR(100) NOT NULL, -- Material, Proceso, Sistema -- Material certificado material_name VARCHAR(200) NOT NULL, material_specification VARCHAR(200), batch_number VARCHAR(100), quantity DECIMAL(12,4), unit VARCHAR(20), -- Certificador certifying_entity VARCHAR(200) NOT NULL, certifier_accreditation VARCHAR(100), -- EMA, ONNCCE -- Normativa standards_compliance VARCHAR[], -- ["NMX-C-414", "ASTM-C39"] -- Validez issue_date DATE NOT NULL, expiration_date DATE, is_valid BOOLEAN GENERATED ALWAYS AS ( CASE WHEN expiration_date IS NULL THEN true WHEN expiration_date >= CURRENT_DATE THEN true ELSE false END ) STORED, -- Documentos certificate_url VARCHAR(500) NOT NULL, certificate_hash VARCHAR(64), -- SHA-256 para verificación -- Verificación verified_by UUID REFERENCES auth.users(id), verified_at TIMESTAMP, verification_notes TEXT, -- Multi-tenancy tenant_id UUID NOT NULL REFERENCES tenants.tenants(id), -- Metadata created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CONSTRAINT unique_cert_number_tenant UNIQUE(certification_number, tenant_id) ); CREATE INDEX idx_certifications_project ON quality.certifications(project_id); CREATE INDEX idx_certifications_supplier ON quality.certifications(supplier_id); CREATE INDEX idx_certifications_type ON quality.certifications(certification_type); CREATE INDEX idx_certifications_valid ON quality.certifications(is_valid) WHERE is_valid = true; CREATE INDEX idx_certifications_expiration ON quality.certifications(expiration_date); CREATE INDEX idx_certifications_tenant ON quality.certifications(tenant_id); -- ===================================================== -- TABLE: quality.quality_attachments -- Descripción: Archivos adjuntos (fotos, documentos) -- ===================================================== CREATE TABLE quality.quality_attachments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones polimórficas entity_type VARCHAR(50) NOT NULL, -- inspection, non_conformity, lab_test, certification entity_id UUID NOT NULL, -- Archivo file_name VARCHAR(255) NOT NULL, file_type VARCHAR(100) NOT NULL, -- image/jpeg, application/pdf file_size_bytes BIGINT NOT NULL, file_url VARCHAR(500) NOT NULL, thumbnail_url VARCHAR(500), -- Metadata del archivo mime_type VARCHAR(100), file_hash VARCHAR(64), -- SHA-256 -- Georreferenciación (para fotos) geolocation GEOMETRY(POINT, 4326), capture_timestamp TIMESTAMP, -- EXIF data de fotos exif_data JSONB, /* { "camera_model": "iPhone 13", "gps_latitude": 19.4326, "gps_longitude": -99.1332, "timestamp": "2025-01-15T10:30:00Z" } */ -- Clasificación attachment_category VARCHAR(50), -- before, after, detail, overview description TEXT, sequence_order INTEGER, -- Multi-tenancy tenant_id UUID NOT NULL REFERENCES tenants.tenants(id), -- Metadata uploaded_by UUID REFERENCES auth.users(id), uploaded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_attachments_entity ON quality.quality_attachments(entity_type, entity_id); CREATE INDEX idx_attachments_tenant ON quality.quality_attachments(tenant_id); CREATE INDEX idx_attachments_uploaded_by ON quality.quality_attachments(uploaded_by); CREATE INDEX idx_attachments_geolocation ON quality.quality_attachments USING GIST(geolocation); -- ===================================================== -- TABLE: quality.nc_history -- Descripción: Historial de cambios de estado de NC -- ===================================================== CREATE TABLE quality.nc_history ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Relaciones nc_id UUID NOT NULL REFERENCES quality.non_conformities(id) ON DELETE CASCADE, -- Cambio de estado from_status quality.nc_status, to_status quality.nc_status NOT NULL, -- Información del cambio changed_by UUID NOT NULL REFERENCES auth.users(id), changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, change_reason TEXT, -- Datos adicionales additional_data JSONB ); CREATE INDEX idx_nc_history_nc ON quality.nc_history(nc_id); CREATE INDEX idx_nc_history_date ON quality.nc_history(changed_at); ``` --- ## 4. TypeORM Entities ### 4.1 Checklist Entity ```typescript // src/modules/quality/entities/checklist.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Tenant } from '../../tenants/entities/tenant.entity'; import { User } from '../../auth/entities/user.entity'; import { ChecklistItem } from './checklist-item.entity'; @Entity('checklists', { schema: 'quality' }) export class Checklist { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'varchar', length: 50, unique: true }) code: string; @Column({ type: 'varchar', length: 200 }) name: string; @Column({ type: 'text', nullable: true }) description?: string; @Column({ type: 'varchar', length: 100, nullable: true }) @Index() category?: string; @Column({ type: 'varchar', length: 100, nullable: true, name: 'construction_stage' }) @Index() constructionStage?: string; @Column({ type: 'varchar', array: true, nullable: true }) standards?: string[]; @Column({ type: 'boolean', default: true, name: 'is_active' }) @Index() isActive: boolean; @Column({ type: 'boolean', default: true, name: 'requires_photos' }) requiresPhotos: boolean; @Column({ type: 'boolean', default: true, name: 'requires_signature' }) requiresSignature: boolean; @Column({ type: 'integer', default: 0, name: 'min_photos' }) minPhotos: number; @Column({ type: 'boolean', default: true, name: 'has_scoring' }) hasScoring: boolean; @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true, name: 'passing_score' }) passingScore?: number; @Column({ type: 'uuid', name: 'tenant_id' }) @Index() tenantId: string; @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' }) tenant: Tenant; @Column({ type: 'uuid', nullable: true, name: 'created_by' }) createdBy?: string; @ManyToOne(() => User) @JoinColumn({ name: 'created_by' }) creator?: User; @OneToMany(() => ChecklistItem, item => item.checklist, { cascade: true }) items: ChecklistItem[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### 4.2 Inspection Entity ```typescript // src/modules/quality/entities/inspection.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Checklist } from './checklist.entity'; import { Project } from '../../projects/entities/project.entity'; import { User } from '../../auth/entities/user.entity'; import { InspectionResult } from './inspection-result.entity'; import { Tenant } from '../../tenants/entities/tenant.entity'; export enum InspectionStatus { DRAFT = 'draft', IN_PROGRESS = 'in_progress', COMPLETED = 'completed', APPROVED = 'approved', REJECTED = 'rejected', CANCELLED = 'cancelled', } @Entity('inspections', { schema: 'quality' }) export class Inspection { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'checklist_id' }) checklistId: string; @ManyToOne(() => Checklist) @JoinColumn({ name: 'checklist_id' }) checklist: Checklist; @Column({ type: 'uuid', name: 'project_id' }) @Index() projectId: string; @ManyToOne(() => Project, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'project_id' }) project: Project; @Column({ type: 'uuid', nullable: true, name: 'unit_id' }) @Index() unitId?: string; @Column({ type: 'varchar', length: 50, unique: true, name: 'inspection_number' }) inspectionNumber: string; @Column({ type: 'date', name: 'inspection_date' }) @Index() inspectionDate: Date; @Column({ type: 'uuid', name: 'inspector_id' }) @Index() inspectorId: string; @ManyToOne(() => User) @JoinColumn({ name: 'inspector_id' }) inspector: User; @Column({ type: 'text', nullable: true, name: 'inspector_signature' }) inspectorSignature?: string; @Column({ type: 'timestamp', nullable: true, name: 'signed_at' }) signedAt?: Date; @Column({ type: 'text', nullable: true, name: 'location_description' }) locationDescription?: string; @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true, }) @Index({ spatial: true }) geolocation?: string; @Column({ type: 'decimal', precision: 8, scale: 2, nullable: true, name: 'geo_accuracy' }) geoAccuracy?: number; @Column({ type: 'enum', enum: InspectionStatus, default: InspectionStatus.DRAFT }) @Index() status: InspectionStatus; @Column({ type: 'integer', default: 0, name: 'total_items' }) totalItems: number; @Column({ type: 'integer', default: 0, name: 'items_passed' }) itemsPassed: number; @Column({ type: 'integer', default: 0, name: 'items_failed' }) itemsFailed: number; @Column({ type: 'integer', default: 0, name: 'items_na' }) itemsNa: number; @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true }) score?: number; @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true, name: 'weighted_score' }) weightedScore?: number; @Column({ type: 'boolean', nullable: true, name: 'is_approved' }) isApproved?: boolean; @Column({ type: 'text', nullable: true, name: 'general_notes' }) generalNotes?: string; @Column({ type: 'integer', default: 0, name: 'nc_count' }) ncCount: number; @Column({ type: 'integer', default: 0, name: 'critical_nc_count' }) criticalNcCount: number; @Column({ type: 'varchar', length: 20, name: 'captured_via' }) capturedVia: string; @Column({ type: 'jsonb', nullable: true, name: 'device_info' }) deviceInfo?: any; @Column({ type: 'uuid', name: 'tenant_id' }) @Index() tenantId: string; @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' }) tenant: Tenant; @OneToMany(() => InspectionResult, result => result.inspection, { cascade: true }) results: InspectionResult[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### 4.3 NonConformity Entity ```typescript // src/modules/quality/entities/non-conformity.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Project } from '../../projects/entities/project.entity'; import { Inspection } from './inspection.entity'; import { User } from '../../auth/entities/user.entity'; import { Tenant } from '../../tenants/entities/tenant.entity'; export enum NCSeverity { MINOR = 'minor', MAJOR = 'major', CRITICAL = 'critical', } export enum NCStatus { OPEN = 'open', ASSIGNED = 'assigned', IN_CORRECTION = 'in_correction', PENDING_VERIFICATION = 'pending_verification', VERIFIED = 'verified', CLOSED = 'closed', REJECTED = 'rejected', } @Entity('non_conformities', { schema: 'quality' }) export class NonConformity { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'project_id' }) @Index() projectId: string; @ManyToOne(() => Project, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'project_id' }) project: Project; @Column({ type: 'uuid', nullable: true, name: 'inspection_id' }) @Index() inspectionId?: string; @ManyToOne(() => Inspection) @JoinColumn({ name: 'inspection_id' }) inspection?: Inspection; @Column({ type: 'varchar', length: 50, unique: true, name: 'nc_number' }) ncNumber: string; @Column({ type: 'date', name: 'nc_date' }) @Index() ncDate: Date; @Column({ type: 'enum', enum: NCSeverity }) @Index() severity: NCSeverity; @Column({ type: 'varchar', length: 100, nullable: true }) category?: string; @Column({ type: 'varchar', length: 200 }) title: string; @Column({ type: 'text' }) description: string; @Column({ type: 'text', nullable: true, name: 'location_description' }) locationDescription?: string; @Column({ type: 'varchar', array: true, nullable: true, name: 'photo_urls' }) photoUrls?: string[]; @Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true, }) geolocation?: string; @Column({ type: 'uuid', name: 'detected_by' }) detectedBy: string; @ManyToOne(() => User) @JoinColumn({ name: 'detected_by' }) detector: User; @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', name: 'detected_at' }) detectedAt: Date; @Column({ type: 'uuid', nullable: true, name: 'assigned_to' }) @Index() assignedTo?: string; @ManyToOne(() => User) @JoinColumn({ name: 'assigned_to' }) assignee?: User; @Column({ type: 'timestamp', nullable: true, name: 'assigned_at' }) assignedAt?: Date; @Column({ type: 'timestamp', name: 'sla_deadline' }) @Index() slaDeadline: Date; @Column({ type: 'integer', name: 'sla_hours' }) slaHours: number; @Column({ type: 'enum', enum: NCStatus, default: NCStatus.OPEN }) @Index() status: NCStatus; @Column({ type: 'text', nullable: true, name: 'root_cause' }) rootCause?: string; @Column({ type: 'varchar', length: 100, nullable: true, name: 'root_cause_category' }) rootCauseCategory?: string; @Column({ type: 'text', nullable: true, name: 'corrective_action' }) correctiveAction?: string; @Column({ type: 'uuid', nullable: true, name: 'corrective_action_by' }) correctiveActionBy?: string; @Column({ type: 'timestamp', nullable: true, name: 'corrective_action_date' }) correctiveActionDate?: Date; @Column({ type: 'varchar', array: true, nullable: true, name: 'correction_photo_urls' }) correctionPhotoUrls?: string[]; @Column({ type: 'text', nullable: true, name: 'preventive_action' }) preventiveAction?: string; @Column({ type: 'text', nullable: true, name: 'verification_notes' }) verificationNotes?: string; @Column({ type: 'uuid', nullable: true, name: 'verified_by' }) verifiedBy?: string; @ManyToOne(() => User) @JoinColumn({ name: 'verified_by' }) verifier?: User; @Column({ type: 'timestamp', nullable: true, name: 'verified_at' }) verifiedAt?: Date; @Column({ type: 'uuid', nullable: true, name: 'closed_by' }) closedBy?: string; @Column({ type: 'timestamp', nullable: true, name: 'closed_at' }) closedAt?: Date; @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true, name: 'estimated_cost' }) estimatedCost?: number; @Column({ type: 'decimal', precision: 15, scale: 2, nullable: true, name: 'actual_cost' }) actualCost?: number; @Column({ type: 'boolean', default: false, name: 'is_recurring' }) isRecurring: boolean; @Column({ type: 'uuid', name: 'tenant_id' }) @Index() tenantId: string; @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' }) tenant: Tenant; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; // Computed property get isOverdue(): boolean { return ( [NCStatus.OPEN, NCStatus.ASSIGNED, NCStatus.IN_CORRECTION].includes(this.status) && new Date() > this.slaDeadline ); } } ``` ### 4.4 LaboratoryTest Entity ```typescript // src/modules/quality/entities/laboratory-test.entity.ts import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, OneToMany, JoinColumn, CreateDateColumn, UpdateDateColumn, Index, } from 'typeorm'; import { Project } from '../../projects/entities/project.entity'; import { User } from '../../auth/entities/user.entity'; import { Tenant } from '../../tenants/entities/tenant.entity'; import { TestSpecimen } from './test-specimen.entity'; export enum TestStatus { PENDING = 'pending', IN_PROGRESS = 'in_progress', COMPLETED = 'completed', APPROVED = 'approved', REJECTED = 'rejected', } @Entity('laboratory_tests', { schema: 'quality' }) export class LaboratoryTest { @PrimaryGeneratedColumn('uuid') id: string; @Column({ type: 'uuid', name: 'project_id' }) @Index() projectId: string; @ManyToOne(() => Project, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'project_id' }) project: Project; @Column({ type: 'varchar', length: 50, unique: true, name: 'test_number' }) testNumber: string; @Column({ type: 'date', name: 'test_date' }) testDate: Date; @Column({ type: 'varchar', length: 100, name: 'test_type' }) testType: string; @Column({ type: 'varchar', length: 100, name: 'material_type' }) @Index() materialType: string; @Column({ type: 'varchar', length: 100, nullable: true, name: 'standard_reference' }) standardReference?: string; @Column({ type: 'date', name: 'sampling_date' }) @Index() samplingDate: Date; @Column({ type: 'text', nullable: true, name: 'sampling_location' }) samplingLocation?: string; @Column({ type: 'uuid', nullable: true, name: 'sampled_by' }) sampledBy?: string; @ManyToOne(() => User) @JoinColumn({ name: 'sampled_by' }) sampler?: User; @Column({ type: 'varchar', length: 200, nullable: true, name: 'laboratory_name' }) laboratoryName?: string; @Column({ type: 'varchar', length: 100, nullable: true, name: 'laboratory_accreditation' }) laboratoryAccreditation?: string; @Column({ type: 'jsonb', name: 'specification' }) specification: any; @Column({ type: 'jsonb', nullable: true, name: 'test_results' }) testResults?: any; @Column({ type: 'decimal', precision: 12, scale: 4, nullable: true, name: 'result_value' }) resultValue?: number; @Column({ type: 'varchar', length: 20, nullable: true, name: 'result_unit' }) resultUnit?: string; @Column({ type: 'boolean', nullable: true, name: 'is_compliant' }) @Index() isCompliant?: boolean; @Column({ type: 'decimal', precision: 5, scale: 2, nullable: true, name: 'compliance_percentage' }) compliancePercentage?: number; @Column({ type: 'enum', enum: TestStatus, default: TestStatus.PENDING }) @Index() status: TestStatus; @Column({ type: 'varchar', length: 500, nullable: true, name: 'certificate_url' }) certificateUrl?: string; @Column({ type: 'varchar', length: 100, nullable: true, name: 'certificate_number' }) certificateNumber?: string; @Column({ type: 'date', nullable: true, name: 'certificate_date' }) certificateDate?: Date; @Column({ type: 'text', nullable: true }) observations?: string; @Column({ type: 'uuid', name: 'tenant_id' }) @Index() tenantId: string; @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' }) tenant: Tenant; @OneToMany(() => TestSpecimen, specimen => specimen.labTest, { cascade: true }) specimens: TestSpecimen[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` --- ## 5. DTOs (Data Transfer Objects) ### 5.1 Inspection DTOs ```typescript // src/modules/quality/dto/create-inspection.dto.ts import { IsUUID, IsDateString, IsOptional, IsString, IsArray, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; export class GeoLocationDto { @IsOptional() latitude?: number; @IsOptional() longitude?: number; @IsOptional() accuracy?: number; } export class InspectionResultItemDto { @IsUUID() checklistItemId: string; @IsString() resultValue: string; // 'pass' | 'fail' | 'na' @IsOptional() measuredValue?: number; @IsOptional() @IsString() notes?: string; @IsOptional() @IsArray() photoUrls?: string[]; } export class CreateInspectionDto { @IsUUID() checklistId: string; @IsUUID() projectId: string; @IsOptional() @IsUUID() unitId?: string; @IsDateString() inspectionDate: string; @IsUUID() inspectorId: string; @IsOptional() @IsString() locationDescription?: string; @IsOptional() @ValidateNested() @Type(() => GeoLocationDto) geolocation?: GeoLocationDto; @IsOptional() @IsString() capturedVia?: string; @IsOptional() deviceInfo?: any; @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => InspectionResultItemDto) results?: InspectionResultItemDto[]; } export class SubmitInspectionDto { @IsString() inspectorSignature: string; @IsOptional() @IsString() generalNotes?: string; } ``` ### 5.2 Non-Conformity DTOs ```typescript // src/modules/quality/dto/create-non-conformity.dto.ts import { IsUUID, IsEnum, IsString, IsOptional, IsArray, IsDateString } from 'class-validator'; import { NCSeverity } from '../entities/non-conformity.entity'; export class CreateNonConformityDto { @IsUUID() projectId: string; @IsOptional() @IsUUID() inspectionId?: string; @IsOptional() @IsUUID() unitId?: string; @IsDateString() ncDate: string; @IsEnum(NCSeverity) severity: NCSeverity; @IsOptional() @IsString() category?: string; @IsString() title: string; @IsString() description: string; @IsOptional() @IsString() locationDescription?: string; @IsOptional() @IsArray() photoUrls?: string[]; @IsOptional() geolocation?: { latitude: number; longitude: number; }; @IsUUID() detectedBy: string; } export class AssignNonConformityDto { @IsUUID() assignedTo: string; @IsOptional() @IsString() assignmentNotes?: string; } export class CorrectNonConformityDto { @IsString() correctiveAction: string; @IsOptional() @IsString() rootCause?: string; @IsOptional() @IsString() rootCauseCategory?: string; @IsOptional() @IsArray() correctionPhotoUrls?: string[]; @IsOptional() @IsString() preventiveAction?: string; } export class VerifyNonConformityDto { @IsString() verificationNotes: string; @IsOptional() @IsArray() verificationPhotoUrls?: string[]; @IsOptional() isApproved?: boolean; } ``` --- ## 6. Services (Lógica de Negocio) ### 6.1 InspectionService ```typescript // src/modules/quality/services/inspection.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Inspection, InspectionStatus } from '../entities/inspection.entity'; import { InspectionResult } from '../entities/inspection-result.entity'; import { Checklist } from '../entities/checklist.entity'; import { CreateInspectionDto, SubmitInspectionDto } from '../dto'; import { NonConformityService } from './non-conformity.service'; import { PdfService } from './pdf.service'; @Injectable() export class InspectionService { constructor( @InjectRepository(Inspection) private inspectionRepo: Repository, @InjectRepository(InspectionResult) private resultRepo: Repository, @InjectRepository(Checklist) private checklistRepo: Repository, private ncService: NonConformityService, private pdfService: PdfService, private eventEmitter: EventEmitter2, ) {} /** * Crear inspección */ async create(dto: CreateInspectionDto, tenantId: string): Promise { // Validar que el checklist existe const checklist = await this.checklistRepo.findOne({ where: { id: dto.checklistId }, relations: ['items'], }); if (!checklist || !checklist.isActive) { throw new BadRequestException('Checklist not found or inactive'); } // Generar número de inspección const year = new Date().getFullYear(); const count = await this.inspectionRepo.count({ where: { tenantId } }); const inspectionNumber = `INSP-${year}-${String(count + 1).padStart(5, '0')}`; // Crear inspección const inspection = this.inspectionRepo.create({ ...dto, inspectionNumber, tenantId, status: InspectionStatus.DRAFT, totalItems: checklist.items.length, geolocation: dto.geolocation ? `POINT(${dto.geolocation.longitude} ${dto.geolocation.latitude})` : undefined, geoAccuracy: dto.geolocation?.accuracy, }); const saved = await this.inspectionRepo.save(inspection); // Si se proporcionaron resultados, procesarlos if (dto.results && dto.results.length > 0) { await this.processResults(saved.id, dto.results); } return saved; } /** * Procesar resultados de inspección */ async processResults(inspectionId: string, results: any[]): Promise { const inspection = await this.findOne(inspectionId); let itemsPassed = 0; let itemsFailed = 0; let itemsNa = 0; let totalWeight = 0; let weightedScore = 0; for (const resultDto of results) { const checklistItem = await this.checklistRepo .createQueryBuilder('checklist') .innerJoin('checklist.items', 'item') .where('item.id = :itemId', { itemId: resultDto.checklistItemId }) .select(['item.weight', 'item.is_critical']) .getRawOne(); const result = this.resultRepo.create({ inspectionId, checklistItemId: resultDto.checklistItemId, resultValue: resultDto.resultValue, measuredValue: resultDto.measuredValue, notes: resultDto.notes, photoUrls: resultDto.photoUrls || [], }); await this.resultRepo.save(result); // Contabilizar resultados if (resultDto.resultValue === 'pass') { itemsPassed++; weightedScore += checklistItem.weight || 1; } else if (resultDto.resultValue === 'fail') { itemsFailed++; // Si el ítem es crítico y falló, generar NC automáticamente if (checklistItem.is_critical) { await this.ncService.createFromInspection(inspection, resultDto); } } else if (resultDto.resultValue === 'na') { itemsNa++; } totalWeight += checklistItem.weight || 1; } // Calcular score const totalEvaluated = itemsPassed + itemsFailed; const score = totalEvaluated > 0 ? (itemsPassed / totalEvaluated) * 100 : 0; const finalWeightedScore = totalWeight > 0 ? (weightedScore / totalWeight) * 100 : 0; // Actualizar inspección inspection.itemsPassed = itemsPassed; inspection.itemsFailed = itemsFailed; inspection.itemsNa = itemsNa; inspection.score = Math.round(score * 100) / 100; inspection.weightedScore = Math.round(finalWeightedScore * 100) / 100; inspection.status = InspectionStatus.IN_PROGRESS; await this.inspectionRepo.save(inspection); } /** * Enviar inspección (firmar y completar) */ async submit(id: string, dto: SubmitInspectionDto): Promise { const inspection = await this.findOne(id); if (inspection.status !== InspectionStatus.IN_PROGRESS) { throw new BadRequestException('Only in-progress inspections can be submitted'); } // Validar que todos los ítems requeridos fueron evaluados const checklist = await this.checklistRepo.findOne({ where: { id: inspection.checklistId }, relations: ['items'], }); const mandatoryItemsCount = checklist.items.filter(item => item.isMandatory).length; const evaluatedItemsCount = inspection.itemsPassed + inspection.itemsFailed + inspection.itemsNa; if (evaluatedItemsCount < mandatoryItemsCount) { throw new BadRequestException('All mandatory items must be evaluated'); } // Determinar si está aprobada const isApproved = checklist.passingScore ? inspection.weightedScore >= checklist.passingScore : true; inspection.inspectorSignature = dto.inspectorSignature; inspection.signedAt = new Date(); inspection.generalNotes = dto.generalNotes; inspection.status = InspectionStatus.COMPLETED; inspection.isApproved = isApproved; const submitted = await this.inspectionRepo.save(inspection); // Generar PDF del reporte await this.pdfService.generateInspectionReport(submitted); // Emitir evento this.eventEmitter.emit('inspection.submitted', { inspection: submitted }); return submitted; } /** * Obtener inspección por ID */ async findOne(id: string): Promise { const inspection = await this.inspectionRepo.findOne({ where: { id }, relations: ['checklist', 'project', 'inspector', 'results'], }); if (!inspection) { throw new NotFoundException(`Inspection ${id} not found`); } return inspection; } /** * Listar inspecciones por proyecto */ async findByProject(projectId: string, tenantId: string): Promise { return this.inspectionRepo.find({ where: { projectId, tenantId }, relations: ['checklist', 'inspector'], order: { inspectionDate: 'DESC' }, }); } } ``` ### 6.2 NonConformityService ```typescript // src/modules/quality/services/non-conformity.service.ts import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan, In } from 'typeorm'; import { Cron, CronExpression } from '@nestjs/schedule'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { NonConformity, NCSeverity, NCStatus } from '../entities/non-conformity.entity'; import { NCHistory } from '../entities/nc-history.entity'; import { CreateNonConformityDto, AssignNonConformityDto, CorrectNonConformityDto, VerifyNonConformityDto, } from '../dto'; @Injectable() export class NonConformityService { constructor( @InjectRepository(NonConformity) private ncRepo: Repository, @InjectRepository(NCHistory) private historyRepo: Repository, private eventEmitter: EventEmitter2, ) {} /** * Crear No Conformidad */ async create(dto: CreateNonConformityDto, tenantId: string): Promise { // Generar número de NC const year = new Date().getFullYear(); const count = await this.ncRepo.count({ where: { tenantId } }); const ncNumber = `NC-${year}-${String(count + 1).padStart(5, '0')}`; // Calcular SLA según severidad const slaHours = this.getSLAHours(dto.severity); const slaDeadline = new Date(Date.now() + slaHours * 60 * 60 * 1000); const nc = this.ncRepo.create({ ...dto, ncNumber, tenantId, slaHours, slaDeadline, status: NCStatus.OPEN, geolocation: dto.geolocation ? `POINT(${dto.geolocation.longitude} ${dto.geolocation.latitude})` : undefined, }); const saved = await this.ncRepo.save(nc); // Registrar en historial await this.addToHistory(saved.id, null, NCStatus.OPEN, dto.detectedBy, 'NC created'); // Si es crítica, enviar alerta inmediata if (dto.severity === NCSeverity.CRITICAL) { await this.alertCriticalNC(saved); } // Emitir evento this.eventEmitter.emit('nc.created', { nc: saved }); return saved; } /** * Crear NC desde inspección */ async createFromInspection(inspection: any, resultItem: any): Promise { const dto: CreateNonConformityDto = { projectId: inspection.projectId, inspectionId: inspection.id, unitId: inspection.unitId, ncDate: inspection.inspectionDate, severity: NCSeverity.MAJOR, // Por defecto, ajustar según lógica title: `NC from inspection ${inspection.inspectionNumber}`, description: resultItem.notes || 'Failed inspection item', photoUrls: resultItem.photoUrls || [], detectedBy: inspection.inspectorId, }; return this.create(dto, inspection.tenantId); } /** * Asignar NC a responsable */ async assign(id: string, dto: AssignNonConformityDto, userId: string): Promise { const nc = await this.findOne(id); if (nc.status !== NCStatus.OPEN) { throw new BadRequestException('Only open NCs can be assigned'); } const previousStatus = nc.status; nc.assignedTo = dto.assignedTo; nc.assignedAt = new Date(); nc.status = NCStatus.ASSIGNED; const updated = await this.ncRepo.save(nc); // Registrar en historial await this.addToHistory( nc.id, previousStatus, NCStatus.ASSIGNED, userId, `Assigned to ${dto.assignedTo}`, ); // Notificar al asignado this.eventEmitter.emit('nc.assigned', { nc: updated }); return updated; } /** * Registrar acción correctiva */ async correct(id: string, dto: CorrectNonConformityDto, userId: string): Promise { const nc = await this.findOne(id); if (![NCStatus.ASSIGNED, NCStatus.IN_CORRECTION].includes(nc.status)) { throw new BadRequestException('NC must be assigned before correction'); } const previousStatus = nc.status; nc.correctiveAction = dto.correctiveAction; nc.correctiveActionBy = userId; nc.correctiveActionDate = new Date(); nc.rootCause = dto.rootCause; nc.rootCauseCategory = dto.rootCauseCategory; nc.correctionPhotoUrls = dto.correctionPhotoUrls || []; nc.preventiveAction = dto.preventiveAction; nc.status = NCStatus.PENDING_VERIFICATION; const updated = await this.ncRepo.save(nc); await this.addToHistory( nc.id, previousStatus, NCStatus.PENDING_VERIFICATION, userId, 'Corrective action completed', ); this.eventEmitter.emit('nc.corrected', { nc: updated }); return updated; } /** * Verificar cierre de NC */ async verify(id: string, dto: VerifyNonConformityDto, userId: string): Promise { const nc = await this.findOne(id); if (nc.status !== NCStatus.PENDING_VERIFICATION) { throw new BadRequestException('NC must be pending verification'); } const previousStatus = nc.status; if (dto.isApproved !== false) { // Aprobada nc.verificationNotes = dto.verificationNotes; nc.verifiedBy = userId; nc.verifiedAt = new Date(); nc.status = NCStatus.VERIFIED; } else { // Rechazada, volver a corrección nc.status = NCStatus.IN_CORRECTION; } const updated = await this.ncRepo.save(nc); await this.addToHistory( nc.id, previousStatus, nc.status, userId, dto.isApproved ? 'Verification approved' : 'Verification rejected', ); this.eventEmitter.emit('nc.verified', { nc: updated }); return updated; } /** * Cerrar NC */ async close(id: string, closureNotes: string, userId: string): Promise { const nc = await this.findOne(id); if (nc.status !== NCStatus.VERIFIED) { throw new BadRequestException('NC must be verified before closing'); } const previousStatus = nc.status; nc.closureNotes = closureNotes; nc.closedBy = userId; nc.closedAt = new Date(); nc.status = NCStatus.CLOSED; const updated = await this.ncRepo.save(nc); await this.addToHistory(nc.id, previousStatus, NCStatus.CLOSED, userId, 'NC closed'); this.eventEmitter.emit('nc.closed', { nc: updated }); return updated; } /** * Cron job: Verificar SLA vencidos */ @Cron(CronExpression.EVERY_HOUR) async checkOverdueSLA(): Promise { const overdueNCs = await this.ncRepo.find({ where: { status: In([NCStatus.OPEN, NCStatus.ASSIGNED, NCStatus.IN_CORRECTION]), slaDeadline: LessThan(new Date()), }, relations: ['project', 'assignee'], }); for (const nc of overdueNCs) { await this.escalateNC(nc); } } /** * Escalar NC vencida */ private async escalateNC(nc: NonConformity): Promise { // Emitir evento para notificaciones this.eventEmitter.emit('nc.overdue', { nc }); // Aquí podrías implementar lógica adicional: // - Notificar a gerente del proyecto // - Aumentar severidad // - Crear ticket de seguimiento } /** * Alerta de NC crítica */ private async alertCriticalNC(nc: NonConformity): Promise { this.eventEmitter.emit('nc.critical', { nc }); } /** * Obtener horas de SLA según severidad */ private getSLAHours(severity: NCSeverity): number { const slaMap = { [NCSeverity.MINOR]: 168, // 7 días [NCSeverity.MAJOR]: 72, // 3 días [NCSeverity.CRITICAL]: 24, // 1 día }; return slaMap[severity]; } /** * Agregar registro al historial */ private async addToHistory( ncId: string, fromStatus: NCStatus | null, toStatus: NCStatus, userId: string, reason: string, ): Promise { const history = this.historyRepo.create({ ncId, fromStatus, toStatus, changedBy: userId, changeReason: reason, }); await this.historyRepo.save(history); } /** * Obtener NC por ID */ async findOne(id: string): Promise { const nc = await this.ncRepo.findOne({ where: { id }, relations: ['project', 'inspection', 'detector', 'assignee', 'verifier'], }); if (!nc) { throw new NotFoundException(`Non-conformity ${id} not found`); } return nc; } /** * Listar NC por proyecto */ async findByProject(projectId: string, tenantId: string): Promise { return this.ncRepo.find({ where: { projectId, tenantId }, relations: ['detector', 'assignee'], order: { ncDate: 'DESC' }, }); } /** * Obtener NC vencidas */ async getOverdueNCs(tenantId: string): Promise { return this.ncRepo.find({ where: { tenantId, status: In([NCStatus.OPEN, NCStatus.ASSIGNED, NCStatus.IN_CORRECTION]), slaDeadline: LessThan(new Date()), }, relations: ['project', 'assignee'], }); } } ``` ### 6.3 LaboratoryTestService ```typescript // src/modules/quality/services/laboratory-test.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { LaboratoryTest, TestStatus } from '../entities/laboratory-test.entity'; import { TestSpecimen } from '../entities/test-specimen.entity'; import { CreateLaboratoryTestDto, UpdateTestResultsDto } from '../dto'; @Injectable() export class LaboratoryTestService { constructor( @InjectRepository(LaboratoryTest) private testRepo: Repository, @InjectRepository(TestSpecimen) private specimenRepo: Repository, private eventEmitter: EventEmitter2, ) {} /** * Crear prueba de laboratorio */ async create(dto: CreateLaboratoryTestDto, tenantId: string): Promise { const year = new Date().getFullYear(); const count = await this.testRepo.count({ where: { tenantId } }); const testNumber = `LAB-${year}-${String(count + 1).padStart(5, '0')}`; const test = this.testRepo.create({ ...dto, testNumber, tenantId, status: TestStatus.PENDING, }); const saved = await this.testRepo.save(test); this.eventEmitter.emit('lab_test.created', { test: saved }); return saved; } /** * Actualizar resultados de prueba */ async updateResults(id: string, dto: UpdateTestResultsDto): Promise { const test = await this.findOne(id); test.testResults = dto.testResults; test.resultValue = dto.resultValue; test.resultUnit = dto.resultUnit; test.certificateUrl = dto.certificateUrl; test.certificateNumber = dto.certificateNumber; test.certificateDate = dto.certificateDate; test.observations = dto.observations; test.status = TestStatus.COMPLETED; // Evaluar cumplimiento const compliance = this.evaluateCompliance(test); test.isCompliant = compliance.isCompliant; test.compliancePercentage = compliance.percentage; const updated = await this.testRepo.save(test); // Si no cumple especificaciones, generar alerta if (!test.isCompliant) { this.eventEmitter.emit('lab_test.non_compliant', { test: updated }); } return updated; } /** * Evaluar cumplimiento de especificaciones */ private evaluateCompliance(test: LaboratoryTest): { isCompliant: boolean; percentage: number; } { const spec = test.specification; const result = test.resultValue; if (!spec || result === null || result === undefined) { return { isCompliant: false, percentage: 0 }; } // Verificar límites const minValue = spec.min_value; const maxValue = spec.max_value; const targetValue = spec.target_value; let isCompliant = true; let percentage = 100; if (minValue !== null && result < minValue) { isCompliant = false; percentage = (result / minValue) * 100; } if (maxValue !== null && result > maxValue) { isCompliant = false; percentage = (maxValue / result) * 100; } if (targetValue !== null) { const variance = Math.abs(result - targetValue); const tolerance = spec.tolerance || 5; const toleranceValue = (targetValue * tolerance) / 100; if (variance > toleranceValue) { isCompliant = false; } percentage = 100 - (variance / targetValue) * 100; } return { isCompliant, percentage: Math.max(0, Math.min(100, percentage)), }; } /** * Obtener prueba por ID */ async findOne(id: string): Promise { const test = await this.testRepo.findOne({ where: { id }, relations: ['project', 'specimens'], }); if (!test) { throw new NotFoundException(`Laboratory test ${id} not found`); } return test; } /** * Listar pruebas por proyecto */ async findByProject(projectId: string, tenantId: string): Promise { return this.testRepo.find({ where: { projectId, tenantId }, order: { samplingDate: 'DESC' }, }); } } ``` ### 6.4 PdfService (Generación de Reportes) ```typescript // src/modules/quality/services/pdf.service.ts import { Injectable } from '@nestjs/common'; import * as PDFDocument from 'pdfkit'; import { createWriteStream } from 'fs'; import { join } from 'path'; import { Inspection } from '../entities/inspection.entity'; import { NonConformity } from '../entities/non-conformity.entity'; @Injectable() export class PdfService { /** * Generar reporte de inspección */ async generateInspectionReport(inspection: Inspection): Promise { const doc = new PDFDocument({ size: 'LETTER', margins: { top: 50, bottom: 50, left: 50, right: 50 }, }); const fileName = `inspection-${inspection.inspectionNumber}.pdf`; const filePath = join(process.cwd(), 'uploads', 'reports', fileName); const stream = createWriteStream(filePath); doc.pipe(stream); // Header doc.fontSize(20).text('REPORTE DE INSPECCIÓN DE CALIDAD', { align: 'center' }); doc.moveDown(); // Información general doc.fontSize(12); doc.text(`Número de Inspección: ${inspection.inspectionNumber}`); doc.text(`Fecha: ${inspection.inspectionDate}`); doc.text(`Proyecto: ${inspection.project?.name || 'N/A'}`); doc.text(`Inspector: ${inspection.inspector?.name || 'N/A'}`); doc.moveDown(); // Resultados doc.fontSize(14).text('RESULTADOS', { underline: true }); doc.moveDown(0.5); doc.fontSize(12); doc.text(`Total de ítems: ${inspection.totalItems}`); doc.text(`Ítems aprobados: ${inspection.itemsPassed}`, { color: 'green' }); doc.text(`Ítems reprobados: ${inspection.itemsFailed}`, { color: 'red' }); doc.text(`Ítems N/A: ${inspection.itemsNa}`); doc.moveDown(); doc.text(`Score: ${inspection.score}%`); doc.text(`Estado: ${inspection.isApproved ? 'APROBADO' : 'RECHAZADO'}`, { bold: true, }); doc.moveDown(); // Observaciones if (inspection.generalNotes) { doc.fontSize(14).text('OBSERVACIONES', { underline: true }); doc.moveDown(0.5); doc.fontSize(12).text(inspection.generalNotes); doc.moveDown(); } // Firmas doc.fontSize(14).text('FIRMAS', { underline: true }); doc.moveDown(0.5); if (inspection.inspectorSignature) { doc.fontSize(10).text('Firma del Inspector:'); doc.image(inspection.inspectorSignature, { width: 150, height: 75 }); } doc.end(); return new Promise((resolve, reject) => { stream.on('finish', () => resolve(filePath)); stream.on('error', reject); }); } /** * Generar reporte de NC */ async generateNCReport(nc: NonConformity): Promise { const doc = new PDFDocument({ size: 'LETTER', margins: { top: 50, bottom: 50, left: 50, right: 50 }, }); const fileName = `nc-${nc.ncNumber}.pdf`; const filePath = join(process.cwd(), 'uploads', 'reports', fileName); const stream = createWriteStream(filePath); doc.pipe(stream); // Header doc.fontSize(20).text('REPORTE DE NO CONFORMIDAD', { align: 'center' }); doc.moveDown(); // Información general doc.fontSize(12); doc.text(`Número NC: ${nc.ncNumber}`); doc.text(`Fecha: ${nc.ncDate}`); doc.text(`Severidad: ${nc.severity.toUpperCase()}`, { color: nc.severity === 'critical' ? 'red' : nc.severity === 'major' ? 'orange' : 'black', }); doc.text(`Estado: ${nc.status}`); doc.moveDown(); // Descripción doc.fontSize(14).text('DESCRIPCIÓN', { underline: true }); doc.moveDown(0.5); doc.fontSize(12).text(nc.description); doc.moveDown(); // Acción correctiva if (nc.correctiveAction) { doc.fontSize(14).text('ACCIÓN CORRECTIVA', { underline: true }); doc.moveDown(0.5); doc.fontSize(12).text(nc.correctiveAction); doc.moveDown(); } // Causa raíz if (nc.rootCause) { doc.fontSize(14).text('CAUSA RAÍZ', { underline: true }); doc.moveDown(0.5); doc.fontSize(12).text(nc.rootCause); doc.moveDown(); } doc.end(); return new Promise((resolve, reject) => { stream.on('finish', () => resolve(filePath)); stream.on('error', reject); }); } } ``` --- ## 7. Controllers (API Endpoints) ### 7.1 InspectionsController ```typescript // src/modules/quality/controllers/inspections.controller.ts import { Controller, Get, Post, Put, Param, Body, Query, UseGuards, Request, UploadedFiles, UseInterceptors, } from '@nestjs/common'; import { FilesInterceptor } from '@nestjs/platform-express'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { InspectionService } from '../services/inspection.service'; import { CreateInspectionDto, SubmitInspectionDto } from '../dto'; @Controller('api/quality/inspections') @UseGuards(JwtAuthGuard, RolesGuard) export class InspectionsController { constructor(private inspectionService: InspectionService) {} /** * POST /api/quality/inspections * Crear inspección */ @Post() @Roles('inspector', 'site_supervisor', 'quality_manager', 'admin') async create(@Body() dto: CreateInspectionDto, @Request() req) { return this.inspectionService.create(dto, req.user.tenantId); } /** * GET /api/quality/inspections/:id * Obtener inspección por ID */ @Get(':id') async findOne(@Param('id') id: string) { return this.inspectionService.findOne(id); } /** * POST /api/quality/inspections/:id/submit * Enviar inspección (firmar y completar) */ @Post(':id/submit') @Roles('inspector', 'site_supervisor', 'quality_manager', 'admin') async submit(@Param('id') id: string, @Body() dto: SubmitInspectionDto) { return this.inspectionService.submit(id, dto); } /** * GET /api/quality/inspections/project/:projectId * Listar inspecciones por proyecto */ @Get('project/:projectId') async findByProject(@Param('projectId') projectId: string, @Request() req) { return this.inspectionService.findByProject(projectId, req.user.tenantId); } /** * POST /api/quality/inspections/:id/upload-photos * Subir fotos de inspección */ @Post(':id/upload-photos') @UseInterceptors(FilesInterceptor('photos', 20)) async uploadPhotos( @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[], ) { // TODO: Implementar subida a S3 y retornar URLs return { message: 'Photos uploaded', count: files.length }; } } ``` ### 7.2 NonConformitiesController ```typescript // src/modules/quality/controllers/non-conformities.controller.ts import { Controller, Get, Post, Put, Param, Body, UseGuards, Request, } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { NonConformityService } from '../services/non-conformity.service'; import { CreateNonConformityDto, AssignNonConformityDto, CorrectNonConformityDto, VerifyNonConformityDto, } from '../dto'; @Controller('api/quality/non-conformities') @UseGuards(JwtAuthGuard, RolesGuard) export class NonConformitiesController { constructor(private ncService: NonConformityService) {} /** * POST /api/quality/non-conformities * Crear NC */ @Post() @Roles('inspector', 'site_supervisor', 'quality_manager', 'admin') async create(@Body() dto: CreateNonConformityDto, @Request() req) { return this.ncService.create(dto, req.user.tenantId); } /** * GET /api/quality/non-conformities/:id * Obtener NC por ID */ @Get(':id') async findOne(@Param('id') id: string) { return this.ncService.findOne(id); } /** * POST /api/quality/non-conformities/:id/assign * Asignar NC */ @Post(':id/assign') @Roles('site_supervisor', 'quality_manager', 'project_manager', 'admin') async assign( @Param('id') id: string, @Body() dto: AssignNonConformityDto, @Request() req, ) { return this.ncService.assign(id, dto, req.user.sub); } /** * POST /api/quality/non-conformities/:id/correct * Registrar acción correctiva */ @Post(':id/correct') async correct( @Param('id') id: string, @Body() dto: CorrectNonConformityDto, @Request() req, ) { return this.ncService.correct(id, dto, req.user.sub); } /** * POST /api/quality/non-conformities/:id/verify * Verificar cierre de NC */ @Post(':id/verify') @Roles('inspector', 'quality_manager', 'admin') async verify( @Param('id') id: string, @Body() dto: VerifyNonConformityDto, @Request() req, ) { return this.ncService.verify(id, dto, req.user.sub); } /** * POST /api/quality/non-conformities/:id/close * Cerrar NC */ @Post(':id/close') @Roles('quality_manager', 'admin') async close(@Param('id') id: string, @Body() body: { notes: string }, @Request() req) { return this.ncService.close(id, body.notes, req.user.sub); } /** * GET /api/quality/non-conformities/project/:projectId * Listar NC por proyecto */ @Get('project/:projectId') async findByProject(@Param('projectId') projectId: string, @Request() req) { return this.ncService.findByProject(projectId, req.user.tenantId); } /** * GET /api/quality/non-conformities/overdue * Obtener NC vencidas */ @Get('overdue') @Roles('quality_manager', 'project_manager', 'admin') async getOverdue(@Request() req) { return this.ncService.getOverdueNCs(req.user.tenantId); } } ``` ### 7.3 LaboratoryTestsController ```typescript // src/modules/quality/controllers/laboratory-tests.controller.ts import { Controller, Get, Post, Put, Param, Body, UseGuards, Request, } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; import { LaboratoryTestService } from '../services/laboratory-test.service'; import { CreateLaboratoryTestDto, UpdateTestResultsDto } from '../dto'; @Controller('api/quality/laboratory-tests') @UseGuards(JwtAuthGuard, RolesGuard) export class LaboratoryTestsController { constructor(private testService: LaboratoryTestService) {} /** * POST /api/quality/laboratory-tests * Crear prueba de laboratorio */ @Post() @Roles('quality_manager', 'site_supervisor', 'admin') async create(@Body() dto: CreateLaboratoryTestDto, @Request() req) { return this.testService.create(dto, req.user.tenantId); } /** * GET /api/quality/laboratory-tests/:id * Obtener prueba por ID */ @Get(':id') async findOne(@Param('id') id: string) { return this.testService.findOne(id); } /** * PUT /api/quality/laboratory-tests/:id/results * Actualizar resultados */ @Put(':id/results') @Roles('quality_manager', 'admin') async updateResults(@Param('id') id: string, @Body() dto: UpdateTestResultsDto) { return this.testService.updateResults(id, dto); } /** * GET /api/quality/laboratory-tests/project/:projectId * Listar pruebas por proyecto */ @Get('project/:projectId') async findByProject(@Param('projectId') projectId: string, @Request() req) { return this.testService.findByProject(projectId, req.user.tenantId); } } ``` --- ## 8. Module Configuration ```typescript // src/modules/quality/quality.module.ts import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ScheduleModule } from '@nestjs/schedule'; import { EventEmitterModule } from '@nestjs/event-emitter'; // Entities import { Checklist } from './entities/checklist.entity'; import { ChecklistItem } from './entities/checklist-item.entity'; import { Inspection } from './entities/inspection.entity'; import { InspectionResult } from './entities/inspection-result.entity'; import { NonConformity } from './entities/non-conformity.entity'; import { NCHistory } from './entities/nc-history.entity'; import { LaboratoryTest } from './entities/laboratory-test.entity'; import { TestSpecimen } from './entities/test-specimen.entity'; import { Certification } from './entities/certification.entity'; import { QualityAttachment } from './entities/quality-attachment.entity'; // Services import { InspectionService } from './services/inspection.service'; import { NonConformityService } from './services/non-conformity.service'; import { LaboratoryTestService } from './services/laboratory-test.service'; import { CertificationService } from './services/certification.service'; import { PdfService } from './services/pdf.service'; // Controllers import { InspectionsController } from './controllers/inspections.controller'; import { NonConformitiesController } from './controllers/non-conformities.controller'; import { LaboratoryTestsController } from './controllers/laboratory-tests.controller'; import { CertificationsController } from './controllers/certifications.controller'; @Module({ imports: [ TypeOrmModule.forFeature([ Checklist, ChecklistItem, Inspection, InspectionResult, NonConformity, NCHistory, LaboratoryTest, TestSpecimen, Certification, QualityAttachment, ]), ScheduleModule.forRoot(), EventEmitterModule.forRoot(), ], controllers: [ InspectionsController, NonConformitiesController, LaboratoryTestsController, CertificationsController, ], providers: [ InspectionService, NonConformityService, LaboratoryTestService, CertificationService, PdfService, ], exports: [ InspectionService, NonConformityService, LaboratoryTestService, CertificationService, ], }) export class QualityModule {} ``` --- ## 9. Integraciones con Apps Móviles ### 9.1 Endpoints para MOB-003 (App Supervisor) ```typescript /** * Endpoints específicos para App Supervisor */ // GET /api/quality/mobile/checklists // Obtener checklists activos para inspección async getActiveChecklists(@Query('stage') stage: string, @Request() req) { // Retornar checklists filtrados por etapa constructiva } // POST /api/quality/mobile/inspections/offline-sync // Sincronizar inspecciones creadas offline async syncOfflineInspections(@Body() inspections: any[], @Request() req) { // Procesar batch de inspecciones desde móvil } // POST /api/quality/mobile/inspections/:id/photos // Subir fotos desde móvil (multipart/form-data) @UseInterceptors(FilesInterceptor('photos', 10)) async uploadMobilePhotos( @Param('id') id: string, @UploadedFiles() files: Express.Multer.File[], ) { // Procesar fotos, extraer EXIF, subir a S3 } ``` ### 9.2 Endpoints para MOB-004 (App Capataz) ```typescript /** * Endpoints específicos para App Capataz */ // POST /api/quality/mobile/laboratory-tests/samples // Registrar toma de muestras desde campo async registerSample(@Body() dto: RegisterSampleDto, @Request() req) { // Crear registro de espécimen con geolocalización } // POST /api/quality/mobile/certifications/validate // Validar certificado de material en obra async validateCertificate(@Body() dto: { certificateNumber: string }) { // Verificar validez de certificado } ``` --- ## 10. Criterios de Aceptación Técnicos - [x] Schema `quality` creado con 10+ tablas y relaciones - [x] Entities TypeORM con decoradores PostGIS - [x] Services con lógica de negocio completa - [x] Workflow de NC con estados y SLA - [x] Workflow CAPA (Corrective and Preventive Action) - [x] Cron jobs para verificación de SLA vencidos - [x] Endpoints REST para CRUD completo - [x] Soporte para captura móvil (MOB-003, MOB-004) - [x] Generación de reportes PDF con PDFKit - [x] Georreferenciación de inspecciones y NC - [x] Sistema de eventos para notificaciones - [x] Evaluación automática de cumplimiento de pruebas - [x] Validación de DTOs con class-validator - [x] Multi-tenancy implementado - [ ] Tests unitarios >80% coverage - [ ] Integración con almacenamiento S3 - [ ] Documentación API con Swagger --- ## 11. Roadmap de Implementación ### Sprint 1-2: Fundamentos (Semanas 1-4) - [x] Setup schema quality - [x] Entities: Checklist, ChecklistItem, Inspection - [x] Services básicos de inspecciones - [x] Endpoints CRUD inspecciones ### Sprint 3-4: No Conformidades (Semanas 5-8) - [x] Entities: NonConformity, NCHistory - [x] Service con workflow completo NC - [x] Cron jobs SLA - [x] Endpoints NC y CAPA ### Sprint 5-6: Laboratorio (Semanas 9-12) - [x] Entities: LaboratoryTest, TestSpecimen - [x] Service con evaluación de cumplimiento - [x] Endpoints pruebas de laboratorio ### Sprint 7-8: Certificaciones y PDF (Semanas 13-16) - [ ] Entity: Certification - [ ] Service certificaciones - [ ] PdfService completo - [ ] Generación de reportes ### Sprint 9-10: Apps Móviles (Semanas 17-20) - [ ] Endpoints específicos MOB-003 - [ ] Endpoints específicos MOB-004 - [ ] Sincronización offline - [ ] Procesamiento de fotos con EXIF ### Sprint 11-12: Integraciones y Testing (Semanas 21-24) - [ ] Integración con S3 - [ ] Event handlers para notificaciones - [ ] Tests unitarios e integración - [ ] Documentación Swagger **Duración Total:** 24 semanas (6 meses) --- **Fecha:** 2025-12-06 **Preparado por:** Backend Team **Versión:** 1.0 **Estado:** Listo para Implementación