# 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 ```yaml 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_id` para 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) ```sql 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) ```sql 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) ```sql 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) ```sql 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) ```sql 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) ```sql 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) ```sql 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) ```sql 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. ```sql -- ============================================================================ -- 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. ```sql -- ============================================================================ -- 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. ```sql -- ============================================================================ -- 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. ```sql -- ============================================================================ -- 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. ```sql -- ============================================================================ -- 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. ```sql -- ============================================================================ -- 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. ```sql -- ============================================================================ -- 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() ```sql -- ============================================================================ -- 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() ```sql -- ============================================================================ -- 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() ```sql -- ============================================================================ -- 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() ```sql -- ============================================================================ -- 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() ```sql -- ============================================================================ -- 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() ```sql -- ============================================================================ -- 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() ```sql -- ============================================================================ -- 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() ```sql -- ============================================================================ -- 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 ```sql -- ============================================================================ -- 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 ```sql -- ============================================================================ -- 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 ```sql -- ============================================================================ -- 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 ```sql -- 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 ```typescript // 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 ```sql -- ============================================================================ -- 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 1. **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 2. **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 3. **Incidentes:** - Días perdidos >= 0 - Costos >= 0 - Número único por tenant - Solo una investigación por incidente 4. **Capacitaciones:** - Hora fin > hora inicio - Capacidad máxima >= mínima - Score aprobatorio entre 0 y 100 - Asistentes <= registrados 5. **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: ```sql -- 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 ```sql -- 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 ```sql -- 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_longitude` para geolocalización - Arrays de URLs para fotos capturadas en campo - JSONB para datos flexibles de checklists --- ## Checklist de Validación - [x] Schema `safety_management` creado - [x] 8 ENUMs definidos con valores apropiados - [x] 7 tablas principales con estructura completa - [x] Todas las tablas tienen `tenant_id` - [x] RLS habilitado en todas las tablas - [x] 4 políticas RLS por tabla (SELECT, INSERT, UPDATE, DELETE) - [x] Índices obligatorios en `tenant_id` - [x] Índices de performance en columnas de búsqueda - [x] Triggers para `updated_at` - [x] Triggers de negocio (alertas, cálculos) - [x] Funciones auxiliares documentadas - [x] Vistas para KPIs y reportes - [x] Constraints de integridad y reglas de negocio - [x] Comentarios en tablas y columnas críticas - [x] Seeds de datos iniciales - [x] Consideraciones de rendimiento --- ## Próximos Pasos 1. **Implementación:** - Crear migración Prisma - Ejecutar en ambiente de desarrollo - Validar con datos de prueba 2. **Integración:** - Definir DTOs de TypeScript - Crear Prisma schema - Implementar repositorios 3. **Testing:** - Tests unitarios de funciones - Tests de integridad referencial - Tests de performance con volumen 4. **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