erp-construccion/orchestration/propagacion-fase8/FASE-3-PLAN-IMPLEMENTACION.md
rckrdmrd 7f422e51db
Some checks failed
CI Pipeline / Lint & Type Check (push) Has been cancelled
CI Pipeline / Validate SSOT Constants (push) Has been cancelled
CI Pipeline / Backend Tests (push) Has been cancelled
CI Pipeline / Frontend Tests (push) Has been cancelled
CI Pipeline / Build (push) Has been cancelled
CI Pipeline / Docker Build (push) Has been cancelled
feat: Documentation and orchestration updates
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 05:35:28 -06:00

52 KiB

FASE 3: Plan de Implementación - ERP Construcción

Proyecto: erp-construccion Fecha: 2026-01-04 Estado: Completado Base: FASE-2-ANALISIS-DETALLADO.md


1. Archivos DDL a Crear

1.1 Archivo: 08-financial-ext-schema-ddl.sql

Propósito: Extensiones financieras para construcción

-- ============================================
-- ERP CONSTRUCCIÓN - EXTENSIONES FINANCIAL
-- Basado en: ERP-Core FASE-8 (COR-035 a COR-039)
-- Fecha: 2026-01-04
-- ============================================

-- Verificar schema
CREATE SCHEMA IF NOT EXISTS financial;

-- ============================================
-- 1. PAYMENT TERM LINES (COR-035)
-- Términos de pago para estimaciones de obra
-- ============================================

CREATE TABLE IF NOT EXISTS financial.payment_term_lines (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  payment_term_id UUID NOT NULL REFERENCES financial.payment_terms(id) ON DELETE CASCADE,
  sequence INTEGER NOT NULL DEFAULT 10,
  value VARCHAR(20) NOT NULL DEFAULT 'percent',
  value_amount DECIMAL(10,4) NOT NULL DEFAULT 100,
  days INTEGER NOT NULL DEFAULT 0,
  day_of_month INTEGER,
  end_month BOOLEAN DEFAULT FALSE,
  -- Extensión construcción
  applies_to VARCHAR(50), -- 'anticipo', 'estimacion', 'retencion', 'finiquito'
  created_at TIMESTAMPTZ DEFAULT NOW(),

  CONSTRAINT chk_value CHECK (value IN ('percent', 'fixed', 'balance')),
  CONSTRAINT chk_applies_to CHECK (applies_to IS NULL OR applies_to IN ('anticipo', 'estimacion', 'retencion', 'finiquito'))
);

-- Índices
CREATE INDEX IF NOT EXISTS idx_payment_term_lines_tenant ON financial.payment_term_lines(tenant_id);
CREATE INDEX IF NOT EXISTS idx_payment_term_lines_term ON financial.payment_term_lines(payment_term_id);

-- RLS
ALTER TABLE financial.payment_term_lines ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_payment_term_lines ON financial.payment_term_lines
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 2. INCOTERMS (COR-036)
-- Para importación de maquinaria/materiales
-- ============================================

CREATE TABLE IF NOT EXISTS financial.incoterms (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code VARCHAR(3) NOT NULL UNIQUE,
  name VARCHAR(100) NOT NULL,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- ============================================
-- 3. PAYMENT METHODS (COR-037)
-- Métodos de pago específicos de obra
-- ============================================

CREATE TYPE financial.payment_method_type AS ENUM ('inbound', 'outbound');

CREATE TABLE IF NOT EXISTS financial.payment_methods (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  name VARCHAR(100) NOT NULL,
  code VARCHAR(50) NOT NULL,
  payment_type financial.payment_method_type NOT NULL,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW(),

  UNIQUE(tenant_id, code)
);

-- Índices
CREATE INDEX IF NOT EXISTS idx_payment_methods_tenant ON financial.payment_methods(tenant_id);

-- RLS
ALTER TABLE financial.payment_methods ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_payment_methods ON financial.payment_methods
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 4. RECONCILE MODELS (COR-038)
-- Para conciliación de estimaciones
-- ============================================

CREATE TYPE financial.reconcile_model_type AS ENUM (
  'writeoff_button',
  'writeoff_suggestion',
  'invoice_matching'
);

CREATE TABLE IF NOT EXISTS financial.reconcile_models (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  company_id UUID REFERENCES core.companies(id),
  name VARCHAR(100) NOT NULL,
  sequence INTEGER DEFAULT 10,
  rule_type financial.reconcile_model_type NOT NULL DEFAULT 'writeoff_suggestion',
  auto_reconcile BOOLEAN DEFAULT FALSE,
  match_amount VARCHAR(20) DEFAULT 'percentage',
  match_amount_min DECIMAL(5,2),
  match_amount_max DECIMAL(5,2),
  match_label VARCHAR(100),
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW(),

  CONSTRAINT chk_match_amount CHECK (match_amount IN ('percentage', 'fixed', 'any'))
);

CREATE TABLE IF NOT EXISTS financial.reconcile_model_lines (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  model_id UUID NOT NULL REFERENCES financial.reconcile_models(id) ON DELETE CASCADE,
  sequence INTEGER DEFAULT 10,
  account_id UUID NOT NULL REFERENCES financial.accounts(id),
  amount_type VARCHAR(20) NOT NULL DEFAULT 'percentage',
  amount_value DECIMAL(18,4) NOT NULL DEFAULT 100,
  label VARCHAR(100),
  created_at TIMESTAMPTZ DEFAULT NOW(),

  CONSTRAINT chk_amount_type CHECK (amount_type IN ('percentage', 'fixed', 'regex'))
);

-- Índices
CREATE INDEX IF NOT EXISTS idx_reconcile_models_tenant ON financial.reconcile_models(tenant_id);
CREATE INDEX IF NOT EXISTS idx_reconcile_model_lines_model ON financial.reconcile_model_lines(model_id);

-- RLS
ALTER TABLE financial.reconcile_models ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_reconcile_models ON financial.reconcile_models
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 5. CAMPOS ADICIONALES (COR-039)
-- Modificar tablas existentes
-- ============================================

-- Campos en journal_entries (si existe)
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'financial' AND table_name = 'journal_entries') THEN
    -- payment_state
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'financial' AND table_name = 'journal_entries' AND column_name = 'payment_state') THEN
      ALTER TABLE financial.journal_entries ADD COLUMN payment_state VARCHAR(20) DEFAULT 'not_paid';
    END IF;
    -- amount_residual
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'financial' AND table_name = 'journal_entries' AND column_name = 'amount_residual') THEN
      ALTER TABLE financial.journal_entries ADD COLUMN amount_residual DECIMAL(18,4) DEFAULT 0;
    END IF;
    -- invoice_date_due
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'financial' AND table_name = 'journal_entries' AND column_name = 'invoice_date_due') THEN
      ALTER TABLE financial.journal_entries ADD COLUMN invoice_date_due DATE;
    END IF;
    -- incoterm_id
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'financial' AND table_name = 'journal_entries' AND column_name = 'incoterm_id') THEN
      ALTER TABLE financial.journal_entries ADD COLUMN incoterm_id UUID REFERENCES financial.incoterms(id);
    END IF;
  END IF;
END $$;

-- Campos en payments (si existe)
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'financial' AND table_name = 'payments') THEN
    -- is_matched
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'financial' AND table_name = 'payments' AND column_name = 'is_matched') THEN
      ALTER TABLE financial.payments ADD COLUMN is_matched BOOLEAN DEFAULT FALSE;
    END IF;
    -- partner_bank_id
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'financial' AND table_name = 'payments' AND column_name = 'partner_bank_id') THEN
      ALTER TABLE financial.payments ADD COLUMN partner_bank_id UUID;
    END IF;
  END IF;
END $$;

COMMENT ON TABLE financial.payment_term_lines IS 'ERP-Core FASE-8: Líneas de términos de pago para estimaciones';
COMMENT ON TABLE financial.incoterms IS 'ERP-Core FASE-8: Términos de comercio internacional';
COMMENT ON TABLE financial.payment_methods IS 'ERP-Core FASE-8: Métodos de pago de obra';
COMMENT ON TABLE financial.reconcile_models IS 'ERP-Core FASE-8: Modelos de conciliación';
COMMENT ON TABLE financial.reconcile_model_lines IS 'ERP-Core FASE-8: Líneas de modelos de conciliación';

Líneas estimadas: ~180


1.2 Archivo: 09-projects-ext-schema-ddl.sql

Propósito: Extensiones de proyectos adaptadas a construcción

-- ============================================
-- ERP CONSTRUCCIÓN - EXTENSIONES PROJECTS
-- Basado en: ERP-Core FASE-8 (COR-056 a COR-060)
-- Fecha: 2026-01-04
-- ============================================

-- ============================================
-- 1. COLLABORATORS (COR-056)
-- Colaboradores de obra: supervisores, peritos
-- ============================================

CREATE TABLE IF NOT EXISTS projects.collaborators (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  project_id UUID NOT NULL REFERENCES projects.projects(id) ON DELETE CASCADE,
  partner_id UUID REFERENCES core.partners(id),
  user_id UUID REFERENCES auth.users(id),
  can_read BOOLEAN DEFAULT TRUE,
  can_write BOOLEAN DEFAULT FALSE,
  -- Extensión construcción
  rol VARCHAR(50), -- 'supervisor', 'perito', 'representante', 'infonavit'
  vigencia_desde DATE,
  vigencia_hasta DATE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  invited_by UUID REFERENCES auth.users(id),

  CONSTRAINT chk_partner_or_user CHECK (partner_id IS NOT NULL OR user_id IS NOT NULL),
  CONSTRAINT chk_rol CHECK (rol IS NULL OR rol IN ('supervisor', 'perito', 'representante', 'infonavit', 'contratista', 'auditor'))
);

-- Índices
CREATE INDEX IF NOT EXISTS idx_collaborators_project ON projects.collaborators(project_id);
CREATE INDEX IF NOT EXISTS idx_collaborators_partner ON projects.collaborators(partner_id);
CREATE INDEX IF NOT EXISTS idx_collaborators_user ON projects.collaborators(user_id);

-- RLS
ALTER TABLE projects.collaborators ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_collaborators ON projects.collaborators
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 2. RATINGS (COR-059)
-- Calificaciones de proveedores/contratistas
-- ============================================

CREATE TABLE IF NOT EXISTS projects.ratings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  res_model VARCHAR(100) NOT NULL, -- 'construction.contratistas', 'core.partners'
  res_id UUID NOT NULL,
  rating DECIMAL(3,2) NOT NULL CHECK (rating >= 1 AND rating <= 5),
  feedback TEXT,
  partner_id UUID REFERENCES core.partners(id),
  rated_by UUID REFERENCES auth.users(id),
  -- Extensión construcción
  proyecto_id UUID, -- Calificación por proyecto
  tipo_trabajo VARCHAR(50), -- 'albanileria', 'electricidad', etc.
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Índices
CREATE INDEX IF NOT EXISTS idx_ratings_tenant ON projects.ratings(tenant_id);
CREATE INDEX IF NOT EXISTS idx_ratings_res ON projects.ratings(res_model, res_id);
CREATE INDEX IF NOT EXISTS idx_ratings_proyecto ON projects.ratings(proyecto_id);

-- RLS
ALTER TABLE projects.ratings ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_ratings ON projects.ratings
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 3. AVANCE PROGRAMADO (COR-060 adaptado)
-- Burndown chart adaptado a construcción
-- ============================================

CREATE TABLE IF NOT EXISTS construction.avance_programado (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  proyecto_id UUID NOT NULL REFERENCES construction.proyectos(id) ON DELETE CASCADE,
  fecha DATE NOT NULL,

  -- Avance físico
  total_partidas INTEGER NOT NULL DEFAULT 0,
  partidas_completadas INTEGER NOT NULL DEFAULT 0,
  partidas_pendientes INTEGER NOT NULL DEFAULT 0,
  partidas_en_proceso INTEGER NOT NULL DEFAULT 0,

  -- Avance financiero
  presupuesto_total DECIMAL(20,2) NOT NULL DEFAULT 0,
  ejercido DECIMAL(20,2) NOT NULL DEFAULT 0,
  por_ejercer DECIMAL(20,2) NOT NULL DEFAULT 0,
  estimado_actual DECIMAL(20,2) NOT NULL DEFAULT 0,

  -- Porcentajes
  avance_fisico_programado DECIMAL(5,2) NOT NULL DEFAULT 0,
  avance_fisico_real DECIMAL(5,2) NOT NULL DEFAULT 0,
  avance_financiero_pct DECIMAL(5,2) NOT NULL DEFAULT 0,

  -- Horas (para mano de obra)
  horas_programadas DECIMAL(10,2) DEFAULT 0,
  horas_ejecutadas DECIMAL(10,2) DEFAULT 0,

  -- Metadatos
  created_at TIMESTAMPTZ DEFAULT NOW(),
  created_by UUID REFERENCES auth.users(id),

  UNIQUE(proyecto_id, fecha)
);

-- Índices
CREATE INDEX IF NOT EXISTS idx_avance_proyecto ON construction.avance_programado(proyecto_id);
CREATE INDEX IF NOT EXISTS idx_avance_fecha ON construction.avance_programado(fecha);

-- RLS
ALTER TABLE construction.avance_programado ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_avance ON construction.avance_programado
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 4. CAMPOS ADICIONALES EN PROYECTOS (COR-057)
-- ============================================

DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'construction' AND table_name = 'proyectos') THEN
    -- sequence
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'construction' AND table_name = 'proyectos' AND column_name = 'sequence') THEN
      ALTER TABLE construction.proyectos ADD COLUMN sequence INTEGER DEFAULT 10;
    END IF;
    -- is_favorite
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'construction' AND table_name = 'proyectos' AND column_name = 'is_favorite') THEN
      ALTER TABLE construction.proyectos ADD COLUMN is_favorite BOOLEAN DEFAULT FALSE;
    END IF;
    -- task_count (partidas)
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'construction' AND table_name = 'proyectos' AND column_name = 'partida_count') THEN
      ALTER TABLE construction.proyectos ADD COLUMN partida_count INTEGER DEFAULT 0;
    END IF;
    -- open_task_count
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'construction' AND table_name = 'proyectos' AND column_name = 'partida_pendiente_count') THEN
      ALTER TABLE construction.proyectos ADD COLUMN partida_pendiente_count INTEGER DEFAULT 0;
    END IF;
    -- closed_task_count
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'construction' AND table_name = 'proyectos' AND column_name = 'partida_completada_count') THEN
      ALTER TABLE construction.proyectos ADD COLUMN partida_completada_count INTEGER DEFAULT 0;
    END IF;
    -- last_update_status
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'construction' AND table_name = 'proyectos' AND column_name = 'status_avance') THEN
      ALTER TABLE construction.proyectos ADD COLUMN status_avance VARCHAR(20) DEFAULT 'on_track';
    END IF;
  END IF;
END $$;

-- ============================================
-- 5. TRIGGER PARA CONTEO DE PARTIDAS (COR-058)
-- ============================================

CREATE OR REPLACE FUNCTION construction.update_proyecto_partida_count()
RETURNS TRIGGER AS $$
DECLARE
  v_proyecto_id UUID;
  v_total INTEGER;
  v_completadas INTEGER;
  v_pendientes INTEGER;
BEGIN
  -- Determinar proyecto_id según operación
  IF TG_OP = 'DELETE' THEN
    v_proyecto_id := OLD.proyecto_id;
  ELSE
    v_proyecto_id := NEW.proyecto_id;
  END IF;

  -- Calcular conteos
  SELECT
    COUNT(*),
    COUNT(*) FILTER (WHERE estado = 'completada'),
    COUNT(*) FILTER (WHERE estado IN ('pendiente', 'en_proceso'))
  INTO v_total, v_completadas, v_pendientes
  FROM construction.partidas
  WHERE proyecto_id = v_proyecto_id;

  -- Actualizar proyecto
  UPDATE construction.proyectos
  SET
    partida_count = v_total,
    partida_completada_count = v_completadas,
    partida_pendiente_count = v_pendientes,
    updated_at = NOW()
  WHERE id = v_proyecto_id;

  RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

-- Crear trigger si tabla partidas existe
DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'construction' AND table_name = 'partidas') THEN
    DROP TRIGGER IF EXISTS trg_update_proyecto_partida_count ON construction.partidas;
    CREATE TRIGGER trg_update_proyecto_partida_count
      AFTER INSERT OR UPDATE OR DELETE ON construction.partidas
      FOR EACH ROW EXECUTE FUNCTION construction.update_proyecto_partida_count();
  END IF;
END $$;

-- ============================================
-- 6. FUNCIÓN PARA SNAPSHOT DE AVANCE (COR-060)
-- ============================================

CREATE OR REPLACE FUNCTION construction.generate_avance_snapshot(
  p_proyecto_id UUID,
  p_fecha DATE DEFAULT CURRENT_DATE
)
RETURNS UUID AS $$
DECLARE
  v_id UUID;
  v_tenant_id UUID;
  v_total_partidas INTEGER;
  v_completadas INTEGER;
  v_pendientes INTEGER;
  v_en_proceso INTEGER;
  v_presupuesto DECIMAL(20,2);
  v_ejercido DECIMAL(20,2);
  v_avance_fisico DECIMAL(5,2);
BEGIN
  -- Obtener tenant
  SELECT tenant_id INTO v_tenant_id
  FROM construction.proyectos WHERE id = p_proyecto_id;

  -- Contar partidas
  SELECT
    COUNT(*),
    COUNT(*) FILTER (WHERE estado = 'completada'),
    COUNT(*) FILTER (WHERE estado = 'pendiente'),
    COUNT(*) FILTER (WHERE estado = 'en_proceso')
  INTO v_total_partidas, v_completadas, v_pendientes, v_en_proceso
  FROM construction.partidas
  WHERE proyecto_id = p_proyecto_id;

  -- Obtener montos del presupuesto
  SELECT
    COALESCE(SUM(importe_total), 0),
    COALESCE(SUM(importe_ejercido), 0)
  INTO v_presupuesto, v_ejercido
  FROM construction.presupuesto_partidas
  WHERE presupuesto_id IN (
    SELECT id FROM construction.presupuestos WHERE proyecto_id = p_proyecto_id
  );

  -- Calcular avance físico
  IF v_total_partidas > 0 THEN
    v_avance_fisico := (v_completadas::DECIMAL / v_total_partidas) * 100;
  ELSE
    v_avance_fisico := 0;
  END IF;

  -- Insertar o actualizar snapshot
  INSERT INTO construction.avance_programado (
    tenant_id, proyecto_id, fecha,
    total_partidas, partidas_completadas, partidas_pendientes, partidas_en_proceso,
    presupuesto_total, ejercido, por_ejercer,
    avance_fisico_real, avance_financiero_pct
  ) VALUES (
    v_tenant_id, p_proyecto_id, p_fecha,
    v_total_partidas, v_completadas, v_pendientes, v_en_proceso,
    v_presupuesto, v_ejercido, v_presupuesto - v_ejercido,
    v_avance_fisico,
    CASE WHEN v_presupuesto > 0 THEN (v_ejercido / v_presupuesto) * 100 ELSE 0 END
  )
  ON CONFLICT (proyecto_id, fecha) DO UPDATE SET
    total_partidas = EXCLUDED.total_partidas,
    partidas_completadas = EXCLUDED.partidas_completadas,
    partidas_pendientes = EXCLUDED.partidas_pendientes,
    partidas_en_proceso = EXCLUDED.partidas_en_proceso,
    presupuesto_total = EXCLUDED.presupuesto_total,
    ejercido = EXCLUDED.ejercido,
    por_ejercer = EXCLUDED.por_ejercer,
    avance_fisico_real = EXCLUDED.avance_fisico_real,
    avance_financiero_pct = EXCLUDED.avance_financiero_pct
  RETURNING id INTO v_id;

  RETURN v_id;
END;
$$ LANGUAGE plpgsql;

COMMENT ON TABLE projects.collaborators IS 'ERP-Core FASE-8: Colaboradores de proyecto/obra';
COMMENT ON TABLE projects.ratings IS 'ERP-Core FASE-8: Calificaciones de proveedores/contratistas';
COMMENT ON TABLE construction.avance_programado IS 'ERP-Core FASE-8: Snapshots de avance de obra (burndown)';
COMMENT ON FUNCTION construction.generate_avance_snapshot IS 'Genera snapshot diario de avance de obra';

Líneas estimadas: ~280


2. Archivos DDL a Modificar

2.1 Modificar: 02-hr-schema-ddl.sql

Agregar al final del archivo:

-- ============================================
-- EXTENSIONES HR - ERP-Core FASE-8
-- COR-061 a COR-066
-- ============================================

-- ============================================
-- 1. WORK LOCATIONS (COR-062)
-- ============================================

CREATE TABLE IF NOT EXISTS hr.work_locations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  company_id UUID REFERENCES core.companies(id),
  name VARCHAR(100) NOT NULL,
  location_type VARCHAR(50) DEFAULT 'office',
  address_id UUID REFERENCES core.addresses(id),
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW(),

  CONSTRAINT chk_location_type CHECK (location_type IN ('office', 'home', 'other'))
);

-- RLS
ALTER TABLE hr.work_locations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_work_locations ON hr.work_locations
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Extensión: Ubicaciones de obra
CREATE TABLE IF NOT EXISTS construction.ubicaciones_obra (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  work_location_id UUID REFERENCES hr.work_locations(id),
  proyecto_id UUID NOT NULL REFERENCES construction.proyectos(id),
  tipo VARCHAR(50) NOT NULL,
  nombre VARCHAR(100),
  coordenadas_lat DECIMAL(10,8),
  coordenadas_lng DECIMAL(11,8),
  radio_metros INTEGER DEFAULT 100,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW(),

  CONSTRAINT chk_tipo CHECK (tipo IN ('frente', 'almacen', 'oficina_obra', 'caseta', 'acceso'))
);

ALTER TABLE construction.ubicaciones_obra ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_ubicaciones_obra ON construction.ubicaciones_obra
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 2. SKILLS SYSTEM (COR-063)
-- ============================================

CREATE TABLE IF NOT EXISTS hr.skill_types (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  name VARCHAR(100) NOT NULL,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS hr.skills (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  skill_type_id UUID NOT NULL REFERENCES hr.skill_types(id) ON DELETE CASCADE,
  name VARCHAR(100) NOT NULL,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS hr.skill_levels (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  skill_type_id UUID NOT NULL REFERENCES hr.skill_types(id) ON DELETE CASCADE,
  name VARCHAR(50) NOT NULL,
  level INTEGER NOT NULL DEFAULT 1,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS hr.employee_skills (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE,
  skill_id UUID NOT NULL REFERENCES hr.skills(id),
  skill_level_id UUID REFERENCES hr.skill_levels(id),
  created_at TIMESTAMPTZ DEFAULT NOW(),

  UNIQUE(employee_id, skill_id)
);

-- RLS para skills
ALTER TABLE hr.skill_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.skills ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.skill_levels ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_skill_types ON hr.skill_types
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_skills ON hr.skills
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_skill_levels ON hr.skill_levels
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 3. EXPENSE SYSTEM (COR-064)
-- ============================================

CREATE TYPE hr.expense_status AS ENUM ('draft', 'submitted', 'approved', 'posted', 'paid', 'rejected');

CREATE TABLE IF NOT EXISTS hr.expense_sheets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  company_id UUID REFERENCES core.companies(id),
  employee_id UUID NOT NULL REFERENCES hr.employees(id),
  name VARCHAR(100) NOT NULL,
  status hr.expense_status DEFAULT 'draft',
  total_amount DECIMAL(18,4) DEFAULT 0,
  currency_id UUID REFERENCES core.currencies(id),
  approved_by UUID REFERENCES auth.users(id),
  approved_at TIMESTAMPTZ,
  -- Extensión construcción
  proyecto_id UUID, -- Asignar a proyecto
  centro_costo VARCHAR(50), -- Por frente/etapa
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS hr.expenses (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  expense_sheet_id UUID REFERENCES hr.expense_sheets(id) ON DELETE SET NULL,
  employee_id UUID NOT NULL REFERENCES hr.employees(id),
  name VARCHAR(200) NOT NULL,
  product_id UUID REFERENCES inventory.products(id),
  unit_amount DECIMAL(18,4) NOT NULL,
  quantity DECIMAL(10,4) DEFAULT 1,
  total_amount DECIMAL(18,4) GENERATED ALWAYS AS (unit_amount * quantity) STORED,
  currency_id UUID REFERENCES core.currencies(id),
  date DATE NOT NULL DEFAULT CURRENT_DATE,
  reference VARCHAR(100),
  description TEXT,
  -- Extensión construcción
  proyecto_id UUID,
  partida_id UUID, -- Asignar a partida específica
  created_at TIMESTAMPTZ DEFAULT NOW()
);

ALTER TABLE hr.expense_sheets ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.expenses ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_expense_sheets ON hr.expense_sheets
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_expenses ON hr.expenses
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 4. RESUME LINES (COR-065)
-- ============================================

CREATE TYPE hr.resume_line_type AS ENUM ('experience', 'education', 'certification', 'internal');

CREATE TABLE IF NOT EXISTS hr.employee_resume_lines (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  employee_id UUID NOT NULL REFERENCES hr.employees(id) ON DELETE CASCADE,
  name VARCHAR(200) NOT NULL,
  date_start DATE,
  date_end DATE,
  line_type hr.resume_line_type NOT NULL,
  description TEXT,
  -- Para experience
  company_name VARCHAR(100),
  job_title VARCHAR(100),
  -- Para education
  institution VARCHAR(100),
  degree VARCHAR(100),
  -- Para certification (importante en construcción)
  certificate_number VARCHAR(50),
  issuing_authority VARCHAR(100), -- STPS, NOM, etc.
  expiry_date DATE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- ============================================
-- 5. PAYSLIP BASICS (COR-066)
-- ============================================

CREATE TYPE hr.payslip_status AS ENUM ('draft', 'verify', 'done', 'cancel');

CREATE TABLE IF NOT EXISTS hr.payslip_structures (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  name VARCHAR(100) NOT NULL,
  code VARCHAR(50) NOT NULL,
  is_active BOOLEAN DEFAULT TRUE,
  -- Extensión construcción
  tipo_pago VARCHAR(50), -- 'quincenal', 'semanal', 'destajo'
  created_at TIMESTAMPTZ DEFAULT NOW(),

  UNIQUE(tenant_id, code)
);

CREATE TABLE IF NOT EXISTS hr.payslips (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  company_id UUID REFERENCES core.companies(id),
  employee_id UUID NOT NULL REFERENCES hr.employees(id),
  contract_id UUID REFERENCES hr.contracts(id),
  structure_id UUID REFERENCES hr.payslip_structures(id),
  name VARCHAR(100) NOT NULL,
  number VARCHAR(50),
  status hr.payslip_status DEFAULT 'draft',
  date_from DATE NOT NULL,
  date_to DATE NOT NULL,
  -- Extensión construcción
  proyecto_id UUID, -- Nómina por proyecto
  is_destajo BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS hr.payslip_lines (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  payslip_id UUID NOT NULL REFERENCES hr.payslips(id) ON DELETE CASCADE,
  name VARCHAR(100) NOT NULL,
  code VARCHAR(50) NOT NULL,
  category VARCHAR(50) NOT NULL, -- 'basic', 'allowance', 'deduction', 'gross', 'net'
  quantity DECIMAL(10,4) DEFAULT 1,
  rate DECIMAL(18,4) DEFAULT 0,
  amount DECIMAL(18,4) NOT NULL DEFAULT 0,
  sequence INTEGER DEFAULT 10,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

ALTER TABLE hr.payslip_structures ENABLE ROW LEVEL SECURITY;
ALTER TABLE hr.payslips ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_payslip_structures ON hr.payslip_structures
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_payslips ON hr.payslips
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 6. CAMPOS ADICIONALES EN EMPLOYEES (COR-061)
-- ============================================

DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'hr' AND table_name = 'employees') THEN
    -- work_location_id
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'hr' AND table_name = 'employees' AND column_name = 'work_location_id') THEN
      ALTER TABLE hr.employees ADD COLUMN work_location_id UUID REFERENCES hr.work_locations(id);
    END IF;
    -- badge_id (gafete)
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'hr' AND table_name = 'employees' AND column_name = 'badge_id') THEN
      ALTER TABLE hr.employees ADD COLUMN badge_id VARCHAR(50);
    END IF;
    -- pin (para entrada)
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'hr' AND table_name = 'employees' AND column_name = 'pin') THEN
      ALTER TABLE hr.employees ADD COLUMN pin VARCHAR(10);
    END IF;
    -- barcode (asistencia)
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'hr' AND table_name = 'employees' AND column_name = 'barcode') THEN
      ALTER TABLE hr.employees ADD COLUMN barcode VARCHAR(50);
    END IF;
    -- vehicle
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'hr' AND table_name = 'employees' AND column_name = 'vehicle') THEN
      ALTER TABLE hr.employees ADD COLUMN vehicle VARCHAR(100);
    END IF;
    -- vehicle_license_plate
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'hr' AND table_name = 'employees' AND column_name = 'vehicle_license_plate') THEN
      ALTER TABLE hr.employees ADD COLUMN vehicle_license_plate VARCHAR(20);
    END IF;
    -- certificate (nivel educativo)
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'hr' AND table_name = 'employees' AND column_name = 'certificate') THEN
      ALTER TABLE hr.employees ADD COLUMN certificate VARCHAR(50);
    END IF;
  END IF;
END $$;

Líneas a agregar: ~300


2.2 Modificar: 06-inventory-ext-schema-ddl.sql

Agregar al final del archivo:

-- ============================================
-- EXTENSIONES INVENTORY - ERP-Core FASE-8
-- COR-040 a COR-044
-- ============================================

-- ============================================
-- 1. PACKAGE TYPES (COR-040)
-- ============================================

CREATE TABLE IF NOT EXISTS inventory.package_types (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  name VARCHAR(100) NOT NULL,
  sequence INTEGER DEFAULT 10,
  height DECIMAL(10,2), -- cm
  width DECIMAL(10,2),
  length DECIMAL(10,2),
  base_weight DECIMAL(10,4), -- kg
  max_weight DECIMAL(10,4),
  barcode VARCHAR(50),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE IF NOT EXISTS inventory.packages (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  name VARCHAR(100) NOT NULL,
  package_type_id UUID REFERENCES inventory.package_types(id),
  shipping_weight DECIMAL(10,4),
  location_id UUID REFERENCES inventory.locations(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- RLS
ALTER TABLE inventory.package_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE inventory.packages ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_package_types ON inventory.package_types
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_packages ON inventory.packages
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 2. STORAGE CATEGORIES (COR-042)
-- ============================================

CREATE TABLE IF NOT EXISTS inventory.storage_categories (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  name VARCHAR(100) NOT NULL,
  max_weight DECIMAL(12,4),
  allow_new_product VARCHAR(20) DEFAULT 'mixed',
  created_at TIMESTAMPTZ DEFAULT NOW(),

  CONSTRAINT chk_allow CHECK (allow_new_product IN ('empty', 'same', 'mixed'))
);

ALTER TABLE inventory.storage_categories ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_storage_categories ON inventory.storage_categories
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 3. PUTAWAY RULES (COR-041)
-- ============================================

CREATE TABLE IF NOT EXISTS inventory.putaway_rules (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  product_id UUID REFERENCES inventory.products(id),
  category_id UUID REFERENCES inventory.categories(id),
  location_in_id UUID NOT NULL REFERENCES inventory.locations(id),
  location_out_id UUID NOT NULL REFERENCES inventory.locations(id),
  sequence INTEGER DEFAULT 10,
  storage_category_id UUID REFERENCES inventory.storage_categories(id),
  company_id UUID REFERENCES core.companies(id),
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

ALTER TABLE inventory.putaway_rules ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_putaway_rules ON inventory.putaway_rules
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 4. REMOVAL STRATEGIES (COR-044)
-- ============================================

CREATE TABLE IF NOT EXISTS inventory.removal_strategies (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  code VARCHAR(20) NOT NULL UNIQUE,
  name VARCHAR(50) NOT NULL,
  description TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- ============================================
-- 5. CAMPOS ADICIONALES EN PRODUCTS (COR-043)
-- ============================================

DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'inventory' AND table_name = 'products') THEN
    -- tracking
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'inventory' AND table_name = 'products' AND column_name = 'tracking') THEN
      ALTER TABLE inventory.products ADD COLUMN tracking VARCHAR(20) DEFAULT 'none';
    END IF;
    -- sale_ok
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'inventory' AND table_name = 'products' AND column_name = 'sale_ok') THEN
      ALTER TABLE inventory.products ADD COLUMN sale_ok BOOLEAN DEFAULT FALSE;
    END IF;
    -- purchase_ok
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'inventory' AND table_name = 'products' AND column_name = 'purchase_ok') THEN
      ALTER TABLE inventory.products ADD COLUMN purchase_ok BOOLEAN DEFAULT TRUE;
    END IF;
    -- volume
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'inventory' AND table_name = 'products' AND column_name = 'volume') THEN
      ALTER TABLE inventory.products ADD COLUMN volume DECIMAL(10,4);
    END IF;
    -- weight
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'inventory' AND table_name = 'products' AND column_name = 'weight') THEN
      ALTER TABLE inventory.products ADD COLUMN weight DECIMAL(10,4);
    END IF;
    -- hs_code (arancel)
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'inventory' AND table_name = 'products' AND column_name = 'hs_code') THEN
      ALTER TABLE inventory.products ADD COLUMN hs_code VARCHAR(20);
    END IF;
    -- origin_country_id
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'inventory' AND table_name = 'products' AND column_name = 'origin_country_id') THEN
      ALTER TABLE inventory.products ADD COLUMN origin_country_id UUID REFERENCES core.countries(id);
    END IF;
    -- removal_strategy_id
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'inventory' AND table_name = 'products' AND column_name = 'removal_strategy_id') THEN
      ALTER TABLE inventory.products ADD COLUMN removal_strategy_id UUID REFERENCES inventory.removal_strategies(id);
    END IF;
  END IF;
END $$;

Líneas a agregar: ~150


2.3 Modificar: 07-purchase-ext-schema-ddl.sql

Agregar al final del archivo:

-- ============================================
-- EXTENSIONES PURCHASE - ERP-Core FASE-8
-- COR-045 a COR-047
-- ============================================

-- ============================================
-- 1. PRODUCT SUPPLIERINFO (COR-045)
-- ============================================

CREATE TABLE IF NOT EXISTS purchase.product_supplierinfo (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES auth.tenants(id),
  company_id UUID REFERENCES core.companies(id),
  partner_id UUID NOT NULL REFERENCES core.partners(id),
  product_id UUID REFERENCES inventory.products(id),
  product_name VARCHAR(200),
  product_code VARCHAR(50),
  sequence INTEGER DEFAULT 10,
  min_qty DECIMAL(12,4) DEFAULT 0,
  price DECIMAL(18,4) NOT NULL,
  currency_id UUID REFERENCES core.currencies(id),
  delay INTEGER DEFAULT 1, -- días
  date_start DATE,
  date_end DATE,
  -- Extensión construcción
  proyecto_id UUID, -- Precio por proyecto
  aplica_iva BOOLEAN DEFAULT TRUE,
  flete_incluido BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Índices
CREATE INDEX IF NOT EXISTS idx_supplierinfo_tenant ON purchase.product_supplierinfo(tenant_id);
CREATE INDEX IF NOT EXISTS idx_supplierinfo_partner ON purchase.product_supplierinfo(partner_id);
CREATE INDEX IF NOT EXISTS idx_supplierinfo_product ON purchase.product_supplierinfo(product_id);

-- RLS
ALTER TABLE purchase.product_supplierinfo ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation_supplierinfo ON purchase.product_supplierinfo
  FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- ============================================
-- 2. CAMPOS ADICIONALES EN ORDERS (COR-046)
-- ============================================

DO $$
BEGIN
  IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'purchase' AND table_name = 'orders') THEN
    -- origin
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'purchase' AND table_name = 'orders' AND column_name = 'origin') THEN
      ALTER TABLE purchase.orders ADD COLUMN origin VARCHAR(200);
    END IF;
    -- partner_ref
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'purchase' AND table_name = 'orders' AND column_name = 'partner_ref') THEN
      ALTER TABLE purchase.orders ADD COLUMN partner_ref VARCHAR(100);
    END IF;
    -- date_approve
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'purchase' AND table_name = 'orders' AND column_name = 'date_approve') THEN
      ALTER TABLE purchase.orders ADD COLUMN date_approve TIMESTAMPTZ;
    END IF;
    -- receipt_status
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'purchase' AND table_name = 'orders' AND column_name = 'receipt_status') THEN
      ALTER TABLE purchase.orders ADD COLUMN receipt_status VARCHAR(20) DEFAULT 'pending';
    END IF;
    -- incoterm_id
    IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = 'purchase' AND table_name = 'orders' AND column_name = 'incoterm_id') THEN
      ALTER TABLE purchase.orders ADD COLUMN incoterm_id UUID REFERENCES financial.incoterms(id);
    END IF;
  END IF;
END $$;

-- ============================================
-- 3. FUNCIÓN CREATE STOCK MOVES (COR-047)
-- ============================================

CREATE OR REPLACE FUNCTION purchase.action_create_stock_moves(p_order_id UUID)
RETURNS UUID AS $$
DECLARE
  v_picking_id UUID;
  v_tenant_id UUID;
  v_partner_id UUID;
  v_location_dest_id UUID;
  v_location_src_id UUID;
  v_line RECORD;
BEGIN
  -- Obtener datos de la orden
  SELECT tenant_id, partner_id
  INTO v_tenant_id, v_partner_id
  FROM purchase.orders WHERE id = p_order_id;

  -- Obtener ubicaciones (supplier -> stock)
  SELECT id INTO v_location_src_id
  FROM inventory.locations
  WHERE tenant_id = v_tenant_id AND usage = 'supplier' LIMIT 1;

  SELECT id INTO v_location_dest_id
  FROM inventory.locations
  WHERE tenant_id = v_tenant_id AND usage = 'internal' LIMIT 1;

  -- Crear picking de recepción
  INSERT INTO inventory.stock_pickings (
    tenant_id, picking_type, partner_id,
    location_id, location_dest_id, origin,
    status, scheduled_date
  ) VALUES (
    v_tenant_id, 'incoming', v_partner_id,
    v_location_src_id, v_location_dest_id,
    (SELECT name FROM purchase.orders WHERE id = p_order_id),
    'draft', CURRENT_DATE
  ) RETURNING id INTO v_picking_id;

  -- Crear moves por cada línea
  FOR v_line IN
    SELECT product_id, quantity, price_unit
    FROM purchase.order_lines
    WHERE order_id = p_order_id
  LOOP
    INSERT INTO inventory.stock_moves (
      tenant_id, picking_id, product_id,
      product_qty, location_id, location_dest_id, status
    ) VALUES (
      v_tenant_id, v_picking_id, v_line.product_id,
      v_line.quantity, v_location_src_id, v_location_dest_id, 'draft'
    );
  END LOOP;

  -- Actualizar status de orden
  UPDATE purchase.orders
  SET receipt_status = 'pending'
  WHERE id = p_order_id;

  RETURN v_picking_id;
END;
$$ LANGUAGE plpgsql;

Líneas a agregar: ~130


3. Script de Migración Consolidado

3.1 Archivo: migrations/20260104_fase8_construccion.sql

-- ============================================
-- MIGRACIÓN FASE-8 - ERP CONSTRUCCIÓN
-- Fecha: 2026-01-04
-- Basado en: ERP-Core FASE-8
-- ============================================

BEGIN;

-- Verificación previa
DO $$
BEGIN
  -- Verificar que existan schemas requeridos
  IF NOT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'construction') THEN
    RAISE EXCEPTION 'Schema construction no existe';
  END IF;
  IF NOT EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'hr') THEN
    RAISE EXCEPTION 'Schema hr no existe';
  END IF;
END $$;

-- ============================================
-- FASE 1: EXTENSIONES FINANCIAL
-- ============================================

\echo 'Aplicando extensiones Financial...'

-- [Contenido de 08-financial-ext-schema-ddl.sql]
-- Se ejecuta el archivo completo

-- ============================================
-- FASE 2: EXTENSIONES PROJECTS/CONSTRUCTION
-- ============================================

\echo 'Aplicando extensiones Projects/Construction...'

-- [Contenido de 09-projects-ext-schema-ddl.sql]
-- Se ejecuta el archivo completo

-- ============================================
-- FASE 3: EXTENSIONES HR
-- ============================================

\echo 'Aplicando extensiones HR...'

-- [Agregados a 02-hr-schema-ddl.sql]

-- ============================================
-- FASE 4: EXTENSIONES INVENTORY
-- ============================================

\echo 'Aplicando extensiones Inventory...'

-- [Agregados a 06-inventory-ext-schema-ddl.sql]

-- ============================================
-- FASE 5: EXTENSIONES PURCHASE
-- ============================================

\echo 'Aplicando extensiones Purchase...'

-- [Agregados a 07-purchase-ext-schema-ddl.sql]

-- ============================================
-- FASE 6: SEED DATA
-- ============================================

\echo 'Insertando seed data...'

-- Incoterms estándar
INSERT INTO financial.incoterms (code, name) VALUES
  ('EXW', 'Ex Works'),
  ('FCA', 'Free Carrier'),
  ('CPT', 'Carriage Paid To'),
  ('CIP', 'Carriage and Insurance Paid To'),
  ('DAP', 'Delivered at Place'),
  ('DPU', 'Delivered at Place Unloaded'),
  ('DDP', 'Delivered Duty Paid'),
  ('FAS', 'Free Alongside Ship'),
  ('FOB', 'Free On Board'),
  ('CFR', 'Cost and Freight'),
  ('CIF', 'Cost, Insurance and Freight')
ON CONFLICT (code) DO NOTHING;

-- Removal strategies
INSERT INTO inventory.removal_strategies (code, name, description) VALUES
  ('fifo', 'First In First Out', 'Salida por fecha de entrada más antigua'),
  ('lifo', 'Last In First Out', 'Salida por fecha de entrada más reciente'),
  ('fefo', 'First Expired First Out', 'Salida por fecha de caducidad más próxima'),
  ('closest', 'Closest Location', 'Salida por ubicación más cercana')
ON CONFLICT (code) DO NOTHING;

-- ============================================
-- VALIDACIÓN
-- ============================================

\echo 'Validando migración...'

DO $$
DECLARE
  v_count INTEGER;
BEGIN
  -- Verificar tablas creadas
  SELECT COUNT(*) INTO v_count
  FROM information_schema.tables
  WHERE table_schema IN ('financial', 'inventory', 'purchase', 'hr', 'projects', 'construction')
    AND table_name IN (
      'payment_term_lines', 'incoterms', 'payment_methods',
      'reconcile_models', 'reconcile_model_lines',
      'package_types', 'packages', 'storage_categories',
      'putaway_rules', 'removal_strategies',
      'product_supplierinfo',
      'collaborators', 'ratings', 'avance_programado',
      'work_locations', 'ubicaciones_obra',
      'skill_types', 'skills', 'skill_levels', 'employee_skills',
      'expense_sheets', 'expenses', 'employee_resume_lines',
      'payslip_structures', 'payslips', 'payslip_lines'
    );

  IF v_count < 20 THEN
    RAISE WARNING 'Solo se encontraron % de 24 tablas esperadas', v_count;
  ELSE
    RAISE NOTICE 'Validación exitosa: % tablas creadas', v_count;
  END IF;
END $$;

COMMIT;

\echo 'Migración FASE-8 completada exitosamente'

4. Seed Data Específico de Construcción

4.1 Archivo: seeds/construccion-skill-types.sql

-- ============================================
-- SEED DATA: HABILIDADES DE CONSTRUCCIÓN
-- ============================================

-- Tipos de habilidad
INSERT INTO hr.skill_types (tenant_id, name) VALUES
  (current_setting('app.current_tenant_id')::UUID, 'Albañilería'),
  (current_setting('app.current_tenant_id')::UUID, 'Plomería'),
  (current_setting('app.current_tenant_id')::UUID, 'Electricidad'),
  (current_setting('app.current_tenant_id')::UUID, 'Herrería'),
  (current_setting('app.current_tenant_id')::UUID, 'Carpintería'),
  (current_setting('app.current_tenant_id')::UUID, 'Acabados'),
  (current_setting('app.current_tenant_id')::UUID, 'Maquinaria Pesada'),
  (current_setting('app.current_tenant_id')::UUID, 'Topografía'),
  (current_setting('app.current_tenant_id')::UUID, 'Soldadura');

-- Niveles de habilidad (aplicables a todos los tipos)
INSERT INTO hr.skill_levels (tenant_id, skill_type_id, name, level)
SELECT
  current_setting('app.current_tenant_id')::UUID,
  st.id,
  l.name,
  l.level
FROM hr.skill_types st
CROSS JOIN (VALUES
  ('Ayudante', 1),
  ('Oficial', 2),
  ('Maestro', 3),
  ('Especialista', 4)
) AS l(name, level)
WHERE st.tenant_id = current_setting('app.current_tenant_id')::UUID;

-- Habilidades específicas por tipo
-- Albañilería
INSERT INTO hr.skills (tenant_id, skill_type_id, name)
SELECT
  current_setting('app.current_tenant_id')::UUID,
  id,
  unnest(ARRAY['Cimentación', 'Muros de block', 'Losas', 'Castillos y dalas', 'Firmes', 'Aplanados'])
FROM hr.skill_types
WHERE name = 'Albañilería' AND tenant_id = current_setting('app.current_tenant_id')::UUID;

-- Plomería
INSERT INTO hr.skills (tenant_id, skill_type_id, name)
SELECT
  current_setting('app.current_tenant_id')::UUID,
  id,
  unnest(ARRAY['Instalación hidráulica', 'Instalación sanitaria', 'Gas', 'Tinaco y cisterna', 'Calentadores'])
FROM hr.skill_types
WHERE name = 'Plomería' AND tenant_id = current_setting('app.current_tenant_id')::UUID;

-- Electricidad
INSERT INTO hr.skills (tenant_id, skill_type_id, name)
SELECT
  current_setting('app.current_tenant_id')::UUID,
  id,
  unnest(ARRAY['Instalación residencial', 'Tableros', 'Acometida CFE', 'Tierra física', 'Iluminación'])
FROM hr.skill_types
WHERE name = 'Electricidad' AND tenant_id = current_setting('app.current_tenant_id')::UUID;

4.2 Archivo: seeds/construccion-storage-categories.sql

-- ============================================
-- SEED DATA: CATEGORÍAS DE ALMACÉN CONSTRUCCIÓN
-- ============================================

INSERT INTO inventory.storage_categories (tenant_id, name, max_weight, allow_new_product) VALUES
  (current_setting('app.current_tenant_id')::UUID, 'Área Techada', 10000.0, 'mixed'),
  (current_setting('app.current_tenant_id')::UUID, 'Área Descubierta', 50000.0, 'mixed'),
  (current_setting('app.current_tenant_id')::UUID, 'Bodega Cerrada', 5000.0, 'mixed'),
  (current_setting('app.current_tenant_id')::UUID, 'Caseta Herramienta', 500.0, 'same'),
  (current_setting('app.current_tenant_id')::UUID, 'Área Inflamables', 200.0, 'same');

4.3 Archivo: seeds/construccion-package-types.sql

-- ============================================
-- SEED DATA: TIPOS DE PAQUETE CONSTRUCCIÓN
-- ============================================

INSERT INTO inventory.package_types (tenant_id, name, height, width, length, base_weight, max_weight, sequence) VALUES
  (current_setting('app.current_tenant_id')::UUID, 'Tarima Block', 150.0, 100.0, 100.0, 5.0, 500.0, 10),
  (current_setting('app.current_tenant_id')::UUID, 'Paquete Varilla', 600.0, 30.0, 30.0, 2.0, 1000.0, 20),
  (current_setting('app.current_tenant_id')::UUID, 'Rollo Cable', 50.0, 50.0, 20.0, 0.5, 50.0, 30),
  (current_setting('app.current_tenant_id')::UUID, 'Saco Cemento', 60.0, 40.0, 15.0, 0.2, 50.0, 40),
  (current_setting('app.current_tenant_id')::UUID, 'Cubeta Pintura', 40.0, 30.0, 30.0, 0.3, 25.0, 50),
  (current_setting('app.current_tenant_id')::UUID, 'Caja Herrajes', 40.0, 30.0, 20.0, 0.5, 30.0, 60);

4.4 Archivo: seeds/construccion-payment-methods.sql

-- ============================================
-- SEED DATA: MÉTODOS DE PAGO CONSTRUCCIÓN
-- ============================================

INSERT INTO financial.payment_methods (tenant_id, name, code, payment_type) VALUES
  (current_setting('app.current_tenant_id')::UUID, 'Anticipo de Obra', 'anticipo_obra', 'outbound'),
  (current_setting('app.current_tenant_id')::UUID, 'Pago Estimación', 'pago_estimacion', 'outbound'),
  (current_setting('app.current_tenant_id')::UUID, 'Pago Destajo', 'pago_destajo', 'outbound'),
  (current_setting('app.current_tenant_id')::UUID, 'Pago Finiquito', 'pago_finiquito', 'outbound'),
  (current_setting('app.current_tenant_id')::UUID, 'Retención 5%', 'retencion_5', 'outbound'),
  (current_setting('app.current_tenant_id')::UUID, 'Cobro Cliente', 'cobro_cliente', 'inbound'),
  (current_setting('app.current_tenant_id')::UUID, 'Anticipo Cliente', 'anticipo_cliente', 'inbound');

5. Resumen del Plan

5.1 Archivos a Crear

Archivo Líneas Contenido
08-financial-ext-schema-ddl.sql ~180 5 tablas, 2 ENUMs
09-projects-ext-schema-ddl.sql ~280 3 tablas, 2 funciones
migrations/20260104_fase8_construccion.sql ~200 Migración consolidada
seeds/construccion-skill-types.sql ~60 Habilidades construcción
seeds/construccion-storage-categories.sql ~15 Categorías almacén
seeds/construccion-package-types.sql ~20 Tipos de paquete
seeds/construccion-payment-methods.sql ~15 Métodos pago obra
TOTAL ~770

5.2 Archivos a Modificar

Archivo Líneas a agregar Contenido
02-hr-schema-ddl.sql ~300 11 tablas, 3 ENUMs
06-inventory-ext-schema-ddl.sql ~150 5 tablas
07-purchase-ext-schema-ddl.sql ~130 1 tabla, 1 función
TOTAL ~580

5.3 Documentación a Actualizar

Documento Cambio
HERENCIA-ERP-CORE.md Agregar referencia FASE-8
DEPENDENCIAS-ERP-CORE.yml Actualizar versión
DATABASE_INVENTORY.yml Agregar 24 nuevas tablas

6. Orden de Ejecución

  1. Paso 1: Crear 08-financial-ext-schema-ddl.sql
  2. Paso 2: Crear 09-projects-ext-schema-ddl.sql
  3. Paso 3: Modificar 02-hr-schema-ddl.sql
  4. Paso 4: Modificar 06-inventory-ext-schema-ddl.sql
  5. Paso 5: Modificar 07-purchase-ext-schema-ddl.sql
  6. Paso 6: Crear archivos seed
  7. Paso 7: Crear script de migración
  8. Paso 8: Actualizar documentación

Estado: FASE 3 COMPLETADA Siguiente: FASE 4 - Validación del Plan Fecha: 2026-01-04