BEGIN; CREATE SCHEMA IF NOT EXISTS mv_loss_intelligence; COMMENT ON SCHEMA mv_loss_intelligence IS 'Capa común MESAVAULT LossIntelligence. Conserva configuración, catálogo de acciones, Loss Ledger, pruebas y decisiones. No sustituye las tablas raw de captura.'; CREATE TABLE IF NOT EXISTS mv_loss_intelligence.deployment_config ( id BIGSERIAL PRIMARY KEY, tenant TEXT NOT NULL, site TEXT NOT NULL, scope_code TEXT NOT NULL, scope_name TEXT NOT NULL, mode TEXT NOT NULL DEFAULT 'SHADOW' CHECK (mode IN ('SHADOW', 'ACTIVE', 'PAUSED', 'CLOSED')), planned_go_live_date DATE, actual_go_live_at TIMESTAMPTZ, timezone TEXT NOT NULL DEFAULT 'Europe/Madrid', review_owner_primary TEXT, review_owner_secondary TEXT, review_cadence TEXT NOT NULL DEFAULT 'DAILY', daily_review_limit INTEGER NOT NULL DEFAULT 5 CHECK (daily_review_limit BETWEEN 1 AND 20), notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant, site, scope_code), CHECK (actual_go_live_at IS NULL OR planned_go_live_date IS NULL OR actual_go_live_at::date >= planned_go_live_date - 31) ); CREATE TABLE IF NOT EXISTS mv_loss_intelligence.economic_parameters ( id BIGSERIAL PRIMARY KEY, tenant TEXT NOT NULL, site TEXT NOT NULL, scope_code TEXT NOT NULL DEFAULT 'GLOBAL', machine_id TEXT, product_id BIGINT, parameter_code TEXT NOT NULL, parameter_value NUMERIC(18,6) NOT NULL, unit TEXT NOT NULL, confidence TEXT NOT NULL DEFAULT 'LOW' CHECK (confidence IN ('HIGH', 'MEDIUM', 'LOW', 'NOT_ACCEPTED')), includes_energy BOOLEAN, valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), valid_to TIMESTAMPTZ, is_active BOOLEAN NOT NULL DEFAULT true, source_ref TEXT, approved_by TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (valid_to IS NULL OR valid_to > valid_from) ); CREATE INDEX IF NOT EXISTS idx_li_economic_parameters_lookup ON mv_loss_intelligence.economic_parameters (tenant, site, parameter_code, is_active, valid_from DESC); CREATE TABLE IF NOT EXISTS mv_loss_intelligence.product_cycle_parameters ( id BIGSERIAL PRIMARY KEY, tenant TEXT NOT NULL, site TEXT NOT NULL, machine_id TEXT, product_id BIGINT, product_default_code TEXT, product_name TEXT, units_per_cycle NUMERIC(10,4) NOT NULL CHECK (units_per_cycle > 0), target_cycle_rate_ppm NUMERIC(12,4) CHECK (target_cycle_rate_ppm IS NULL OR target_cycle_rate_ppm > 0), target_units_per_min NUMERIC(14,4) GENERATED ALWAYS AS ( CASE WHEN target_cycle_rate_ppm IS NULL THEN NULL ELSE target_cycle_rate_ppm * units_per_cycle END ) STORED, confidence TEXT NOT NULL DEFAULT 'LOW' CHECK (confidence IN ('HIGH', 'MEDIUM', 'LOW')), source_ref TEXT, approved_by TEXT, valid_from TIMESTAMPTZ NOT NULL DEFAULT now(), valid_to TIMESTAMPTZ, is_active BOOLEAN NOT NULL DEFAULT true, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CHECK (product_id IS NOT NULL OR product_default_code IS NOT NULL), CHECK (valid_to IS NULL OR valid_to > valid_from) ); CREATE INDEX IF NOT EXISTS idx_li_product_cycle_parameters_lookup ON mv_loss_intelligence.product_cycle_parameters (tenant, site, product_id, product_default_code, machine_id, is_active, valid_from DESC); CREATE TABLE IF NOT EXISTS mv_loss_intelligence.loss_policies ( id BIGSERIAL PRIMARY KEY, tenant TEXT NOT NULL, site TEXT NOT NULL, policy_code TEXT NOT NULL, source_type TEXT NOT NULL DEFAULT 'MACHINE_STOP', noise_below_s NUMERIC(12,2) NOT NULL DEFAULT 5 CHECK (noise_below_s >= 0), aggregate_below_s NUMERIC(12,2) NOT NULL DEFAULT 300 CHECK (aggregate_below_s >= noise_below_s), operator_individual_from_s NUMERIC(12,2) NOT NULL DEFAULT 300 CHECK (operator_individual_from_s >= noise_below_s), aggregation_window_min INTEGER NOT NULL DEFAULT 15 CHECK (aggregation_window_min BETWEEN 1 AND 1440), require_valid_order BOOLEAN NOT NULL DEFAULT true, require_closed_event BOOLEAN NOT NULL DEFAULT true, exclude_planned_by_default BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant, site, policy_code) ); CREATE TABLE IF NOT EXISTS mv_loss_intelligence.action_catalog ( action_id TEXT PRIMARY KEY, catalog_code TEXT NOT NULL, catalog_version TEXT NOT NULL, tenant TEXT, site TEXT, loss_type TEXT NOT NULL, source_cause_code TEXT, action_type TEXT NOT NULL CHECK (action_type IN ('TECHNICAL', 'METHOD', 'ORGANIZATIONAL', 'PROCESS_DESIGN', 'DATA_QUALITY', 'INVESTMENT_DECISION')), action_candidate TEXT NOT NULL, applies_when JSONB NOT NULL DEFAULT '[]'::jsonb, does_not_apply_when JSONB NOT NULL DEFAULT '[]'::jsonb, suggested_owner TEXT, test_window TEXT NOT NULL, metric_before TEXT NOT NULL, metric_after TEXT NOT NULL, success_criteria TEXT NOT NULL, risk_level TEXT NOT NULL DEFAULT 'LOW' CHECK (risk_level IN ('LOW', 'MEDIUM', 'HIGH')), cost_to_test TEXT NOT NULL DEFAULT 'LOW' CHECK (cost_to_test IN ('LOW', 'MEDIUM', 'HIGH')), is_active BOOLEAN NOT NULL DEFAULT true, source_ref TEXT, notes TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX IF NOT EXISTS idx_li_action_catalog_lookup ON mv_loss_intelligence.action_catalog (loss_type, source_cause_code, is_active); CREATE TABLE IF NOT EXISTS mv_loss_intelligence.loss_ledger ( loss_id BIGSERIAL PRIMARY KEY, tenant TEXT NOT NULL, site TEXT NOT NULL, deployment_id BIGINT REFERENCES mv_loss_intelligence.deployment_config(id), ledger_phase TEXT NOT NULL DEFAULT 'SHADOW' CHECK (ledger_phase IN ('SHADOW', 'ACTIVE')), source_type TEXT NOT NULL, source_key TEXT NOT NULL, machine_id TEXT, asset TEXT, order_ref TEXT, odoo_production_id BIGINT, odoo_workorder_id BIGINT, product_id BIGINT, product_default_code TEXT, product_name TEXT, started_at TIMESTAMPTZ, ended_at TIMESTAMPTZ, event_count INTEGER NOT NULL DEFAULT 1 CHECK (event_count > 0), duration_s NUMERIC(18,2) NOT NULL DEFAULT 0 CHECK (duration_s >= 0), cycle_count NUMERIC(18,4), units_per_cycle NUMERIC(10,4), estimated_units NUMERIC(18,4), loss_type TEXT NOT NULL, cause_code TEXT, classification_status TEXT NOT NULL DEFAULT 'PENDING' CHECK (classification_status IN ('PENDING', 'CLASSIFIED', 'EXCLUDED', 'NOT_REQUIRED')), capacity_loss_eur NUMERIC(18,2), energy_waste_kwh NUMERIC(18,6), energy_waste_eur NUMERIC(18,2), technical_confidence TEXT NOT NULL DEFAULT 'MEDIUM' CHECK (technical_confidence IN ('HIGH', 'MEDIUM', 'LOW')), economic_confidence TEXT NOT NULL DEFAULT 'LOW' CHECK (economic_confidence IN ('HIGH', 'MEDIUM', 'LOW', 'NOT_ACCEPTED')), priority_score NUMERIC(12,2), priority_level TEXT CHECK (priority_level IS NULL OR priority_level IN ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL')), action_catalog_id TEXT REFERENCES mv_loss_intelligence.action_catalog(action_id), action_candidate TEXT, action_final TEXT, review_owner TEXT, review_due_at TIMESTAMPTZ, status TEXT NOT NULL DEFAULT 'DETECTED' CHECK (status IN ('DETECTED', 'CANDIDATE', 'IN_REVIEW', 'IN_TRIAL', 'VALIDATED', 'ADAPTED', 'DISCARDED', 'ESCALATED', 'CLOSED')), learning_reusable TEXT, evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb, detected_at TIMESTAMPTZ NOT NULL DEFAULT now(), promoted_at TIMESTAMPTZ, closed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant, site, source_type, source_key) ); CREATE INDEX IF NOT EXISTS idx_li_loss_ledger_review ON mv_loss_intelligence.loss_ledger (tenant, site, status, review_due_at, priority_score DESC); CREATE INDEX IF NOT EXISTS idx_li_loss_ledger_order_machine ON mv_loss_intelligence.loss_ledger (tenant, site, machine_id, order_ref, started_at DESC); CREATE TABLE IF NOT EXISTS mv_loss_intelligence.action_trials ( trial_id BIGSERIAL PRIMARY KEY, loss_id BIGINT NOT NULL REFERENCES mv_loss_intelligence.loss_ledger(loss_id) ON DELETE CASCADE, trial_code TEXT NOT NULL, action_final TEXT NOT NULL, owner TEXT NOT NULL, baseline_from TIMESTAMPTZ, baseline_to TIMESTAMPTZ, test_from TIMESTAMPTZ, test_to TIMESTAMPTZ, metric_code TEXT NOT NULL, metric_unit TEXT, metric_before NUMERIC(18,6), metric_after NUMERIC(18,6), change_pct NUMERIC(12,4), success_criteria TEXT NOT NULL, result TEXT CHECK (result IS NULL OR result IN ('PENDING', 'SUCCESS', 'PARTIAL', 'NO_CHANGE', 'WORSE', 'INVALID_TEST')), decision TEXT CHECK (decision IS NULL OR decision IN ('KEEP', 'ADAPT', 'REPEAT', 'DISCARD', 'SCALE')), learning TEXT, evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (loss_id, trial_code) ); CREATE TABLE IF NOT EXISTS mv_loss_intelligence.review_decisions ( decision_id BIGSERIAL PRIMARY KEY, loss_id BIGINT REFERENCES mv_loss_intelligence.loss_ledger(loss_id) ON DELETE CASCADE, trial_id BIGINT REFERENCES mv_loss_intelligence.action_trials(trial_id) ON DELETE CASCADE, decision_type TEXT NOT NULL CHECK (decision_type IN ('PROMOTE', 'CLASSIFY', 'ASSIGN_ACTION', 'ADAPT_ACTION', 'DEFER', 'EXCLUDE', 'CLOSE', 'ESCALATE')), decision_text TEXT NOT NULL, decided_by TEXT NOT NULL, decided_at TIMESTAMPTZ NOT NULL DEFAULT now(), next_review_at TIMESTAMPTZ, evidence_json JSONB NOT NULL DEFAULT '{}'::jsonb, CHECK (loss_id IS NOT NULL OR trial_id IS NOT NULL) ); DO $$ BEGIN IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'grafana_ucepsa_ro') THEN GRANT USAGE ON SCHEMA mv_loss_intelligence TO grafana_ucepsa_ro; GRANT SELECT ON ALL TABLES IN SCHEMA mv_loss_intelligence TO grafana_ucepsa_ro; END IF; END $$; COMMIT;