diff --git a/init/16-dispatch-schema.sql b/init/16-dispatch-schema.sql new file mode 100644 index 0000000..fd15d0d --- /dev/null +++ b/init/16-dispatch-schema.sql @@ -0,0 +1,503 @@ +-- ============================================================ +-- 16-dispatch-schema.sql +-- Mecanicas Diesel - ERP Suite +-- +-- Dispatch Center Schema (MMD-011) +-- Centro de despacho para asignacion de incidentes +-- ============================================================ + +-- Create dispatch schema +CREATE SCHEMA IF NOT EXISTS dispatch; + +-- ============================================================ +-- ENUMS +-- ============================================================ + +-- Unit operational status +CREATE TYPE dispatch.unit_status_enum AS ENUM ( + 'available', + 'assigned', + 'en_route', + 'on_site', + 'returning', + 'offline', + 'maintenance' +); + +-- Technician shift types +CREATE TYPE dispatch.shift_type AS ENUM ( + 'morning', + 'afternoon', + 'night', + 'full_day', + 'on_call' +); + +-- Skill levels +CREATE TYPE dispatch.skill_level AS ENUM ( + 'basic', + 'intermediate', + 'advanced', + 'expert' +); + +-- Escalation notification channels +CREATE TYPE dispatch.notification_channel AS ENUM ( + 'email', + 'sms', + 'whatsapp', + 'push', + 'call' +); + +-- Dispatch log actions +CREATE TYPE dispatch.dispatch_action AS ENUM ( + 'created', + 'assigned', + 'reassigned', + 'rejected', + 'escalated', + 'cancelled', + 'acknowledged', + 'completed' +); + +-- ============================================================ +-- TABLES +-- ============================================================ + +-- Dispatch Board Configuration +CREATE TABLE dispatch.dispatch_boards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Map defaults + default_zoom INTEGER DEFAULT 12, + center_lat DECIMAL(10, 7) DEFAULT 19.4326, + center_lng DECIMAL(10, 7) DEFAULT -99.1332, + + -- Behavior + refresh_interval_seconds INTEGER DEFAULT 30, + show_offline_units BOOLEAN DEFAULT true, + auto_assign_enabled BOOLEAN DEFAULT false, + max_suggestions INTEGER DEFAULT 5, + + -- Filters + default_filters JSONB DEFAULT '{}', + + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID +); + +CREATE INDEX idx_dispatch_boards_tenant ON dispatch.dispatch_boards(tenant_id); + +-- Unit Status (real-time unit state) +CREATE TABLE dispatch.unit_statuses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Unit reference + unit_id UUID NOT NULL, + unit_code VARCHAR(50), + unit_name VARCHAR(100), + + -- Status + status dispatch.unit_status_enum DEFAULT 'offline', + + -- Current assignment + current_incident_id UUID, + current_technician_ids UUID[] DEFAULT '{}', + + -- Location (cached from GPS) + last_position_id UUID, + last_known_lat DECIMAL(10, 7), + last_known_lng DECIMAL(10, 7), + last_location_update TIMESTAMPTZ, + + -- Timing + last_status_change TIMESTAMPTZ DEFAULT NOW(), + estimated_available_at TIMESTAMPTZ, + + -- Capacity and capabilities + unit_capacity VARCHAR(20) DEFAULT 'light', -- light, medium, heavy + can_tow BOOLEAN DEFAULT false, + max_tow_weight_kg INTEGER, + + notes TEXT, + metadata JSONB DEFAULT '{}', + + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE UNIQUE INDEX idx_unit_statuses_tenant_unit ON dispatch.unit_statuses(tenant_id, unit_id); +CREATE INDEX idx_unit_statuses_status ON dispatch.unit_statuses(tenant_id, status); +CREATE INDEX idx_unit_statuses_current_incident ON dispatch.unit_statuses(current_incident_id) WHERE current_incident_id IS NOT NULL; + +-- Technician Skills +CREATE TABLE dispatch.technician_skills ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Technician reference + technician_id UUID NOT NULL, + + -- Skill definition + skill_code VARCHAR(50) NOT NULL, + skill_name VARCHAR(100) NOT NULL, + skill_description TEXT, + level dispatch.skill_level DEFAULT 'basic', + + -- Certification + certification_number VARCHAR(100), + certified_at DATE, + expires_at DATE, + certification_document_url VARCHAR(500), + + -- Status + is_active BOOLEAN DEFAULT true, + verified_by UUID, + verified_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT uq_technician_skill UNIQUE(tenant_id, technician_id, skill_code) +); + +CREATE INDEX idx_technician_skills_tenant ON dispatch.technician_skills(tenant_id); +CREATE INDEX idx_technician_skills_technician ON dispatch.technician_skills(tenant_id, technician_id); +CREATE INDEX idx_technician_skills_code ON dispatch.technician_skills(tenant_id, skill_code); +CREATE INDEX idx_technician_skills_expiry ON dispatch.technician_skills(expires_at) WHERE expires_at IS NOT NULL; + +-- Technician Shifts +CREATE TABLE dispatch.technician_shifts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Technician reference + technician_id UUID NOT NULL, + + -- Schedule + shift_date DATE NOT NULL, + shift_type dispatch.shift_type NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + + -- On-call specifics + is_on_call BOOLEAN DEFAULT false, + on_call_priority INTEGER DEFAULT 0, + + -- Assignment + assigned_unit_id UUID, + + -- Status + actual_start_time TIMESTAMPTZ, + actual_end_time TIMESTAMPTZ, + is_absent BOOLEAN DEFAULT false, + absence_reason TEXT, + + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID +); + +CREATE INDEX idx_technician_shifts_tenant ON dispatch.technician_shifts(tenant_id); +CREATE INDEX idx_technician_shifts_technician_date ON dispatch.technician_shifts(tenant_id, technician_id, shift_date); +CREATE INDEX idx_technician_shifts_date ON dispatch.technician_shifts(tenant_id, shift_date); +CREATE INDEX idx_technician_shifts_unit ON dispatch.technician_shifts(assigned_unit_id) WHERE assigned_unit_id IS NOT NULL; + +-- Dispatch Rules (assignment criteria) +CREATE TABLE dispatch.dispatch_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + name VARCHAR(100) NOT NULL, + description TEXT, + priority INTEGER DEFAULT 0, + + -- Applicability + service_type_code VARCHAR(50), + incident_category VARCHAR(50), + + -- Conditions (JSON structure for flexibility) + conditions JSONB NOT NULL DEFAULT '{}', + /* + conditions structure: + { + "requires_skills": ["DIESEL_ENGINE", "ELECTRICAL"], + "requires_asset_categories": ["DIAGNOSTIC_TOOL"], + "min_unit_capacity": "heavy", + "max_distance_km": 50, + "zone_codes": ["NORTE", "CENTRO"], + "min_skill_level": "intermediate", + "exclude_on_call": false + } + */ + + -- Action + auto_assign BOOLEAN DEFAULT false, + assignment_weight INTEGER DEFAULT 100, + + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID +); + +CREATE INDEX idx_dispatch_rules_tenant ON dispatch.dispatch_rules(tenant_id); +CREATE INDEX idx_dispatch_rules_service_type ON dispatch.dispatch_rules(tenant_id, service_type_code) WHERE service_type_code IS NOT NULL; +CREATE INDEX idx_dispatch_rules_priority ON dispatch.dispatch_rules(tenant_id, priority DESC); + +-- Escalation Rules +CREATE TABLE dispatch.escalation_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Trigger conditions + trigger_after_minutes INTEGER NOT NULL, + trigger_status VARCHAR(50), -- incident status that triggers + trigger_priority VARCHAR(20), -- only for specific priorities + + -- Escalation target + escalate_to_role VARCHAR(50) NOT NULL, + escalate_to_user_ids UUID[], + + -- Notification + notification_channel dispatch.notification_channel NOT NULL, + notification_template TEXT, + notification_data JSONB DEFAULT '{}', + + -- Repeat + repeat_interval_minutes INTEGER, + max_escalations INTEGER DEFAULT 3, + + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID +); + +CREATE INDEX idx_escalation_rules_tenant ON dispatch.escalation_rules(tenant_id); +CREATE INDEX idx_escalation_rules_trigger ON dispatch.escalation_rules(tenant_id, trigger_after_minutes); + +-- Dispatch Log (audit trail) +CREATE TABLE dispatch.dispatch_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Incident reference + incident_id UUID NOT NULL, + + -- Action + action dispatch.dispatch_action NOT NULL, + + -- Unit/Technician changes + from_unit_id UUID, + to_unit_id UUID, + from_technician_id UUID, + to_technician_id UUID, + + -- Context + reason TEXT, + automated BOOLEAN DEFAULT false, + rule_id UUID, -- dispatch rule that caused action + escalation_id UUID, -- escalation rule that caused action + + -- Response times + response_time_seconds INTEGER, + + -- Actor + performed_by UUID, + performed_at TIMESTAMPTZ DEFAULT NOW(), + + -- Extra data + metadata JSONB DEFAULT '{}' +); + +CREATE INDEX idx_dispatch_logs_tenant ON dispatch.dispatch_logs(tenant_id); +CREATE INDEX idx_dispatch_logs_incident ON dispatch.dispatch_logs(tenant_id, incident_id); +CREATE INDEX idx_dispatch_logs_time ON dispatch.dispatch_logs(tenant_id, performed_at DESC); +CREATE INDEX idx_dispatch_logs_action ON dispatch.dispatch_logs(tenant_id, action); + +-- ============================================================ +-- TRIGGERS +-- ============================================================ + +-- Auto-update updated_at +CREATE OR REPLACE FUNCTION dispatch.update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_dispatch_boards_updated_at + BEFORE UPDATE ON dispatch.dispatch_boards + FOR EACH ROW EXECUTE FUNCTION dispatch.update_updated_at(); + +CREATE TRIGGER trg_unit_statuses_updated_at + BEFORE UPDATE ON dispatch.unit_statuses + FOR EACH ROW EXECUTE FUNCTION dispatch.update_updated_at(); + +CREATE TRIGGER trg_technician_skills_updated_at + BEFORE UPDATE ON dispatch.technician_skills + FOR EACH ROW EXECUTE FUNCTION dispatch.update_updated_at(); + +CREATE TRIGGER trg_technician_shifts_updated_at + BEFORE UPDATE ON dispatch.technician_shifts + FOR EACH ROW EXECUTE FUNCTION dispatch.update_updated_at(); + +CREATE TRIGGER trg_dispatch_rules_updated_at + BEFORE UPDATE ON dispatch.dispatch_rules + FOR EACH ROW EXECUTE FUNCTION dispatch.update_updated_at(); + +CREATE TRIGGER trg_escalation_rules_updated_at + BEFORE UPDATE ON dispatch.escalation_rules + FOR EACH ROW EXECUTE FUNCTION dispatch.update_updated_at(); + +-- Log unit status changes +CREATE OR REPLACE FUNCTION dispatch.log_unit_status_change() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.status IS DISTINCT FROM NEW.status THEN + NEW.last_status_change = NOW(); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_unit_status_change + BEFORE UPDATE ON dispatch.unit_statuses + FOR EACH ROW EXECUTE FUNCTION dispatch.log_unit_status_change(); + +-- ============================================================ +-- VIEWS +-- ============================================================ + +-- Available units with current position +CREATE OR REPLACE VIEW dispatch.vw_available_units AS +SELECT + us.id, + us.tenant_id, + us.unit_id, + us.unit_code, + us.unit_name, + us.status, + us.last_known_lat, + us.last_known_lng, + us.last_location_update, + us.unit_capacity, + us.can_tow, + us.max_tow_weight_kg, + us.estimated_available_at, + EXTRACT(EPOCH FROM (NOW() - us.last_location_update)) / 60 AS minutes_since_update +FROM dispatch.unit_statuses us +WHERE us.status = 'available'; + +-- Technicians on shift today +CREATE OR REPLACE VIEW dispatch.vw_technicians_on_shift AS +SELECT + ts.id, + ts.tenant_id, + ts.technician_id, + ts.shift_date, + ts.shift_type, + ts.start_time, + ts.end_time, + ts.is_on_call, + ts.assigned_unit_id, + ts.is_absent, + CASE + WHEN ts.is_absent THEN 'absent' + WHEN ts.actual_start_time IS NOT NULL AND ts.actual_end_time IS NULL THEN 'active' + WHEN ts.actual_start_time IS NULL THEN 'pending' + ELSE 'completed' + END AS shift_status +FROM dispatch.technician_shifts ts +WHERE ts.shift_date = CURRENT_DATE + AND ts.is_absent = false; + +-- Skills about to expire +CREATE OR REPLACE VIEW dispatch.vw_expiring_skills AS +SELECT + tsk.id, + tsk.tenant_id, + tsk.technician_id, + tsk.skill_code, + tsk.skill_name, + tsk.level, + tsk.expires_at, + tsk.expires_at - CURRENT_DATE AS days_until_expiry +FROM dispatch.technician_skills tsk +WHERE tsk.is_active = true + AND tsk.expires_at IS NOT NULL + AND tsk.expires_at <= CURRENT_DATE + INTERVAL '30 days'; + +-- Dispatch activity summary +CREATE OR REPLACE VIEW dispatch.vw_dispatch_activity AS +SELECT + dl.tenant_id, + DATE(dl.performed_at) AS activity_date, + dl.action, + COUNT(*) AS count, + AVG(dl.response_time_seconds) AS avg_response_time_seconds, + COUNT(CASE WHEN dl.automated THEN 1 END) AS automated_count +FROM dispatch.dispatch_logs dl +WHERE dl.performed_at >= CURRENT_DATE - INTERVAL '30 days' +GROUP BY dl.tenant_id, DATE(dl.performed_at), dl.action; + +-- ============================================================ +-- RLS POLICIES +-- ============================================================ + +ALTER TABLE dispatch.dispatch_boards ENABLE ROW LEVEL SECURITY; +ALTER TABLE dispatch.unit_statuses ENABLE ROW LEVEL SECURITY; +ALTER TABLE dispatch.technician_skills ENABLE ROW LEVEL SECURITY; +ALTER TABLE dispatch.technician_shifts ENABLE ROW LEVEL SECURITY; +ALTER TABLE dispatch.dispatch_rules ENABLE ROW LEVEL SECURITY; +ALTER TABLE dispatch.escalation_rules ENABLE ROW LEVEL SECURITY; +ALTER TABLE dispatch.dispatch_logs ENABLE ROW LEVEL SECURITY; + +-- RLS Policies for each table +CREATE POLICY tenant_isolation_dispatch_boards ON dispatch.dispatch_boards + FOR ALL USING (tenant_id = current_setting('app.current_tenant', true)::uuid); + +CREATE POLICY tenant_isolation_unit_statuses ON dispatch.unit_statuses + FOR ALL USING (tenant_id = current_setting('app.current_tenant', true)::uuid); + +CREATE POLICY tenant_isolation_technician_skills ON dispatch.technician_skills + FOR ALL USING (tenant_id = current_setting('app.current_tenant', true)::uuid); + +CREATE POLICY tenant_isolation_technician_shifts ON dispatch.technician_shifts + FOR ALL USING (tenant_id = current_setting('app.current_tenant', true)::uuid); + +CREATE POLICY tenant_isolation_dispatch_rules ON dispatch.dispatch_rules + FOR ALL USING (tenant_id = current_setting('app.current_tenant', true)::uuid); + +CREATE POLICY tenant_isolation_escalation_rules ON dispatch.escalation_rules + FOR ALL USING (tenant_id = current_setting('app.current_tenant', true)::uuid); + +CREATE POLICY tenant_isolation_dispatch_logs ON dispatch.dispatch_logs + FOR ALL USING (tenant_id = current_setting('app.current_tenant', true)::uuid); + +-- ============================================================ +-- COMMENTS +-- ============================================================ + +COMMENT ON SCHEMA dispatch IS 'Dispatch Center schema for incident assignment and unit management (MMD-011)'; +COMMENT ON TABLE dispatch.dispatch_boards IS 'Dispatch board configurations for map and assignment views'; +COMMENT ON TABLE dispatch.unit_statuses IS 'Real-time status of fleet units for dispatch'; +COMMENT ON TABLE dispatch.technician_skills IS 'Skills and certifications of technicians'; +COMMENT ON TABLE dispatch.technician_shifts IS 'Shift schedules for technicians'; +COMMENT ON TABLE dispatch.dispatch_rules IS 'Rules for automatic assignment suggestions'; +COMMENT ON TABLE dispatch.escalation_rules IS 'Rules for escalating unresponded incidents'; +COMMENT ON TABLE dispatch.dispatch_logs IS 'Audit log of all dispatch actions';