From 9902b4bbe1459c2f83e7ba83c6ef6dd085ed77e5 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 27 Jan 2026 02:42:50 -0600 Subject: [PATCH] feat(MMD-012): Add field service schema - Create field_service schema with 11 tables - Add 5 enums: checklist_item_type, diagnosis_type, sync_status, checklist_status, worklog_status - Add tables: service_checklists, checklist_item_templates, checklist_responses, checklist_item_responses, work_logs, activity_catalog, diagnosis_records, root_cause_catalog, field_evidence, offline_queue_items, field_checkins - Add 4 views: vw_active_work_logs, vw_pending_sync, vw_technician_activity, vw_checklist_summary - Add triggers for duration calculation and count updates - Add RLS policies for multi-tenant isolation Co-Authored-By: Claude Opus 4.5 --- init/17-field-service-schema.sql | 839 +++++++++++++++++++++++++++++++ 1 file changed, 839 insertions(+) create mode 100644 init/17-field-service-schema.sql diff --git a/init/17-field-service-schema.sql b/init/17-field-service-schema.sql new file mode 100644 index 0000000..bde4166 --- /dev/null +++ b/init/17-field-service-schema.sql @@ -0,0 +1,839 @@ +-- ============================================================================= +-- 17-field-service-schema.sql +-- MMD-012: Field Service / Bitácora Técnica Móvil +-- ============================================================================= +-- Schema: field_service +-- Descripción: Checklist de servicio, registro de diagnóstico, control de mano +-- de obra, modo offline con sincronización +-- Sistema: ERP Mecánicas Diesel +-- Fecha: 2026-01-27 +-- ============================================================================= + +-- Create schema +CREATE SCHEMA IF NOT EXISTS field_service; + +-- ============================================================================= +-- TYPES/ENUMS +-- ============================================================================= + +-- Tipo de item de checklist +CREATE TYPE field_service.checklist_item_type AS ENUM ( + 'BOOLEAN', + 'TEXT', + 'NUMBER', + 'PHOTO', + 'SIGNATURE', + 'SELECT' +); + +-- Tipo de diagnóstico +CREATE TYPE field_service.diagnosis_type AS ENUM ( + 'VISUAL', + 'OBD', + 'ELECTRONIC', + 'MECHANICAL' +); + +-- Estado de sincronización +CREATE TYPE field_service.sync_status AS ENUM ( + 'PENDING', + 'IN_PROGRESS', + 'COMPLETED', + 'FAILED', + 'CONFLICT' +); + +-- Estado de checklist response +CREATE TYPE field_service.checklist_status AS ENUM ( + 'STARTED', + 'IN_PROGRESS', + 'COMPLETED', + 'CANCELLED' +); + +-- Estado de work log +CREATE TYPE field_service.worklog_status AS ENUM ( + 'ACTIVE', + 'PAUSED', + 'COMPLETED', + 'CANCELLED' +); + +-- ============================================================================= +-- TABLE: service_checklists (Plantillas de checklist) +-- ============================================================================= +CREATE TABLE field_service.service_checklists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Identification + name VARCHAR(150) NOT NULL, + description TEXT, + code VARCHAR(50), + + -- Scope + service_type_code VARCHAR(50), -- Aplica a tipo de servicio específico + vehicle_type VARCHAR(50), -- Aplica a tipo de vehículo + is_default BOOLEAN DEFAULT FALSE, + + -- Status + is_active BOOLEAN DEFAULT TRUE, + version INTEGER DEFAULT 1, + + -- Audit + created_by UUID, + updated_by UUID, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- TABLE: checklist_item_templates (Items de plantilla) +-- ============================================================================= +CREATE TABLE field_service.checklist_item_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + checklist_id UUID NOT NULL REFERENCES field_service.service_checklists(id) ON DELETE CASCADE, + + -- Item definition + item_order INTEGER NOT NULL DEFAULT 0, + text VARCHAR(500) NOT NULL, + item_type field_service.checklist_item_type NOT NULL DEFAULT 'BOOLEAN', + + -- Configuration + is_required BOOLEAN DEFAULT FALSE, + options JSONB, -- Para tipo SELECT: ["Opción 1", "Opción 2"] + unit VARCHAR(20), -- Para tipo NUMBER: 'km', 'hrs', 'psi', etc. + min_value DECIMAL(12,2), -- Validación para NUMBER + max_value DECIMAL(12,2), + + -- Help + help_text VARCHAR(500), + photo_required BOOLEAN DEFAULT FALSE, -- Requiere foto adicional + + -- Grouping + section VARCHAR(100), -- Sección del checklist + + -- Status + is_active BOOLEAN DEFAULT TRUE, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- TABLE: checklist_responses (Respuestas de checklist en un servicio) +-- ============================================================================= +CREATE TABLE field_service.checklist_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- References + incident_id UUID, -- Incidente/orden de servicio + service_order_id UUID, -- Orden de servicio alternativa + checklist_id UUID NOT NULL REFERENCES field_service.service_checklists(id), + technician_id UUID NOT NULL, + + -- Status + status field_service.checklist_status DEFAULT 'STARTED', + + -- Timing + started_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ, + + -- Location at start/end + start_latitude DECIMAL(10,7), + start_longitude DECIMAL(10,7), + end_latitude DECIMAL(10,7), + end_longitude DECIMAL(10,7), + + -- Offline handling + is_offline BOOLEAN DEFAULT FALSE, + device_id VARCHAR(100), + local_id VARCHAR(100), -- ID local del dispositivo + synced_at TIMESTAMPTZ, + + -- Summary + total_items INTEGER DEFAULT 0, + completed_items INTEGER DEFAULT 0, + passed_items INTEGER DEFAULT 0, + failed_items INTEGER DEFAULT 0, + + -- Notes + notes TEXT, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- TABLE: checklist_item_responses (Respuestas individuales) +-- ============================================================================= +CREATE TABLE field_service.checklist_item_responses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + checklist_response_id UUID NOT NULL REFERENCES field_service.checklist_responses(id) ON DELETE CASCADE, + item_template_id UUID NOT NULL REFERENCES field_service.checklist_item_templates(id), + + -- Response value + value_boolean BOOLEAN, + value_text TEXT, + value_number DECIMAL(12,4), + value_json JSONB, -- Para SELECT múltiple o datos complejos + + -- Evidence + photo_url VARCHAR(500), + photo_urls JSONB, -- Múltiples fotos + signature_url VARCHAR(500), + + -- Status + is_passed BOOLEAN, -- Pasó la verificación + failure_reason TEXT, + + -- Timing + captured_at TIMESTAMPTZ DEFAULT NOW(), + + -- Offline + local_id VARCHAR(100), + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- TABLE: work_logs (Registro de mano de obra) +-- ============================================================================= +CREATE TABLE field_service.work_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- References + incident_id UUID, + service_order_id UUID, + technician_id UUID NOT NULL, + + -- Activity + activity_code VARCHAR(50) NOT NULL, -- Código de catálogo + activity_name VARCHAR(200), + description TEXT, + + -- Timing + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ, + duration_minutes INTEGER, + + -- Pause tracking + pause_start TIMESTAMPTZ, + total_pause_minutes INTEGER DEFAULT 0, + + -- Status + status field_service.worklog_status DEFAULT 'ACTIVE', + + -- Labor calculation + labor_rate DECIMAL(10,2), -- Tarifa por hora + labor_total DECIMAL(12,2), -- Total calculado + is_overtime BOOLEAN DEFAULT FALSE, + overtime_multiplier DECIMAL(3,2) DEFAULT 1.5, + + -- Location + latitude DECIMAL(10,7), + longitude DECIMAL(10,7), + + -- Offline + is_offline BOOLEAN DEFAULT FALSE, + device_id VARCHAR(100), + local_id VARCHAR(100), + synced_at TIMESTAMPTZ, + + -- Notes + notes TEXT, + + -- Audit + created_by UUID, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- TABLE: activity_catalog (Catálogo de actividades) +-- ============================================================================= +CREATE TABLE field_service.activity_catalog ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Activity definition + code VARCHAR(50) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + + -- Categorization + category VARCHAR(100), + service_type_code VARCHAR(50), + + -- Defaults + default_duration_minutes INTEGER, + default_rate DECIMAL(10,2), + + -- Status + is_active BOOLEAN DEFAULT TRUE, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT uq_activity_tenant_code UNIQUE (tenant_id, code) +); + +-- ============================================================================= +-- TABLE: diagnosis_records (Registro de diagnóstico) +-- ============================================================================= +CREATE TABLE field_service.diagnosis_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- References + incident_id UUID, + service_order_id UUID, + vehicle_id UUID, + technician_id UUID NOT NULL, + + -- Diagnosis type + diagnosis_type field_service.diagnosis_type NOT NULL DEFAULT 'VISUAL', + + -- Symptoms + symptoms TEXT NOT NULL, + customer_complaint TEXT, + + -- Root cause + root_cause_code VARCHAR(50), -- Catálogo de causas raíz + root_cause_category VARCHAR(100), + root_cause_description TEXT, + + -- OBD-II (for vehicles) + obd2_codes JSONB, -- ["P0300", "P0420", ...] + obd2_raw_data JSONB, -- Datos crudos del escáner + + -- Readings + odometer_reading DECIMAL(12,2), + engine_hours DECIMAL(10,2), + fuel_level INTEGER, -- Porcentaje + + -- Recommendation + recommendation TEXT, + severity VARCHAR(20), -- LOW, MEDIUM, HIGH, CRITICAL + requires_immediate_action BOOLEAN DEFAULT FALSE, + + -- Evidence + photo_urls JSONB, + video_urls JSONB, + + -- Location + latitude DECIMAL(10,7), + longitude DECIMAL(10,7), + + -- Offline + is_offline BOOLEAN DEFAULT FALSE, + device_id VARCHAR(100), + local_id VARCHAR(100), + synced_at TIMESTAMPTZ, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- TABLE: root_cause_catalog (Catálogo de causas raíz) +-- ============================================================================= +CREATE TABLE field_service.root_cause_catalog ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- Definition + code VARCHAR(50) NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + + -- Categorization + category VARCHAR(100) NOT NULL, + subcategory VARCHAR(100), + + -- Common for vehicle type + vehicle_types JSONB, -- ["Diesel", "Gasolina", "Híbrido"] + + -- Recommendations + standard_recommendation TEXT, + estimated_repair_hours DECIMAL(5,2), + + -- Status + is_active BOOLEAN DEFAULT TRUE, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT uq_root_cause_tenant_code UNIQUE (tenant_id, code) +); + +-- ============================================================================= +-- TABLE: field_evidence (Evidencias de campo: fotos, videos, firmas) +-- ============================================================================= +CREATE TABLE field_service.field_evidence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- References + incident_id UUID, + service_order_id UUID, + checklist_response_id UUID, + diagnosis_record_id UUID, + + -- Evidence type + evidence_type VARCHAR(20) NOT NULL, -- PHOTO, VIDEO, SIGNATURE, DOCUMENT + + -- File info + file_url VARCHAR(500) NOT NULL, + file_name VARCHAR(200), + file_size INTEGER, + mime_type VARCHAR(100), + + -- Metadata + caption TEXT, + tags JSONB, + + -- Photo specific + is_before BOOLEAN, -- Foto antes del servicio + is_after BOOLEAN, -- Foto después del servicio + + -- Signature specific + signer_name VARCHAR(150), + signer_role VARCHAR(50), -- CUSTOMER, TECHNICIAN, SUPERVISOR + + -- Location + latitude DECIMAL(10,7), + longitude DECIMAL(10,7), + + -- Offline + is_offline BOOLEAN DEFAULT FALSE, + device_id VARCHAR(100), + local_id VARCHAR(100), + synced_at TIMESTAMPTZ, + pending_upload BOOLEAN DEFAULT FALSE, + + -- Audit + captured_by UUID, + captured_at TIMESTAMPTZ DEFAULT NOW(), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- TABLE: offline_queue_items (Cola de sincronización offline) +-- ============================================================================= +CREATE TABLE field_service.offline_queue_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Device + device_id VARCHAR(100) NOT NULL, + technician_id UUID, + + -- Entity reference + entity_type VARCHAR(50) NOT NULL, -- ChecklistResponse, WorkLog, DiagnosisRecord, FieldEvidence + entity_id VARCHAR(100) NOT NULL, -- UUID o ID local + local_id VARCHAR(100) NOT NULL, + + -- Payload + payload JSONB NOT NULL, + + -- Sync status + status field_service.sync_status DEFAULT 'PENDING', + priority INTEGER DEFAULT 0, -- Mayor = más prioritario + + -- Attempts + sync_attempts INTEGER DEFAULT 0, + max_attempts INTEGER DEFAULT 5, + last_attempt_at TIMESTAMPTZ, + next_attempt_at TIMESTAMPTZ, + + -- Result + synced_at TIMESTAMPTZ, + server_entity_id UUID, -- ID asignado por el servidor + error_message TEXT, + error_code VARCHAR(50), + + -- Conflict resolution + has_conflict BOOLEAN DEFAULT FALSE, + conflict_data JSONB, + resolved_at TIMESTAMPTZ, + resolution VARCHAR(20), -- LOCAL, SERVER, MERGED + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT uq_offline_device_local_id UNIQUE (device_id, local_id) +); + +-- ============================================================================= +-- TABLE: field_checkins (Check-in/Check-out de técnicos) +-- ============================================================================= +CREATE TABLE field_service.field_checkins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL, + + -- References + incident_id UUID, + service_order_id UUID, + technician_id UUID NOT NULL, + unit_id UUID, -- Unidad móvil + + -- Checkin + checkin_time TIMESTAMPTZ NOT NULL, + checkin_latitude DECIMAL(10,7), + checkin_longitude DECIMAL(10,7), + checkin_address TEXT, + checkin_photo_url VARCHAR(500), + + -- Checkout + checkout_time TIMESTAMPTZ, + checkout_latitude DECIMAL(10,7), + checkout_longitude DECIMAL(10,7), + checkout_photo_url VARCHAR(500), + + -- Duration + on_site_minutes INTEGER, + + -- Notes + notes TEXT, + + -- Offline + is_offline BOOLEAN DEFAULT FALSE, + device_id VARCHAR(100), + synced_at TIMESTAMPTZ, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================================= +-- INDEXES +-- ============================================================================= + +-- service_checklists +CREATE INDEX idx_checklists_tenant ON field_service.service_checklists(tenant_id); +CREATE INDEX idx_checklists_service_type ON field_service.service_checklists(tenant_id, service_type_code); +CREATE INDEX idx_checklists_active ON field_service.service_checklists(tenant_id, is_active); + +-- checklist_item_templates +CREATE INDEX idx_checklist_items_checklist ON field_service.checklist_item_templates(checklist_id); +CREATE INDEX idx_checklist_items_order ON field_service.checklist_item_templates(checklist_id, item_order); + +-- checklist_responses +CREATE INDEX idx_checklist_resp_tenant ON field_service.checklist_responses(tenant_id); +CREATE INDEX idx_checklist_resp_incident ON field_service.checklist_responses(tenant_id, incident_id); +CREATE INDEX idx_checklist_resp_technician ON field_service.checklist_responses(tenant_id, technician_id); +CREATE INDEX idx_checklist_resp_status ON field_service.checklist_responses(tenant_id, status); +CREATE INDEX idx_checklist_resp_offline ON field_service.checklist_responses(is_offline, synced_at) WHERE is_offline = TRUE; + +-- checklist_item_responses +CREATE INDEX idx_item_resp_response ON field_service.checklist_item_responses(checklist_response_id); + +-- work_logs +CREATE INDEX idx_worklogs_tenant ON field_service.work_logs(tenant_id); +CREATE INDEX idx_worklogs_incident ON field_service.work_logs(tenant_id, incident_id); +CREATE INDEX idx_worklogs_technician ON field_service.work_logs(tenant_id, technician_id); +CREATE INDEX idx_worklogs_active ON field_service.work_logs(tenant_id, status) WHERE status = 'ACTIVE'; +CREATE INDEX idx_worklogs_date ON field_service.work_logs(tenant_id, start_time); + +-- activity_catalog +CREATE INDEX idx_activity_tenant ON field_service.activity_catalog(tenant_id); +CREATE INDEX idx_activity_code ON field_service.activity_catalog(tenant_id, code); + +-- diagnosis_records +CREATE INDEX idx_diagnosis_tenant ON field_service.diagnosis_records(tenant_id); +CREATE INDEX idx_diagnosis_incident ON field_service.diagnosis_records(tenant_id, incident_id); +CREATE INDEX idx_diagnosis_vehicle ON field_service.diagnosis_records(tenant_id, vehicle_id); +CREATE INDEX idx_diagnosis_technician ON field_service.diagnosis_records(tenant_id, technician_id); +CREATE INDEX idx_diagnosis_date ON field_service.diagnosis_records(tenant_id, created_at); + +-- root_cause_catalog +CREATE INDEX idx_root_cause_tenant ON field_service.root_cause_catalog(tenant_id); +CREATE INDEX idx_root_cause_category ON field_service.root_cause_catalog(tenant_id, category); + +-- field_evidence +CREATE INDEX idx_evidence_tenant ON field_service.field_evidence(tenant_id); +CREATE INDEX idx_evidence_incident ON field_service.field_evidence(tenant_id, incident_id); +CREATE INDEX idx_evidence_type ON field_service.field_evidence(tenant_id, evidence_type); +CREATE INDEX idx_evidence_pending ON field_service.field_evidence(pending_upload) WHERE pending_upload = TRUE; + +-- offline_queue_items +CREATE INDEX idx_offline_queue_device ON field_service.offline_queue_items(device_id); +CREATE INDEX idx_offline_queue_status ON field_service.offline_queue_items(status); +CREATE INDEX idx_offline_queue_pending ON field_service.offline_queue_items(status, next_attempt_at) WHERE status = 'PENDING'; +CREATE INDEX idx_offline_queue_entity ON field_service.offline_queue_items(entity_type, entity_id); + +-- field_checkins +CREATE INDEX idx_checkins_tenant ON field_service.field_checkins(tenant_id); +CREATE INDEX idx_checkins_incident ON field_service.field_checkins(tenant_id, incident_id); +CREATE INDEX idx_checkins_technician ON field_service.field_checkins(tenant_id, technician_id); +CREATE INDEX idx_checkins_date ON field_service.field_checkins(tenant_id, checkin_time); + +-- ============================================================================= +-- TRIGGERS +-- ============================================================================= + +-- Auto-update timestamps +CREATE TRIGGER update_service_checklists_updated_at + BEFORE UPDATE ON field_service.service_checklists + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_checklist_item_templates_updated_at + BEFORE UPDATE ON field_service.checklist_item_templates + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_checklist_responses_updated_at + BEFORE UPDATE ON field_service.checklist_responses + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_work_logs_updated_at + BEFORE UPDATE ON field_service.work_logs + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_activity_catalog_updated_at + BEFORE UPDATE ON field_service.activity_catalog + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_diagnosis_records_updated_at + BEFORE UPDATE ON field_service.diagnosis_records + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_root_cause_catalog_updated_at + BEFORE UPDATE ON field_service.root_cause_catalog + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_field_checkins_updated_at + BEFORE UPDATE ON field_service.field_checkins + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_offline_queue_items_updated_at + BEFORE UPDATE ON field_service.offline_queue_items + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================================= +-- FUNCTIONS +-- ============================================================================= + +-- Calculate work log duration on stop +CREATE OR REPLACE FUNCTION field_service.calculate_worklog_duration() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.end_time IS NOT NULL AND OLD.end_time IS NULL THEN + NEW.duration_minutes := EXTRACT(EPOCH FROM (NEW.end_time - NEW.start_time)) / 60 - COALESCE(NEW.total_pause_minutes, 0); + IF NEW.labor_rate IS NOT NULL THEN + NEW.labor_total := (NEW.duration_minutes / 60.0) * NEW.labor_rate; + IF NEW.is_overtime THEN + NEW.labor_total := NEW.labor_total * NEW.overtime_multiplier; + END IF; + END IF; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_calculate_worklog_duration + BEFORE UPDATE ON field_service.work_logs + FOR EACH ROW EXECUTE FUNCTION field_service.calculate_worklog_duration(); + +-- Update checklist response counts +CREATE OR REPLACE FUNCTION field_service.update_checklist_counts() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE field_service.checklist_responses + SET + completed_items = ( + SELECT COUNT(*) FROM field_service.checklist_item_responses + WHERE checklist_response_id = COALESCE(NEW.checklist_response_id, OLD.checklist_response_id) + ), + passed_items = ( + SELECT COUNT(*) FROM field_service.checklist_item_responses + WHERE checklist_response_id = COALESCE(NEW.checklist_response_id, OLD.checklist_response_id) + AND is_passed = TRUE + ), + failed_items = ( + SELECT COUNT(*) FROM field_service.checklist_item_responses + WHERE checklist_response_id = COALESCE(NEW.checklist_response_id, OLD.checklist_response_id) + AND is_passed = FALSE + ) + WHERE id = COALESCE(NEW.checklist_response_id, OLD.checklist_response_id); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_update_checklist_counts + AFTER INSERT OR UPDATE OR DELETE ON field_service.checklist_item_responses + FOR EACH ROW EXECUTE FUNCTION field_service.update_checklist_counts(); + +-- Calculate on-site time for checkins +CREATE OR REPLACE FUNCTION field_service.calculate_onsite_time() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.checkout_time IS NOT NULL AND OLD.checkout_time IS NULL THEN + NEW.on_site_minutes := EXTRACT(EPOCH FROM (NEW.checkout_time - NEW.checkin_time)) / 60; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_calculate_onsite_time + BEFORE UPDATE ON field_service.field_checkins + FOR EACH ROW EXECUTE FUNCTION field_service.calculate_onsite_time(); + +-- ============================================================================= +-- VIEWS +-- ============================================================================= + +-- Active work logs +CREATE OR REPLACE VIEW field_service.vw_active_work_logs AS +SELECT + wl.id, + wl.tenant_id, + wl.technician_id, + wl.incident_id, + wl.service_order_id, + wl.activity_code, + wl.activity_name, + wl.start_time, + EXTRACT(EPOCH FROM (NOW() - wl.start_time)) / 60 AS elapsed_minutes, + wl.labor_rate, + wl.is_overtime +FROM field_service.work_logs wl +WHERE wl.status = 'ACTIVE'; + +-- Pending offline sync +CREATE OR REPLACE VIEW field_service.vw_pending_sync AS +SELECT + oq.id, + oq.device_id, + oq.technician_id, + oq.entity_type, + oq.local_id, + oq.status, + oq.sync_attempts, + oq.last_attempt_at, + oq.error_message, + oq.created_at +FROM field_service.offline_queue_items oq +WHERE oq.status IN ('PENDING', 'FAILED') +ORDER BY oq.priority DESC, oq.created_at ASC; + +-- Technician field activity summary +CREATE OR REPLACE VIEW field_service.vw_technician_activity AS +SELECT + fc.tenant_id, + fc.technician_id, + DATE(fc.checkin_time) AS work_date, + COUNT(DISTINCT fc.id) AS checkins_count, + SUM(fc.on_site_minutes) AS total_onsite_minutes, + COUNT(DISTINCT cr.id) AS checklists_completed, + COUNT(DISTINCT dr.id) AS diagnoses_created, + SUM(wl.duration_minutes) AS total_labor_minutes +FROM field_service.field_checkins fc +LEFT JOIN field_service.checklist_responses cr + ON cr.technician_id = fc.technician_id + AND DATE(cr.completed_at) = DATE(fc.checkin_time) + AND cr.status = 'COMPLETED' +LEFT JOIN field_service.diagnosis_records dr + ON dr.technician_id = fc.technician_id + AND DATE(dr.created_at) = DATE(fc.checkin_time) +LEFT JOIN field_service.work_logs wl + ON wl.technician_id = fc.technician_id + AND DATE(wl.start_time) = DATE(fc.checkin_time) + AND wl.status = 'COMPLETED' +GROUP BY fc.tenant_id, fc.technician_id, DATE(fc.checkin_time); + +-- Checklist completion summary +CREATE OR REPLACE VIEW field_service.vw_checklist_summary AS +SELECT + cr.tenant_id, + cr.checklist_id, + sc.name AS checklist_name, + COUNT(*) AS total_responses, + COUNT(*) FILTER (WHERE cr.status = 'COMPLETED') AS completed_count, + AVG(cr.completed_items::FLOAT / NULLIF(cr.total_items, 0) * 100) AS avg_completion_rate, + AVG(EXTRACT(EPOCH FROM (cr.completed_at - cr.started_at)) / 60) FILTER (WHERE cr.status = 'COMPLETED') AS avg_completion_minutes +FROM field_service.checklist_responses cr +JOIN field_service.service_checklists sc ON sc.id = cr.checklist_id +GROUP BY cr.tenant_id, cr.checklist_id, sc.name; + +-- ============================================================================= +-- RLS POLICIES +-- ============================================================================= + +-- Enable RLS on all tables +ALTER TABLE field_service.service_checklists ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.checklist_item_templates ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.checklist_responses ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.checklist_item_responses ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.work_logs ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.activity_catalog ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.diagnosis_records ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.root_cause_catalog ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.field_evidence ENABLE ROW LEVEL SECURITY; +ALTER TABLE field_service.field_checkins ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +CREATE POLICY tenant_isolation_service_checklists ON field_service.service_checklists + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_checklist_item_templates ON field_service.checklist_item_templates + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_checklist_responses ON field_service.checklist_responses + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_checklist_item_responses ON field_service.checklist_item_responses + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_work_logs ON field_service.work_logs + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_activity_catalog ON field_service.activity_catalog + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_diagnosis_records ON field_service.diagnosis_records + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_root_cause_catalog ON field_service.root_cause_catalog + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_field_evidence ON field_service.field_evidence + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +CREATE POLICY tenant_isolation_field_checkins ON field_service.field_checkins + FOR ALL USING (tenant_id = current_setting('app.tenant_id')::UUID); + +-- Note: offline_queue_items no tiene RLS ya que es por device, no por tenant + +-- ============================================================================= +-- COMMENTS +-- ============================================================================= + +COMMENT ON SCHEMA field_service IS 'MMD-012: Field Service / Bitácora Técnica Móvil'; +COMMENT ON TABLE field_service.service_checklists IS 'Plantillas de checklist por tipo de servicio'; +COMMENT ON TABLE field_service.checklist_item_templates IS 'Items de las plantillas de checklist'; +COMMENT ON TABLE field_service.checklist_responses IS 'Respuestas de checklist capturadas en servicio'; +COMMENT ON TABLE field_service.checklist_item_responses IS 'Respuestas individuales por item'; +COMMENT ON TABLE field_service.work_logs IS 'Registro de mano de obra (timer)'; +COMMENT ON TABLE field_service.activity_catalog IS 'Catálogo de actividades de mano de obra'; +COMMENT ON TABLE field_service.diagnosis_records IS 'Registro de diagnósticos y causas raíz'; +COMMENT ON TABLE field_service.root_cause_catalog IS 'Catálogo de causas raíz'; +COMMENT ON TABLE field_service.field_evidence IS 'Evidencias: fotos, videos, firmas'; +COMMENT ON TABLE field_service.offline_queue_items IS 'Cola de sincronización offline'; +COMMENT ON TABLE field_service.field_checkins IS 'Check-in/Check-out de técnicos en sitio'; + +-- ============================================================================= +-- END OF SCHEMA +-- =============================================================================