2817 lines
76 KiB
Markdown
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
|