70 KiB
ET-SEG-001: Especificación Técnica - Base de Datos
Módulo: MAI-007 - Seguridad Industrial Versión: 1.0.0 Fecha: 2025-12-06 Responsable: Database Agent
Resumen Ejecutivo
Este documento especifica el diseño completo de la base de datos para el módulo de Seguridad Industrial (MAI-007) del proyecto de construcción. El schema safety_management gestiona EPP (Equipo de Protección Personal), inspecciones de seguridad, incidentes laborales, investigaciones y capacitaciones.
Contexto del Stack
motor: PostgreSQL 15+
extensiones:
- uuid-ossp # Generación de UUIDs
- pg_trgm # Búsqueda fuzzy por trigram
- btree_gist # Índices GiST para exclusión
- pgcrypto # Funciones criptográficas
- postgis # Geolocalización GPS (opcional)
orm: Prisma 5.x
migraciones: Prisma migrations
schema: safety_management
Schema Principal: safety_management
Justificación
- Separación de responsabilidades: Aísla toda la funcionalidad de seguridad industrial
- Multi-tenant: Todas las tablas incluyen
tenant_idpara aislamiento de constructoras - Auditoría completa: Registro de todas las operaciones críticas de seguridad
- Normativas: Cumple con NOM-031-STPS-2011 y NOM-017-STPS-2008
ENUMs
1. epp_type (Tipos de EPP)
CREATE TYPE safety_management.epp_type AS ENUM (
'helmet', -- Casco
'safety_glasses', -- Lentes de seguridad
'goggles', -- Gafas protectoras
'face_shield', -- Careta
'ear_plugs', -- Tapones auditivos
'ear_muffs', -- Orejeras
'respirator', -- Respirador
'dust_mask', -- Cubrebocas
'work_gloves', -- Guantes de trabajo
'cut_resistant_gloves', -- Guantes anticorte
'electrical_gloves', -- Guantes dieléctricos
'safety_boots', -- Botas de seguridad
'rubber_boots', -- Botas de hule
'safety_vest', -- Chaleco de seguridad
'reflective_vest', -- Chaleco reflejante
'harness', -- Arnés de seguridad
'lifeline', -- Línea de vida
'rain_suit', -- Impermeable
'coveralls', -- Overol
'welding_jacket', -- Chaqueta de soldadura
'other' -- Otro
);
COMMENT ON TYPE safety_management.epp_type IS 'Tipos de Equipo de Protección Personal según NOM-017-STPS-2008';
2. epp_status (Estados de EPP)
CREATE TYPE safety_management.epp_status AS ENUM (
'available', -- Disponible en almacén
'assigned', -- Asignado a trabajador
'in_use', -- En uso activo
'expired', -- Vida útil vencida
'damaged', -- Dañado o defectuoso
'lost', -- Extraviado
'retired' -- Dado de baja
);
COMMENT ON TYPE safety_management.epp_status IS 'Estados del ciclo de vida del EPP';
3. risk_level (Nivel de Riesgo)
CREATE TYPE safety_management.risk_level AS ENUM (
'low', -- Riesgo bajo
'medium', -- Riesgo medio
'high', -- Riesgo alto
'critical' -- Riesgo crítico - requiere acción inmediata
);
COMMENT ON TYPE safety_management.risk_level IS 'Niveles de riesgo para hallazgos e incidentes';
4. incident_severity (Severidad de Incidente)
CREATE TYPE safety_management.incident_severity AS ENUM (
'near_miss', -- Casi accidente (sin lesiones)
'first_aid', -- Requiere primeros auxilios
'minor', -- Lesión menor sin días perdidos
'lost_time', -- Lesión con días perdidos
'serious', -- Lesión grave con hospitalización
'fatal', -- Accidente fatal
'property_damage' -- Daño a propiedad sin lesiones
);
COMMENT ON TYPE safety_management.incident_severity IS 'Clasificación de gravedad de incidentes laborales';
5. incident_status (Estado de Incidente)
CREATE TYPE safety_management.incident_status AS ENUM (
'reported', -- Recién reportado
'under_investigation', -- En investigación
'investigation_completed', -- Investigación completa
'actions_pending', -- Acciones correctivas pendientes
'closed', -- Cerrado con medidas implementadas
'archived' -- Archivado
);
COMMENT ON TYPE safety_management.incident_status IS 'Estados del ciclo de vida de un incidente';
6. action_status (Estado de Acción Correctiva)
CREATE TYPE safety_management.action_status AS ENUM (
'pending', -- Pendiente de iniciar
'in_progress', -- En progreso
'completed', -- Completada
'verified', -- Verificada y aprobada
'cancelled', -- Cancelada
'overdue' -- Vencida
);
COMMENT ON TYPE safety_management.action_status IS 'Estados de acciones correctivas y preventivas';
7. inspection_status (Estado de Inspección)
CREATE TYPE safety_management.inspection_status AS ENUM (
'scheduled', -- Programada
'in_progress', -- En ejecución
'completed', -- Completada
'cancelled', -- Cancelada
'overdue' -- Vencida sin realizar
);
COMMENT ON TYPE safety_management.inspection_status IS 'Estados de inspecciones de seguridad';
8. training_status (Estado de Capacitación)
CREATE TYPE safety_management.training_status AS ENUM (
'scheduled', -- Programada
'in_progress', -- En curso
'completed', -- Completada
'cancelled', -- Cancelada
'postponed' -- Pospuesta
);
COMMENT ON TYPE safety_management.training_status IS 'Estados de sesiones de capacitación';
Tablas
1. epp_catalog (Catálogo de EPP)
Descripción: Catálogo maestro de tipos de EPP con especificaciones técnicas y normativas aplicables.
-- ============================================================================
-- Schema: safety_management
-- Tabla: epp_catalog
-- Descripción: Catálogo de tipos de EPP con especificaciones técnicas
-- Módulo: MAI-007
-- ============================================================================
CREATE TABLE IF NOT EXISTS safety_management.epp_catalog (
-- Primary Key
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Multi-tenant
tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
-- Datos del EPP
code VARCHAR(50) NOT NULL,
name VARCHAR(200) NOT NULL,
epp_type safety_management.epp_type NOT NULL,
description TEXT,
-- Especificaciones técnicas
manufacturer VARCHAR(200),
model VARCHAR(100),
technical_specs JSONB DEFAULT '{}',
-- Normativas
norm_references TEXT[], -- ['NOM-017-STPS-2008', 'ANSI Z87.1']
certification_required BOOLEAN NOT NULL DEFAULT false,
certification_details JSONB DEFAULT '{}',
-- Vida útil
useful_life_days INTEGER NOT NULL DEFAULT 365, -- Días de vida útil
replacement_alert_days INTEGER NOT NULL DEFAULT 30, -- Alertar N días antes
-- Inventario
minimum_stock INTEGER NOT NULL DEFAULT 0,
unit_cost DECIMAL(12, 2),
-- Metadata
image_url TEXT,
care_instructions TEXT,
usage_guidelines TEXT,
metadata JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES core_users.users(id),
updated_by UUID REFERENCES core_users.users(id),
-- Soft delete
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES core_users.users(id),
-- Constraints
CONSTRAINT uq_epp_catalog_code_tenant UNIQUE (tenant_id, code),
CONSTRAINT chk_epp_catalog_useful_life CHECK (useful_life_days > 0),
CONSTRAINT chk_epp_catalog_alert_days CHECK (replacement_alert_days >= 0 AND replacement_alert_days < useful_life_days)
);
-- Comentarios
COMMENT ON TABLE safety_management.epp_catalog IS 'Catálogo maestro de tipos de EPP con especificaciones técnicas';
COMMENT ON COLUMN safety_management.epp_catalog.code IS 'Código único del EPP por tenant';
COMMENT ON COLUMN safety_management.epp_catalog.epp_type IS 'Tipo de EPP según enumeración';
COMMENT ON COLUMN safety_management.epp_catalog.norm_references IS 'Normativas aplicables (NOM-STPS, ANSI, etc.)';
COMMENT ON COLUMN safety_management.epp_catalog.useful_life_days IS 'Vida útil en días desde asignación';
COMMENT ON COLUMN safety_management.epp_catalog.replacement_alert_days IS 'Días antes de vencimiento para alertar';
-- ============================================================================
-- ÍNDICES
-- ============================================================================
-- Índice obligatorio para RLS
CREATE INDEX idx_epp_catalog_tenant_id ON safety_management.epp_catalog(tenant_id);
-- Índices de búsqueda
CREATE INDEX idx_epp_catalog_code ON safety_management.epp_catalog(code);
CREATE INDEX idx_epp_catalog_type ON safety_management.epp_catalog(epp_type);
CREATE INDEX idx_epp_catalog_active ON safety_management.epp_catalog(is_active) WHERE is_active = true;
-- Índice para búsqueda de texto
CREATE INDEX idx_epp_catalog_name_trgm ON safety_management.epp_catalog
USING gin (name gin_trgm_ops);
-- ============================================================================
-- RLS POLICIES
-- ============================================================================
ALTER TABLE safety_management.epp_catalog ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON safety_management.epp_catalog
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_insert ON safety_management.epp_catalog
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_update ON safety_management.epp_catalog
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_delete ON safety_management.epp_catalog
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger para updated_at
CREATE TRIGGER trg_epp_catalog_update_timestamp
BEFORE UPDATE ON safety_management.epp_catalog
FOR EACH ROW
EXECUTE FUNCTION core_shared.set_updated_at();
2. epp_assignments (Asignaciones de EPP)
Descripción: Registro de asignación de EPP a trabajadores con control de vida útil y trazabilidad.
-- ============================================================================
-- Schema: safety_management
-- Tabla: epp_assignments
-- Descripción: Asignaciones de EPP a trabajadores con control de vigencia
-- Módulo: MAI-007
-- ============================================================================
CREATE TABLE IF NOT EXISTS safety_management.epp_assignments (
-- Primary Key
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Multi-tenant
tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
-- Relaciones
epp_catalog_id UUID NOT NULL REFERENCES safety_management.epp_catalog(id),
worker_id UUID NOT NULL, -- FK a tabla de trabajadores de MAI-001
project_id UUID, -- FK a tabla de proyectos de MAI-002 (opcional)
-- Datos de la asignación
assignment_number VARCHAR(50) NOT NULL, -- Número de acta de entrega
assignment_date DATE NOT NULL DEFAULT CURRENT_DATE,
quantity INTEGER NOT NULL DEFAULT 1,
-- Control de vida útil
expiry_date DATE NOT NULL,
replaced_on DATE,
replacement_reason TEXT,
-- Estado
status safety_management.epp_status NOT NULL DEFAULT 'assigned',
-- Entrega y recepción
delivery_notes TEXT,
delivery_signature_url TEXT, -- Firma digital del trabajador
delivery_photo_url TEXT, -- Foto de evidencia
delivered_by UUID REFERENCES core_users.users(id),
-- Devolución
return_date DATE,
return_condition TEXT,
return_notes TEXT,
returned_to UUID REFERENCES core_users.users(id),
-- Costos
unit_cost DECIMAL(12, 2),
total_cost DECIMAL(12, 2) GENERATED ALWAYS AS (quantity * unit_cost) STORED,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES core_users.users(id),
updated_by UUID REFERENCES core_users.users(id),
-- Soft delete
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES core_users.users(id),
-- Constraints
CONSTRAINT uq_epp_assignment_number_tenant UNIQUE (tenant_id, assignment_number),
CONSTRAINT chk_epp_assignment_quantity CHECK (quantity > 0),
CONSTRAINT chk_epp_assignment_expiry CHECK (expiry_date >= assignment_date),
CONSTRAINT chk_epp_assignment_return CHECK (return_date IS NULL OR return_date >= assignment_date)
);
-- Comentarios
COMMENT ON TABLE safety_management.epp_assignments IS 'Asignaciones de EPP a trabajadores con control de vigencia';
COMMENT ON COLUMN safety_management.epp_assignments.assignment_number IS 'Número de acta de entrega único por tenant';
COMMENT ON COLUMN safety_management.epp_assignments.expiry_date IS 'Fecha de vencimiento calculada según vida útil';
COMMENT ON COLUMN safety_management.epp_assignments.delivery_signature_url IS 'URL de firma digital del trabajador en S3';
-- ============================================================================
-- ÍNDICES
-- ============================================================================
-- Índice obligatorio para RLS
CREATE INDEX idx_epp_assignments_tenant_id ON safety_management.epp_assignments(tenant_id);
-- Índices de relaciones
CREATE INDEX idx_epp_assignments_epp_catalog ON safety_management.epp_assignments(epp_catalog_id);
CREATE INDEX idx_epp_assignments_worker ON safety_management.epp_assignments(worker_id);
CREATE INDEX idx_epp_assignments_project ON safety_management.epp_assignments(project_id);
-- Índices de búsqueda y filtros
CREATE INDEX idx_epp_assignments_status ON safety_management.epp_assignments(status);
CREATE INDEX idx_epp_assignments_expiry ON safety_management.epp_assignments(expiry_date)
WHERE status IN ('assigned', 'in_use');
CREATE INDEX idx_epp_assignments_assignment_date ON safety_management.epp_assignments(assignment_date DESC);
-- Índice compuesto para alertas de vencimiento
CREATE INDEX idx_epp_assignments_alerts ON safety_management.epp_assignments(tenant_id, expiry_date, status)
WHERE status IN ('assigned', 'in_use') AND is_active = true;
-- ============================================================================
-- RLS POLICIES
-- ============================================================================
ALTER TABLE safety_management.epp_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON safety_management.epp_assignments
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_insert ON safety_management.epp_assignments
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_update ON safety_management.epp_assignments
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_delete ON safety_management.epp_assignments
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger para updated_at
CREATE TRIGGER trg_epp_assignments_update_timestamp
BEFORE UPDATE ON safety_management.epp_assignments
FOR EACH ROW
EXECUTE FUNCTION core_shared.set_updated_at();
-- Trigger para calcular expiry_date automáticamente
CREATE TRIGGER trg_epp_assignments_calculate_expiry
BEFORE INSERT OR UPDATE ON safety_management.epp_assignments
FOR EACH ROW
EXECUTE FUNCTION safety_management.calculate_epp_expiry_date();
3. safety_inspections (Inspecciones de Seguridad)
Descripción: Registro de inspecciones de seguridad realizadas en obra con checklist y hallazgos.
-- ============================================================================
-- Schema: safety_management
-- Tabla: safety_inspections
-- Descripción: Inspecciones de seguridad con geolocalización y evidencias
-- Módulo: MAI-007
-- ============================================================================
CREATE TABLE IF NOT EXISTS safety_management.safety_inspections (
-- Primary Key
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Multi-tenant
tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
-- Relaciones
project_id UUID NOT NULL, -- FK a tabla de proyectos de MAI-002
template_id UUID, -- FK a tabla de plantillas (safety.inspection_templates)
-- Datos de la inspección
inspection_number VARCHAR(50) NOT NULL,
inspection_type VARCHAR(100) NOT NULL, -- 'Diaria', 'Semanal', 'Mensual', 'Especial'
inspection_date DATE NOT NULL DEFAULT CURRENT_DATE,
inspection_time TIME NOT NULL DEFAULT CURRENT_TIME,
-- Ubicación
location_description TEXT,
gps_latitude DECIMAL(10, 8),
gps_longitude DECIMAL(11, 8),
gps_accuracy DECIMAL(8, 2), -- Precisión en metros
-- Inspector
inspector_id UUID NOT NULL REFERENCES core_users.users(id),
inspector_name VARCHAR(200),
inspector_position VARCHAR(100),
-- Resultados
status safety_management.inspection_status NOT NULL DEFAULT 'scheduled',
total_items_checked INTEGER NOT NULL DEFAULT 0,
conforming_items INTEGER NOT NULL DEFAULT 0,
non_conforming_items INTEGER NOT NULL DEFAULT 0,
not_applicable_items INTEGER NOT NULL DEFAULT 0,
overall_score DECIMAL(5, 2), -- Porcentaje de cumplimiento
-- Clasificación de hallazgos
critical_findings INTEGER NOT NULL DEFAULT 0,
high_risk_findings INTEGER NOT NULL DEFAULT 0,
medium_risk_findings INTEGER NOT NULL DEFAULT 0,
low_risk_findings INTEGER NOT NULL DEFAULT 0,
-- Datos del checklist
checklist_data JSONB NOT NULL DEFAULT '[]', -- Array de items con resultados
-- Observaciones
general_observations TEXT,
recommendations TEXT,
immediate_actions_taken TEXT,
-- Evidencias
photo_urls TEXT[], -- Array de URLs de fotos en S3
document_urls TEXT[],
-- Firma y aprobación
inspector_signature_url TEXT,
reviewed_by UUID REFERENCES core_users.users(id),
reviewed_at TIMESTAMPTZ,
review_notes TEXT,
-- Seguimiento
requires_followup BOOLEAN NOT NULL DEFAULT false,
followup_date DATE,
followup_completed BOOLEAN NOT NULL DEFAULT false,
-- Metadata
weather_conditions VARCHAR(100),
workers_present INTEGER,
metadata JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES core_users.users(id),
updated_by UUID REFERENCES core_users.users(id),
-- Soft delete
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES core_users.users(id),
-- Constraints
CONSTRAINT uq_safety_inspection_number_tenant UNIQUE (tenant_id, inspection_number),
CONSTRAINT chk_safety_inspection_items CHECK (
total_items_checked = (conforming_items + non_conforming_items + not_applicable_items)
),
CONSTRAINT chk_safety_inspection_findings CHECK (
non_conforming_items = (critical_findings + high_risk_findings + medium_risk_findings + low_risk_findings)
),
CONSTRAINT chk_safety_inspection_score CHECK (overall_score >= 0 AND overall_score <= 100)
);
-- Comentarios
COMMENT ON TABLE safety_management.safety_inspections IS 'Inspecciones de seguridad con geolocalización y evidencias fotográficas';
COMMENT ON COLUMN safety_management.safety_inspections.checklist_data IS 'Array JSONB con items del checklist y sus resultados';
COMMENT ON COLUMN safety_management.safety_inspections.gps_latitude IS 'Latitud GPS capturada en app móvil';
COMMENT ON COLUMN safety_management.safety_inspections.overall_score IS 'Porcentaje de cumplimiento (conforming / total * 100)';
-- ============================================================================
-- ÍNDICES
-- ============================================================================
-- Índice obligatorio para RLS
CREATE INDEX idx_safety_inspections_tenant_id ON safety_management.safety_inspections(tenant_id);
-- Índices de relaciones
CREATE INDEX idx_safety_inspections_project ON safety_management.safety_inspections(project_id);
CREATE INDEX idx_safety_inspections_inspector ON safety_management.safety_inspections(inspector_id);
CREATE INDEX idx_safety_inspections_template ON safety_management.safety_inspections(template_id);
-- Índices de búsqueda
CREATE INDEX idx_safety_inspections_status ON safety_management.safety_inspections(status);
CREATE INDEX idx_safety_inspections_date ON safety_management.safety_inspections(inspection_date DESC);
CREATE INDEX idx_safety_inspections_type ON safety_management.safety_inspections(inspection_type);
-- Índice para inspecciones con hallazgos críticos
CREATE INDEX idx_safety_inspections_critical ON safety_management.safety_inspections(tenant_id, project_id, critical_findings)
WHERE critical_findings > 0 AND is_active = true;
-- Índice para seguimiento
CREATE INDEX idx_safety_inspections_followup ON safety_management.safety_inspections(followup_date)
WHERE requires_followup = true AND followup_completed = false;
-- Índice GIN para búsqueda en checklist_data
CREATE INDEX idx_safety_inspections_checklist ON safety_management.safety_inspections
USING gin (checklist_data jsonb_path_ops);
-- ============================================================================
-- RLS POLICIES
-- ============================================================================
ALTER TABLE safety_management.safety_inspections ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON safety_management.safety_inspections
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_insert ON safety_management.safety_inspections
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_update ON safety_management.safety_inspections
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_delete ON safety_management.safety_inspections
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger para updated_at
CREATE TRIGGER trg_safety_inspections_update_timestamp
BEFORE UPDATE ON safety_management.safety_inspections
FOR EACH ROW
EXECUTE FUNCTION core_shared.set_updated_at();
-- Trigger para calcular overall_score automáticamente
CREATE TRIGGER trg_safety_inspections_calculate_score
BEFORE INSERT OR UPDATE ON safety_management.safety_inspections
FOR EACH ROW
EXECUTE FUNCTION safety_management.calculate_inspection_score();
4. incidents (Reportes de Incidentes)
Descripción: Registro de incidentes y accidentes laborales con geolocalización y evidencias.
-- ============================================================================
-- Schema: safety_management
-- Tabla: incidents
-- Descripción: Registro de incidentes y accidentes laborales
-- Módulo: MAI-007
-- ============================================================================
CREATE TABLE IF NOT EXISTS safety_management.incidents (
-- Primary Key
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Multi-tenant
tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
-- Relaciones
project_id UUID NOT NULL, -- FK a tabla de proyectos de MAI-002
-- Identificación del incidente
incident_number VARCHAR(50) NOT NULL,
incident_date DATE NOT NULL,
incident_time TIME NOT NULL,
reported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Clasificación
severity safety_management.incident_severity NOT NULL,
status safety_management.incident_status NOT NULL DEFAULT 'reported',
incident_type VARCHAR(100) NOT NULL, -- 'Caída', 'Golpe', 'Corte', etc.
-- Ubicación
location_description TEXT NOT NULL,
area VARCHAR(200),
gps_latitude DECIMAL(10, 8),
gps_longitude DECIMAL(11, 8),
gps_accuracy DECIMAL(8, 2),
-- Descripción del incidente
description TEXT NOT NULL,
immediate_cause TEXT,
activity_at_time TEXT,
weather_conditions VARCHAR(100),
-- Personas involucradas
affected_worker_id UUID, -- FK a tabla de trabajadores
affected_worker_name VARCHAR(200),
affected_worker_position VARCHAR(100),
affected_worker_age INTEGER,
affected_worker_experience_months INTEGER,
-- Lesiones
injury_type VARCHAR(200),
injury_description TEXT,
body_part_affected VARCHAR(200),
medical_attention_required BOOLEAN NOT NULL DEFAULT false,
hospitalization_required BOOLEAN NOT NULL DEFAULT false,
emergency_services_called BOOLEAN NOT NULL DEFAULT false,
-- Impacto laboral
work_days_lost INTEGER DEFAULT 0,
restricted_work_days INTEGER DEFAULT 0,
estimated_return_date DATE,
-- EPP
epp_being_used TEXT[],
epp_condition TEXT,
epp_adequate_for_task BOOLEAN,
-- Reportado por
reported_by_id UUID NOT NULL REFERENCES core_users.users(id),
reported_by_name VARCHAR(200),
reporter_position VARCHAR(100),
witness_names TEXT[],
-- Evidencias
photo_urls TEXT[],
document_urls TEXT[],
video_urls TEXT[],
-- Notificaciones
authorities_notified BOOLEAN NOT NULL DEFAULT false,
authorities_notified_at TIMESTAMPTZ,
authority_names TEXT[],
family_notified BOOLEAN NOT NULL DEFAULT false,
-- Costos (registrados posteriormente)
medical_costs DECIMAL(12, 2) DEFAULT 0,
legal_costs DECIMAL(12, 2) DEFAULT 0,
equipment_damage_costs DECIMAL(12, 2) DEFAULT 0,
productivity_loss_costs DECIMAL(12, 2) DEFAULT 0,
total_costs DECIMAL(12, 2) GENERATED ALWAYS AS (
COALESCE(medical_costs, 0) +
COALESCE(legal_costs, 0) +
COALESCE(equipment_damage_costs, 0) +
COALESCE(productivity_loss_costs, 0)
) STORED,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES core_users.users(id),
updated_by UUID REFERENCES core_users.users(id),
-- Soft delete
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES core_users.users(id),
-- Constraints
CONSTRAINT uq_incident_number_tenant UNIQUE (tenant_id, incident_number),
CONSTRAINT chk_incident_work_days CHECK (work_days_lost >= 0),
CONSTRAINT chk_incident_costs CHECK (
medical_costs >= 0 AND
legal_costs >= 0 AND
equipment_damage_costs >= 0 AND
productivity_loss_costs >= 0
)
);
-- Comentarios
COMMENT ON TABLE safety_management.incidents IS 'Registro de incidentes y accidentes laborales con geolocalización';
COMMENT ON COLUMN safety_management.incidents.severity IS 'Gravedad del incidente según clasificación';
COMMENT ON COLUMN safety_management.incidents.work_days_lost IS 'Días laborales perdidos por el trabajador';
COMMENT ON COLUMN safety_management.incidents.total_costs IS 'Costo total calculado automáticamente';
-- ============================================================================
-- ÍNDICES
-- ============================================================================
-- Índice obligatorio para RLS
CREATE INDEX idx_incidents_tenant_id ON safety_management.incidents(tenant_id);
-- Índices de relaciones
CREATE INDEX idx_incidents_project ON safety_management.incidents(project_id);
CREATE INDEX idx_incidents_worker ON safety_management.incidents(affected_worker_id);
CREATE INDEX idx_incidents_reporter ON safety_management.incidents(reported_by_id);
-- Índices de búsqueda y análisis
CREATE INDEX idx_incidents_severity ON safety_management.incidents(severity);
CREATE INDEX idx_incidents_status ON safety_management.incidents(status);
CREATE INDEX idx_incidents_date ON safety_management.incidents(incident_date DESC);
CREATE INDEX idx_incidents_type ON safety_management.incidents(incident_type);
-- Índice para KPIs de seguridad
CREATE INDEX idx_incidents_kpi ON safety_management.incidents(tenant_id, incident_date, severity)
WHERE is_active = true;
-- Índice para incidentes que requieren seguimiento
CREATE INDEX idx_incidents_pending ON safety_management.incidents(status)
WHERE status IN ('reported', 'under_investigation', 'actions_pending');
-- Índice para incidentes graves
CREATE INDEX idx_incidents_serious ON safety_management.incidents(tenant_id, project_id, severity)
WHERE severity IN ('serious', 'fatal') AND is_active = true;
-- ============================================================================
-- RLS POLICIES
-- ============================================================================
ALTER TABLE safety_management.incidents ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON safety_management.incidents
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_insert ON safety_management.incidents
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_update ON safety_management.incidents
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_delete ON safety_management.incidents
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger para updated_at
CREATE TRIGGER trg_incidents_update_timestamp
BEFORE UPDATE ON safety_management.incidents
FOR EACH ROW
EXECUTE FUNCTION core_shared.set_updated_at();
-- Trigger para notificaciones automáticas de incidentes graves
CREATE TRIGGER trg_incidents_notify_serious
AFTER INSERT OR UPDATE ON safety_management.incidents
FOR EACH ROW
WHEN (NEW.severity IN ('serious', 'fatal'))
EXECUTE FUNCTION safety_management.notify_serious_incident();
5. incident_investigations (Investigaciones de Incidentes)
Descripción: Investigación de causas raíz de incidentes con análisis de Ishikawa y acciones correctivas.
-- ============================================================================
-- Schema: safety_management
-- Tabla: incident_investigations
-- Descripción: Investigación de causas raíz de incidentes
-- Módulo: MAI-007
-- ============================================================================
CREATE TABLE IF NOT EXISTS safety_management.incident_investigations (
-- Primary Key
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Multi-tenant
tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
-- Relación
incident_id UUID NOT NULL REFERENCES safety_management.incidents(id) ON DELETE CASCADE,
-- Investigación
investigation_number VARCHAR(50) NOT NULL,
investigation_start_date DATE NOT NULL,
investigation_end_date DATE,
-- Equipo investigador
lead_investigator_id UUID NOT NULL REFERENCES core_users.users(id),
team_members UUID[], -- Array de IDs de usuarios
team_members_names TEXT[],
-- Metodología
investigation_method VARCHAR(100) DEFAULT 'Ishikawa', -- 'Ishikawa', '5 Whys', 'Root Cause Tree'
-- Análisis de causas (Ishikawa - Espina de Pescado)
people_factors TEXT[], -- Factores humanos
method_factors TEXT[], -- Factores de método/procedimiento
machine_factors TEXT[], -- Factores de equipo/maquinaria
material_factors TEXT[], -- Factores de materiales
environment_factors TEXT[], -- Factores ambientales
management_factors TEXT[], -- Factores de gestión
-- Causa raíz identificada
root_cause TEXT,
contributing_factors TEXT[],
-- Hallazgos
findings TEXT NOT NULL,
evidence_collected TEXT[],
witness_statements JSONB DEFAULT '[]', -- Array de testimonios
-- Conclusiones
conclusions TEXT,
preventability_assessment TEXT,
similar_incidents_reviewed INTEGER DEFAULT 0,
-- Recomendaciones
immediate_actions_recommended TEXT[],
corrective_actions_recommended TEXT[],
preventive_actions_recommended TEXT[],
-- Aprobación
approved_by UUID REFERENCES core_users.users(id),
approved_at TIMESTAMPTZ,
approval_notes TEXT,
-- Documentos
report_url TEXT, -- URL del informe oficial en S3
document_urls TEXT[],
photo_urls TEXT[],
-- Metadata
metadata JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES core_users.users(id),
updated_by UUID REFERENCES core_users.users(id),
-- Soft delete
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES core_users.users(id),
-- Constraints
CONSTRAINT uq_investigation_number_tenant UNIQUE (tenant_id, investigation_number),
CONSTRAINT uq_investigation_incident UNIQUE (incident_id), -- Una investigación por incidente
CONSTRAINT chk_investigation_dates CHECK (
investigation_end_date IS NULL OR investigation_end_date >= investigation_start_date
)
);
-- Comentarios
COMMENT ON TABLE safety_management.incident_investigations IS 'Investigación de causas raíz de incidentes con metodología Ishikawa';
COMMENT ON COLUMN safety_management.incident_investigations.people_factors IS 'Factores humanos en diagrama de Ishikawa';
COMMENT ON COLUMN safety_management.incident_investigations.root_cause IS 'Causa raíz identificada del incidente';
-- ============================================================================
-- ÍNDICES
-- ============================================================================
-- Índice obligatorio para RLS
CREATE INDEX idx_incident_investigations_tenant_id ON safety_management.incident_investigations(tenant_id);
-- Índices de relaciones
CREATE INDEX idx_incident_investigations_incident ON safety_management.incident_investigations(incident_id);
CREATE INDEX idx_incident_investigations_lead ON safety_management.incident_investigations(lead_investigator_id);
-- Índices de búsqueda
CREATE INDEX idx_incident_investigations_dates ON safety_management.incident_investigations(investigation_start_date DESC);
CREATE INDEX idx_incident_investigations_pending ON safety_management.incident_investigations(investigation_end_date)
WHERE investigation_end_date IS NULL AND is_active = true;
-- Índice GIN para búsqueda en testimonios
CREATE INDEX idx_incident_investigations_witnesses ON safety_management.incident_investigations
USING gin (witness_statements jsonb_path_ops);
-- ============================================================================
-- RLS POLICIES
-- ============================================================================
ALTER TABLE safety_management.incident_investigations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON safety_management.incident_investigations
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_insert ON safety_management.incident_investigations
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_update ON safety_management.incident_investigations
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_delete ON safety_management.incident_investigations
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger para updated_at
CREATE TRIGGER trg_incident_investigations_update_timestamp
BEFORE UPDATE ON safety_management.incident_investigations
FOR EACH ROW
EXECUTE FUNCTION core_shared.set_updated_at();
-- Trigger para actualizar estado del incidente al completar investigación
CREATE TRIGGER trg_incident_investigations_complete
AFTER UPDATE ON safety_management.incident_investigations
FOR EACH ROW
WHEN (NEW.investigation_end_date IS NOT NULL AND OLD.investigation_end_date IS NULL)
EXECUTE FUNCTION safety_management.update_incident_status_investigated();
6. trainings (Capacitaciones de Seguridad)
Descripción: Catálogo de capacitaciones de seguridad y programación de sesiones.
-- ============================================================================
-- Schema: safety_management
-- Tabla: trainings
-- Descripción: Sesiones de capacitación de seguridad programadas
-- Módulo: MAI-007
-- ============================================================================
CREATE TABLE IF NOT EXISTS safety_management.trainings (
-- Primary Key
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Multi-tenant
tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
-- Relación
project_id UUID, -- FK a tabla de proyectos (opcional, puede ser corporativa)
-- Datos de la capacitación
training_code VARCHAR(50) NOT NULL,
training_name VARCHAR(200) NOT NULL,
training_type VARCHAR(100) NOT NULL, -- 'Inducción', 'Trabajo en Altura', 'EPP', etc.
description TEXT,
-- Normativa
norm_references TEXT[], -- Normativas que respaldan la capacitación
is_mandatory BOOLEAN NOT NULL DEFAULT false,
required_for_positions TEXT[], -- Puestos que requieren esta capacitación
-- Programación
scheduled_date DATE NOT NULL,
scheduled_start_time TIME NOT NULL,
scheduled_end_time TIME NOT NULL,
duration_hours DECIMAL(4, 2),
-- Ubicación
location VARCHAR(200),
location_type VARCHAR(50) DEFAULT 'presencial', -- 'presencial', 'online', 'híbrida'
meeting_url TEXT, -- Para capacitaciones online
-- Instructor
instructor_id UUID REFERENCES core_users.users(id),
instructor_name VARCHAR(200),
instructor_credentials TEXT,
external_instructor BOOLEAN NOT NULL DEFAULT false,
instructor_company VARCHAR(200),
-- Capacidad
max_participants INTEGER,
min_participants INTEGER,
-- Estado
status safety_management.training_status NOT NULL DEFAULT 'scheduled',
-- Contenido
topics TEXT[],
learning_objectives TEXT[],
materials_provided TEXT[],
materials_urls TEXT[], -- URLs de materiales en S3
-- Evaluación
has_evaluation BOOLEAN NOT NULL DEFAULT false,
evaluation_type VARCHAR(50), -- 'test', 'práctica', 'ambos'
passing_score DECIMAL(5, 2), -- Calificación mínima aprobatoria
-- Certificación
issues_certificate BOOLEAN NOT NULL DEFAULT true,
certificate_template_url TEXT,
certificate_validity_months INTEGER, -- Meses de validez del certificado
-- Asistencia
registered_participants INTEGER DEFAULT 0,
actual_attendees INTEGER DEFAULT 0,
-- Resultados
average_score DECIMAL(5, 2),
passing_participants INTEGER DEFAULT 0,
failing_participants INTEGER DEFAULT 0,
-- Costos
instructor_fee DECIMAL(12, 2) DEFAULT 0,
materials_cost DECIMAL(12, 2) DEFAULT 0,
venue_cost DECIMAL(12, 2) DEFAULT 0,
total_cost DECIMAL(12, 2) GENERATED ALWAYS AS (
COALESCE(instructor_fee, 0) +
COALESCE(materials_cost, 0) +
COALESCE(venue_cost, 0)
) STORED,
-- Feedback
feedback_summary TEXT,
average_satisfaction DECIMAL(3, 2), -- Escala 1-5
-- Metadata
metadata JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES core_users.users(id),
updated_by UUID REFERENCES core_users.users(id),
-- Soft delete
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES core_users.users(id),
-- Constraints
CONSTRAINT uq_training_code_tenant UNIQUE (tenant_id, training_code),
CONSTRAINT chk_training_times CHECK (scheduled_end_time > scheduled_start_time),
CONSTRAINT chk_training_capacity CHECK (
max_participants IS NULL OR
min_participants IS NULL OR
max_participants >= min_participants
),
CONSTRAINT chk_training_score CHECK (
passing_score IS NULL OR
(passing_score >= 0 AND passing_score <= 100)
),
CONSTRAINT chk_training_participants CHECK (
registered_participants >= 0 AND
actual_attendees >= 0 AND
actual_attendees <= registered_participants
)
);
-- Comentarios
COMMENT ON TABLE safety_management.trainings IS 'Sesiones de capacitación de seguridad programadas';
COMMENT ON COLUMN safety_management.trainings.is_mandatory IS 'Indica si es capacitación obligatoria según normativa';
COMMENT ON COLUMN safety_management.trainings.certificate_validity_months IS 'Meses de validez del certificado antes de requerir renovación';
-- ============================================================================
-- ÍNDICES
-- ============================================================================
-- Índice obligatorio para RLS
CREATE INDEX idx_trainings_tenant_id ON safety_management.trainings(tenant_id);
-- Índices de relaciones
CREATE INDEX idx_trainings_project ON safety_management.trainings(project_id);
CREATE INDEX idx_trainings_instructor ON safety_management.trainings(instructor_id);
-- Índices de búsqueda
CREATE INDEX idx_trainings_code ON safety_management.trainings(training_code);
CREATE INDEX idx_trainings_type ON safety_management.trainings(training_type);
CREATE INDEX idx_trainings_status ON safety_management.trainings(status);
CREATE INDEX idx_trainings_date ON safety_management.trainings(scheduled_date DESC);
-- Índice para capacitaciones próximas
CREATE INDEX idx_trainings_upcoming ON safety_management.trainings(scheduled_date)
WHERE status = 'scheduled' AND is_active = true;
-- Índice para capacitaciones obligatorias
CREATE INDEX idx_trainings_mandatory ON safety_management.trainings(tenant_id, is_mandatory)
WHERE is_mandatory = true AND is_active = true;
-- Índice para búsqueda de texto en nombre
CREATE INDEX idx_trainings_name_trgm ON safety_management.trainings
USING gin (training_name gin_trgm_ops);
-- ============================================================================
-- RLS POLICIES
-- ============================================================================
ALTER TABLE safety_management.trainings ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON safety_management.trainings
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_insert ON safety_management.trainings
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_update ON safety_management.trainings
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_delete ON safety_management.trainings
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger para updated_at
CREATE TRIGGER trg_trainings_update_timestamp
BEFORE UPDATE ON safety_management.trainings
FOR EACH ROW
EXECUTE FUNCTION core_shared.set_updated_at();
-- Trigger para notificar capacitaciones próximas
CREATE TRIGGER trg_trainings_notify_upcoming
AFTER INSERT OR UPDATE ON safety_management.trainings
FOR EACH ROW
WHEN (NEW.scheduled_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '7 days')
EXECUTE FUNCTION safety_management.notify_upcoming_training();
7. training_attendance (Registro de Asistencia)
Descripción: Registro de asistencia y evaluaciones de participantes en capacitaciones.
-- ============================================================================
-- Schema: safety_management
-- Tabla: training_attendance
-- Descripción: Registro de asistencia y evaluaciones de capacitaciones
-- Módulo: MAI-007
-- ============================================================================
CREATE TABLE IF NOT EXISTS safety_management.training_attendance (
-- Primary Key
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- Multi-tenant
tenant_id UUID NOT NULL REFERENCES core_tenants.tenants(id) ON DELETE CASCADE,
-- Relaciones
training_id UUID NOT NULL REFERENCES safety_management.trainings(id) ON DELETE CASCADE,
worker_id UUID NOT NULL, -- FK a tabla de trabajadores
-- Datos del participante
worker_name VARCHAR(200) NOT NULL,
worker_position VARCHAR(100),
worker_company VARCHAR(200), -- Empresa (constructora, subcontratista)
-- Registro
registration_date TIMESTAMPTZ NOT NULL DEFAULT now(),
registered_by UUID REFERENCES core_users.users(id),
-- Asistencia
attended BOOLEAN NOT NULL DEFAULT false,
check_in_time TIMESTAMPTZ,
check_out_time TIMESTAMPTZ,
attendance_method VARCHAR(50), -- 'manual', 'qr', 'biometric', 'digital_signature'
-- Firma
signature_url TEXT, -- URL de firma digital en S3
-- Evaluación
evaluation_taken BOOLEAN NOT NULL DEFAULT false,
evaluation_date TIMESTAMPTZ,
score DECIMAL(5, 2),
passed BOOLEAN,
evaluation_answers JSONB DEFAULT '{}',
-- Certificado
certificate_issued BOOLEAN NOT NULL DEFAULT false,
certificate_number VARCHAR(100),
certificate_issue_date DATE,
certificate_expiry_date DATE,
certificate_url TEXT, -- URL del certificado en S3
-- Feedback del participante
satisfaction_rating INTEGER, -- Escala 1-5
feedback_comments TEXT,
-- Metadata
metadata JSONB DEFAULT '{}',
-- Auditoría
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES core_users.users(id),
updated_by UUID REFERENCES core_users.users(id),
-- Soft delete
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
deleted_by UUID REFERENCES core_users.users(id),
-- Constraints
CONSTRAINT uq_training_attendance_worker UNIQUE (training_id, worker_id),
CONSTRAINT chk_training_attendance_score CHECK (
score IS NULL OR (score >= 0 AND score <= 100)
),
CONSTRAINT chk_training_attendance_rating CHECK (
satisfaction_rating IS NULL OR
(satisfaction_rating >= 1 AND satisfaction_rating <= 5)
),
CONSTRAINT chk_training_attendance_times CHECK (
check_out_time IS NULL OR
check_in_time IS NULL OR
check_out_time >= check_in_time
)
);
-- Comentarios
COMMENT ON TABLE safety_management.training_attendance IS 'Registro de asistencia y evaluaciones de capacitaciones';
COMMENT ON COLUMN safety_management.training_attendance.attendance_method IS 'Método de registro: manual, QR, biométrico, firma digital';
COMMENT ON COLUMN safety_management.training_attendance.certificate_expiry_date IS 'Fecha de vencimiento del certificado';
-- ============================================================================
-- ÍNDICES
-- ============================================================================
-- Índice obligatorio para RLS
CREATE INDEX idx_training_attendance_tenant_id ON safety_management.training_attendance(tenant_id);
-- Índices de relaciones
CREATE INDEX idx_training_attendance_training ON safety_management.training_attendance(training_id);
CREATE INDEX idx_training_attendance_worker ON safety_management.training_attendance(worker_id);
-- Índices de búsqueda
CREATE INDEX idx_training_attendance_attended ON safety_management.training_attendance(attended);
CREATE INDEX idx_training_attendance_passed ON safety_management.training_attendance(passed)
WHERE evaluation_taken = true;
-- Índice para certificados próximos a vencer
CREATE INDEX idx_training_attendance_expiry ON safety_management.training_attendance(certificate_expiry_date)
WHERE certificate_issued = true AND is_active = true;
-- Índice para certificados vigentes por trabajador
CREATE INDEX idx_training_attendance_valid_certs ON safety_management.training_attendance(worker_id, certificate_expiry_date)
WHERE certificate_issued = true AND certificate_expiry_date >= CURRENT_DATE;
-- Índice GIN para búsqueda en respuestas de evaluación
CREATE INDEX idx_training_attendance_answers ON safety_management.training_attendance
USING gin (evaluation_answers jsonb_path_ops);
-- ============================================================================
-- RLS POLICIES
-- ============================================================================
ALTER TABLE safety_management.training_attendance ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_select ON safety_management.training_attendance
FOR SELECT
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_insert ON safety_management.training_attendance
FOR INSERT
WITH CHECK (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_update ON safety_management.training_attendance
FOR UPDATE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
CREATE POLICY tenant_isolation_delete ON safety_management.training_attendance
FOR DELETE
USING (tenant_id = current_setting('app.current_tenant_id', true)::uuid);
-- ============================================================================
-- TRIGGERS
-- ============================================================================
-- Trigger para updated_at
CREATE TRIGGER trg_training_attendance_update_timestamp
BEFORE UPDATE ON safety_management.training_attendance
FOR EACH ROW
EXECUTE FUNCTION core_shared.set_updated_at();
-- Trigger para calcular passed automáticamente al registrar score
CREATE TRIGGER trg_training_attendance_calculate_passed
BEFORE INSERT OR UPDATE ON safety_management.training_attendance
FOR EACH ROW
WHEN (NEW.score IS NOT NULL)
EXECUTE FUNCTION safety_management.calculate_training_passed();
-- Trigger para actualizar contadores de la capacitación
CREATE TRIGGER trg_training_attendance_update_training_stats
AFTER INSERT OR UPDATE ON safety_management.training_attendance
FOR EACH ROW
EXECUTE FUNCTION safety_management.update_training_statistics();
-- Trigger para alertar vencimiento de certificados
CREATE TRIGGER trg_training_attendance_alert_expiry
AFTER INSERT OR UPDATE ON safety_management.training_attendance
FOR EACH ROW
WHEN (NEW.certificate_expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days')
EXECUTE FUNCTION safety_management.alert_certificate_expiry();
Funciones Auxiliares
1. calculate_epp_expiry_date()
-- ============================================================================
-- Función: calculate_epp_expiry_date
-- Descripción: Calcula automáticamente la fecha de vencimiento del EPP
-- ============================================================================
CREATE OR REPLACE FUNCTION safety_management.calculate_epp_expiry_date()
RETURNS TRIGGER AS $$
DECLARE
v_useful_life_days INTEGER;
BEGIN
-- Obtener vida útil del catálogo
SELECT useful_life_days INTO v_useful_life_days
FROM safety_management.epp_catalog
WHERE id = NEW.epp_catalog_id;
-- Calcular fecha de vencimiento
IF v_useful_life_days IS NOT NULL THEN
NEW.expiry_date := NEW.assignment_date + v_useful_life_days;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION safety_management.calculate_epp_expiry_date() IS
'Calcula automáticamente la fecha de vencimiento del EPP basado en la vida útil del catálogo';
2. calculate_inspection_score()
-- ============================================================================
-- Función: calculate_inspection_score
-- Descripción: Calcula el score de cumplimiento de la inspección
-- ============================================================================
CREATE OR REPLACE FUNCTION safety_management.calculate_inspection_score()
RETURNS TRIGGER AS $$
BEGIN
-- Calcular porcentaje de cumplimiento
IF NEW.total_items_checked > 0 THEN
NEW.overall_score := ROUND(
(NEW.conforming_items::DECIMAL / NEW.total_items_checked::DECIMAL) * 100,
2
);
ELSE
NEW.overall_score := 0;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION safety_management.calculate_inspection_score() IS
'Calcula el porcentaje de cumplimiento de la inspección automáticamente';
3. notify_serious_incident()
-- ============================================================================
-- Función: notify_serious_incident
-- Descripción: Envía notificaciones automáticas para incidentes graves
-- ============================================================================
CREATE OR REPLACE FUNCTION safety_management.notify_serious_incident()
RETURNS TRIGGER AS $$
BEGIN
-- Insertar notificación en tabla de notificaciones del core
-- Notificar a: Coordinador de Seguridad, Residente, Director General
-- TODO: Implementar lógica de notificación usando sistema MGN-008
-- Por ahora, solo registramos en log
RAISE NOTICE 'INCIDENTE GRAVE REPORTADO: % - Severidad: %', NEW.incident_number, NEW.severity;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION safety_management.notify_serious_incident() IS
'Envía notificaciones automáticas cuando se reporta un incidente grave o fatal';
4. update_incident_status_investigated()
-- ============================================================================
-- Función: update_incident_status_investigated
-- Descripción: Actualiza estado del incidente al completar investigación
-- ============================================================================
CREATE OR REPLACE FUNCTION safety_management.update_incident_status_investigated()
RETURNS TRIGGER AS $$
BEGIN
-- Actualizar estado del incidente
UPDATE safety_management.incidents
SET
status = 'investigation_completed',
updated_at = now()
WHERE id = NEW.incident_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION safety_management.update_incident_status_investigated() IS
'Actualiza automáticamente el estado del incidente cuando se completa la investigación';
5. calculate_training_passed()
-- ============================================================================
-- Función: calculate_training_passed
-- Descripción: Determina si el participante aprobó la evaluación
-- ============================================================================
CREATE OR REPLACE FUNCTION safety_management.calculate_training_passed()
RETURNS TRIGGER AS $$
DECLARE
v_passing_score DECIMAL(5,2);
BEGIN
-- Obtener calificación mínima aprobatoria
SELECT passing_score INTO v_passing_score
FROM safety_management.trainings
WHERE id = NEW.training_id;
-- Determinar si aprobó
IF v_passing_score IS NOT NULL AND NEW.score IS NOT NULL THEN
NEW.passed := NEW.score >= v_passing_score;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION safety_management.calculate_training_passed() IS
'Determina automáticamente si el participante aprobó basado en el passing_score';
6. update_training_statistics()
-- ============================================================================
-- Función: update_training_statistics
-- Descripción: Actualiza contadores de asistencia y resultados
-- ============================================================================
CREATE OR REPLACE FUNCTION safety_management.update_training_statistics()
RETURNS TRIGGER AS $$
BEGIN
-- Actualizar estadísticas de la capacitación
UPDATE safety_management.trainings
SET
registered_participants = (
SELECT COUNT(*)
FROM safety_management.training_attendance
WHERE training_id = NEW.training_id AND is_active = true
),
actual_attendees = (
SELECT COUNT(*)
FROM safety_management.training_attendance
WHERE training_id = NEW.training_id AND attended = true AND is_active = true
),
passing_participants = (
SELECT COUNT(*)
FROM safety_management.training_attendance
WHERE training_id = NEW.training_id AND passed = true AND is_active = true
),
failing_participants = (
SELECT COUNT(*)
FROM safety_management.training_attendance
WHERE training_id = NEW.training_id AND passed = false AND is_active = true
),
average_score = (
SELECT AVG(score)
FROM safety_management.training_attendance
WHERE training_id = NEW.training_id AND score IS NOT NULL AND is_active = true
),
updated_at = now()
WHERE id = NEW.training_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION safety_management.update_training_statistics() IS
'Actualiza automáticamente los contadores y promedios de la capacitación';
7. alert_certificate_expiry()
-- ============================================================================
-- Función: alert_certificate_expiry
-- Descripción: Genera alertas de certificados próximos a vencer
-- ============================================================================
CREATE OR REPLACE FUNCTION safety_management.alert_certificate_expiry()
RETURNS TRIGGER AS $$
BEGIN
-- TODO: Integrar con sistema de notificaciones MGN-008
-- Enviar notificación al trabajador y coordinador
RAISE NOTICE 'ALERTA: Certificado próximo a vencer - Trabajador: %, Expira: %',
NEW.worker_id, NEW.certificate_expiry_date;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION safety_management.alert_certificate_expiry() IS
'Genera alertas cuando un certificado está próximo a vencer (30 días)';
8. notify_upcoming_training()
-- ============================================================================
-- Función: notify_upcoming_training
-- Descripción: Notifica capacitaciones próximas (7 días)
-- ============================================================================
CREATE OR REPLACE FUNCTION safety_management.notify_upcoming_training()
RETURNS TRIGGER AS $$
BEGIN
-- TODO: Integrar con sistema de notificaciones MGN-008
-- Notificar a participantes registrados e instructor
RAISE NOTICE 'RECORDATORIO: Capacitación próxima - %, Fecha: %',
NEW.training_name, NEW.scheduled_date;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION safety_management.notify_upcoming_training() IS
'Notifica automáticamente capacitaciones programadas en los próximos 7 días';
Vistas Útiles
1. v_epp_expiring_soon
-- ============================================================================
-- Vista: v_epp_expiring_soon
-- Descripción: EPP próximo a vencer en los próximos 30 días
-- ============================================================================
CREATE OR REPLACE VIEW safety_management.v_epp_expiring_soon AS
SELECT
ea.id,
ea.tenant_id,
ea.assignment_number,
ea.worker_id,
ec.name AS epp_name,
ec.epp_type,
ea.assignment_date,
ea.expiry_date,
(ea.expiry_date - CURRENT_DATE) AS days_until_expiry,
ea.status
FROM safety_management.epp_assignments ea
JOIN safety_management.epp_catalog ec ON ea.epp_catalog_id = ec.id
WHERE ea.status IN ('assigned', 'in_use')
AND ea.expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days'
AND ea.is_active = true
ORDER BY ea.expiry_date ASC;
COMMENT ON VIEW safety_management.v_epp_expiring_soon IS
'EPP asignado que vence en los próximos 30 días';
2. v_incident_kpi
-- ============================================================================
-- Vista: v_incident_kpi
-- Descripción: KPIs de incidentes por proyecto y período
-- ============================================================================
CREATE OR REPLACE VIEW safety_management.v_incident_kpi AS
SELECT
tenant_id,
project_id,
DATE_TRUNC('month', incident_date) AS month,
COUNT(*) AS total_incidents,
COUNT(*) FILTER (WHERE severity = 'near_miss') AS near_miss_count,
COUNT(*) FILTER (WHERE severity IN ('minor', 'first_aid')) AS minor_count,
COUNT(*) FILTER (WHERE severity IN ('lost_time', 'serious')) AS serious_count,
COUNT(*) FILTER (WHERE severity = 'fatal') AS fatal_count,
SUM(work_days_lost) AS total_days_lost,
SUM(total_costs) AS total_incident_costs,
AVG(work_days_lost) FILTER (WHERE work_days_lost > 0) AS avg_days_lost
FROM safety_management.incidents
WHERE is_active = true
GROUP BY tenant_id, project_id, DATE_TRUNC('month', incident_date);
COMMENT ON VIEW safety_management.v_incident_kpi IS
'KPIs de incidentes agrupados por proyecto y mes';
3. v_training_compliance
-- ============================================================================
-- Vista: v_training_compliance
-- Descripción: Cumplimiento de capacitaciones por trabajador
-- ============================================================================
CREATE OR REPLACE VIEW safety_management.v_training_compliance AS
SELECT
ta.tenant_id,
ta.worker_id,
ta.worker_name,
COUNT(*) AS total_trainings_attended,
COUNT(*) FILTER (WHERE ta.certificate_issued = true) AS certificates_obtained,
COUNT(*) FILTER (
WHERE ta.certificate_expiry_date >= CURRENT_DATE
) AS valid_certificates,
COUNT(*) FILTER (
WHERE ta.certificate_expiry_date < CURRENT_DATE
) AS expired_certificates,
MAX(t.scheduled_date) AS last_training_date
FROM safety_management.training_attendance ta
JOIN safety_management.trainings t ON ta.training_id = t.id
WHERE ta.attended = true AND ta.is_active = true
GROUP BY ta.tenant_id, ta.worker_id, ta.worker_name;
COMMENT ON VIEW safety_management.v_training_compliance IS
'Resumen de cumplimiento de capacitaciones por trabajador';
Migraciones
Orden de Creación
-- 1. Crear schema
CREATE SCHEMA IF NOT EXISTS safety_management;
-- 2. Crear ENUMs
-- (Ver sección de ENUMs arriba)
-- 3. Crear tablas en orden de dependencias
-- 3.1 epp_catalog (sin dependencias externas al schema)
-- 3.2 epp_assignments (depende de epp_catalog)
-- 3.3 safety_inspections (sin dependencias internas)
-- 3.4 incidents (sin dependencias internas)
-- 3.5 incident_investigations (depende de incidents)
-- 3.6 trainings (sin dependencias internas)
-- 3.7 training_attendance (depende de trainings)
-- 4. Crear índices
-- 5. Habilitar RLS y crear policies
-- 6. Crear funciones auxiliares
-- 7. Crear triggers
-- 8. Crear vistas
Ejemplo de Migración Prisma
// prisma/migrations/20251206_create_safety_management_schema.sql
-- Step 1: Create schema
CREATE SCHEMA IF NOT EXISTS safety_management;
-- Step 2: Create ENUMs
CREATE TYPE safety_management.epp_type AS ENUM (
'helmet', 'safety_glasses', 'goggles', 'face_shield',
'ear_plugs', 'ear_muffs', 'respirator', 'dust_mask',
'work_gloves', 'cut_resistant_gloves', 'electrical_gloves',
'safety_boots', 'rubber_boots', 'safety_vest', 'reflective_vest',
'harness', 'lifeline', 'rain_suit', 'coveralls', 'welding_jacket', 'other'
);
-- ... (resto de ENUMs)
-- Step 3: Create tables
-- ... (DDL de todas las tablas)
-- Step 4: Enable RLS
-- ... (Políticas RLS)
-- Step 5: Create functions and triggers
-- ... (Funciones y triggers)
Seeds de Datos Iniciales
1. Catálogo de EPP Básico
-- ============================================================================
-- Seed: Catálogo de EPP básico
-- ============================================================================
INSERT INTO safety_management.epp_catalog
(tenant_id, code, name, epp_type, useful_life_days, replacement_alert_days, minimum_stock)
VALUES
-- Protección de cabeza
('{tenant_uuid}', 'EPP-001', 'Casco de Seguridad Clase G', 'helmet', 730, 30, 50),
('{tenant_uuid}', 'EPP-002', 'Casco de Seguridad Clase E', 'helmet', 730, 30, 20),
-- Protección visual
('{tenant_uuid}', 'EPP-003', 'Lentes de Seguridad Clara', 'safety_glasses', 180, 15, 100),
('{tenant_uuid}', 'EPP-004', 'Lentes de Seguridad Oscura', 'safety_glasses', 180, 15, 50),
('{tenant_uuid}', 'EPP-005', 'Goggles Antiempañante', 'goggles', 180, 15, 30),
-- Protección auditiva
('{tenant_uuid}', 'EPP-006', 'Tapones Auditivos Desechables', 'ear_plugs', 30, 3, 500),
('{tenant_uuid}', 'EPP-007', 'Orejeras de Cancelación de Ruido', 'ear_muffs', 365, 30, 30),
-- Protección respiratoria
('{tenant_uuid}', 'EPP-008', 'Cubrebocas N95', 'dust_mask', 7, 1, 500),
('{tenant_uuid}', 'EPP-009', 'Respirador de Media Cara', 'respirator', 365, 30, 20),
-- Protección de manos
('{tenant_uuid}', 'EPP-010', 'Guantes de Carnaza', 'work_gloves', 60, 10, 200),
('{tenant_uuid}', 'EPP-011', 'Guantes Anticorte Nivel 5', 'cut_resistant_gloves', 90, 10, 50),
('{tenant_uuid}', 'EPP-012', 'Guantes Dieléctricos Clase 00', 'electrical_gloves', 180, 30, 20),
-- Protección de pies
('{tenant_uuid}', 'EPP-013', 'Botas de Seguridad Dieléctrica', 'safety_boots', 365, 30, 100),
('{tenant_uuid}', 'EPP-014', 'Botas de Hule', 'rubber_boots', 365, 30, 50),
-- Protección corporal
('{tenant_uuid}', 'EPP-015', 'Chaleco Reflejante Clase 2', 'reflective_vest', 180, 15, 200),
('{tenant_uuid}', 'EPP-016', 'Arnés de Cuerpo Completo', 'harness', 730, 60, 50),
('{tenant_uuid}', 'EPP-017', 'Línea de Vida Retráctil 6m', 'lifeline', 365, 30, 30);
Validaciones y Constraints
Reglas de Negocio Implementadas
-
EPP:
- Vida útil debe ser > 0 días
- Alerta de reemplazo debe ser antes del vencimiento
- Código único por tenant
- Cantidad asignada debe ser > 0
-
Inspecciones:
- Total de items = conformes + no conformes + no aplicables
- No conformes = suma de hallazgos por nivel de riesgo
- Score entre 0 y 100
- Número único por tenant
-
Incidentes:
- Días perdidos >= 0
- Costos >= 0
- Número único por tenant
- Solo una investigación por incidente
-
Capacitaciones:
- Hora fin > hora inicio
- Capacidad máxima >= mínima
- Score aprobatorio entre 0 y 100
- Asistentes <= registrados
-
Asistencia:
- Score entre 0 y 100
- Satisfacción entre 1 y 5
- Hora salida >= hora entrada
- Un registro por trabajador por capacitación
Consideraciones de Rendimiento
Particionamiento
Para tablas con alto volumen:
-- Particionar incidents por fecha (recomendado después de 1 año de uso)
CREATE TABLE safety_management.incidents (
-- ... columnas
) PARTITION BY RANGE (incident_date);
-- Crear particiones trimestrales
CREATE TABLE safety_management.incidents_2025_q1
PARTITION OF safety_management.incidents
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE safety_management.incidents_2025_q2
PARTITION OF safety_management.incidents
FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');
Índices Optimizados
- Índices compuestos para consultas frecuentes de KPIs
- Índices parciales para filtrar solo registros activos
- Índices GIN para búsquedas en JSONB y arrays
- Índices trigram para búsqueda de texto
Mantenimiento
-- Reindexación periódica (mensual)
REINDEX SCHEMA safety_management;
-- Análisis de estadísticas (semanal)
ANALYZE safety_management.epp_assignments;
ANALYZE safety_management.safety_inspections;
ANALYZE safety_management.incidents;
ANALYZE safety_management.trainings;
Integración con Otros Módulos
Referencias a Tablas Externas
-- MAI-001: Fundamentos
worker_id UUID -- Referencias a workers.id
project_id UUID -- Referencias a projects.id
-- Core Users (MGN-001)
created_by UUID -- Referencias a core_users.users.id
inspector_id UUID -- Referencias a core_users.users.id
reported_by_id UUID -- Referencias a core_users.users.id
-- Core Tenants (MGN-003)
tenant_id UUID NOT NULL -- Referencias a core_tenants.tenants.id
Sincronización con Apps Móviles
- Tablas optimizadas para sincronización offline
- Campos
gps_latitude,gps_longitudepara geolocalización - Arrays de URLs para fotos capturadas en campo
- JSONB para datos flexibles de checklists
Checklist de Validación
- Schema
safety_managementcreado - 8 ENUMs definidos con valores apropiados
- 7 tablas principales con estructura completa
- Todas las tablas tienen
tenant_id - RLS habilitado en todas las tablas
- 4 políticas RLS por tabla (SELECT, INSERT, UPDATE, DELETE)
- Índices obligatorios en
tenant_id - Índices de performance en columnas de búsqueda
- Triggers para
updated_at - Triggers de negocio (alertas, cálculos)
- Funciones auxiliares documentadas
- Vistas para KPIs y reportes
- Constraints de integridad y reglas de negocio
- Comentarios en tablas y columnas críticas
- Seeds de datos iniciales
- Consideraciones de rendimiento
Próximos Pasos
-
Implementación:
- Crear migración Prisma
- Ejecutar en ambiente de desarrollo
- Validar con datos de prueba
-
Integración:
- Definir DTOs de TypeScript
- Crear Prisma schema
- Implementar repositorios
-
Testing:
- Tests unitarios de funciones
- Tests de integridad referencial
- Tests de performance con volumen
-
Documentación:
- Diagramas ER
- Diccionario de datos
- Guía de consultas frecuentes
Generado: 2025-12-06 Versión: 1.0.0 Estado: Completo Mantenedor: @database-team