erp-construccion/docs/02-definicion-modulos/MAI-006-calidad/especificaciones/ET-CAL-001-backend.md

76 KiB

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

- 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

- 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

- 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

-- =====================================================
-- 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

// 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

// 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

// 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

// 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

// 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

// 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

// 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<Inspection>,
    @InjectRepository(InspectionResult)
    private resultRepo: Repository<InspectionResult>,
    @InjectRepository(Checklist)
    private checklistRepo: Repository<Checklist>,
    private ncService: NonConformityService,
    private pdfService: PdfService,
    private eventEmitter: EventEmitter2,
  ) {}

  /**
   * Crear inspección
   */
  async create(dto: CreateInspectionDto, tenantId: string): Promise<Inspection> {
    // 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<void> {
    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<Inspection> {
    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<Inspection> {
    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<Inspection[]> {
    return this.inspectionRepo.find({
      where: { projectId, tenantId },
      relations: ['checklist', 'inspector'],
      order: { inspectionDate: 'DESC' },
    });
  }
}

6.2 NonConformityService

// 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<NonConformity>,
    @InjectRepository(NCHistory)
    private historyRepo: Repository<NCHistory>,
    private eventEmitter: EventEmitter2,
  ) {}

  /**
   * Crear No Conformidad
   */
  async create(dto: CreateNonConformityDto, tenantId: string): Promise<NonConformity> {
    // 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<NonConformity> {
    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<NonConformity> {
    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<NonConformity> {
    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<NonConformity> {
    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<NonConformity> {
    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<void> {
    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<void> {
    // 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<void> {
    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<void> {
    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<NonConformity> {
    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<NonConformity[]> {
    return this.ncRepo.find({
      where: { projectId, tenantId },
      relations: ['detector', 'assignee'],
      order: { ncDate: 'DESC' },
    });
  }

  /**
   * Obtener NC vencidas
   */
  async getOverdueNCs(tenantId: string): Promise<NonConformity[]> {
    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

// 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<LaboratoryTest>,
    @InjectRepository(TestSpecimen)
    private specimenRepo: Repository<TestSpecimen>,
    private eventEmitter: EventEmitter2,
  ) {}

  /**
   * Crear prueba de laboratorio
   */
  async create(dto: CreateLaboratoryTestDto, tenantId: string): Promise<LaboratoryTest> {
    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<LaboratoryTest> {
    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<LaboratoryTest> {
    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<LaboratoryTest[]> {
    return this.testRepo.find({
      where: { projectId, tenantId },
      order: { samplingDate: 'DESC' },
    });
  }
}

6.4 PdfService (Generación de Reportes)

// 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<string> {
    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<string> {
    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

// 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

// 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

// 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

// 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)

/**
 * 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)

/**
 * 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

  • Schema quality creado con 10+ tablas y relaciones
  • Entities TypeORM con decoradores PostGIS
  • Services con lógica de negocio completa
  • Workflow de NC con estados y SLA
  • Workflow CAPA (Corrective and Preventive Action)
  • Cron jobs para verificación de SLA vencidos
  • Endpoints REST para CRUD completo
  • Soporte para captura móvil (MOB-003, MOB-004)
  • Generación de reportes PDF con PDFKit
  • Georreferenciación de inspecciones y NC
  • Sistema de eventos para notificaciones
  • Evaluación automática de cumplimiento de pruebas
  • Validación de DTOs con class-validator
  • 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)

  • Setup schema quality
  • Entities: Checklist, ChecklistItem, Inspection
  • Services básicos de inspecciones
  • Endpoints CRUD inspecciones

Sprint 3-4: No Conformidades (Semanas 5-8)

  • Entities: NonConformity, NCHistory
  • Service con workflow completo NC
  • Cron jobs SLA
  • Endpoints NC y CAPA

Sprint 5-6: Laboratorio (Semanas 9-12)

  • Entities: LaboratoryTest, TestSpecimen
  • Service con evaluación de cumplimiento
  • 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