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

2817 lines
76 KiB
Markdown

# 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<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
```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<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
```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<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)
```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<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
```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