-- ===================================================== -- SCHEMA: inventory (Extensiones) -- PROPÓSITO: Valoración de Inventario, Lotes/Series, Conteos Cíclicos -- MÓDULO: MGN-005 (Inventario) -- FECHA: 2025-12-08 -- VERSION: 1.0.0 -- DEPENDENCIAS: 05-inventory.sql -- SPECS RELACIONADAS: -- - SPEC-VALORACION-INVENTARIO.md -- - SPEC-TRAZABILIDAD-LOTES-SERIES.md -- - SPEC-INVENTARIOS-CICLICOS.md -- ===================================================== -- ===================================================== -- PARTE 1: VALORACIÓN DE INVENTARIO (SVL) -- ===================================================== -- Tabla: stock_valuation_layers (Capas de valoración FIFO/AVCO) CREATE TABLE inventory.stock_valuation_layers ( -- Identificación id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Referencias product_id UUID NOT NULL REFERENCES inventory.products(id), stock_move_id UUID REFERENCES inventory.stock_moves(id), lot_id UUID REFERENCES inventory.lots(id), company_id UUID NOT NULL REFERENCES auth.companies(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), -- Valores de la capa quantity DECIMAL(16,4) NOT NULL, -- Cantidad (positiva=entrada, negativa=salida) unit_cost DECIMAL(16,6) NOT NULL, -- Costo unitario value DECIMAL(16,4) NOT NULL, -- Valor total currency_id UUID REFERENCES core.currencies(id), -- Tracking FIFO (solo para entradas) remaining_qty DECIMAL(16,4) NOT NULL DEFAULT 0, -- Cantidad restante por consumir remaining_value DECIMAL(16,4) NOT NULL DEFAULT 0, -- Valor restante -- Diferencia de precio (facturas vs recepción) price_diff_value DECIMAL(16,4) DEFAULT 0, -- Referencias contables (usando journal_entries del schema financial) journal_entry_id UUID REFERENCES financial.journal_entries(id), journal_entry_line_id UUID REFERENCES financial.journal_entry_lines(id), -- Corrección de vacío (link a capa corregida) parent_svl_id UUID REFERENCES inventory.stock_valuation_layers(id), -- Metadata description VARCHAR(500), reference VARCHAR(255), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES auth.users(id), -- Constraints CONSTRAINT chk_svl_value CHECK ( ABS(value - (quantity * unit_cost)) < 0.01 OR quantity = 0 ) ); -- Índice principal para FIFO (crítico para performance) CREATE INDEX idx_svl_fifo_candidates ON inventory.stock_valuation_layers ( product_id, remaining_qty, stock_move_id, company_id, created_at ) WHERE remaining_qty > 0; -- Índice para agregación de valoración CREATE INDEX idx_svl_valuation ON inventory.stock_valuation_layers ( product_id, company_id, id, value, quantity ); -- Índice por lote CREATE INDEX idx_svl_lot ON inventory.stock_valuation_layers (lot_id) WHERE lot_id IS NOT NULL; -- Índice por movimiento CREATE INDEX idx_svl_move ON inventory.stock_valuation_layers (stock_move_id); -- Índice por tenant CREATE INDEX idx_svl_tenant ON inventory.stock_valuation_layers (tenant_id); -- Comentarios COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valoración de inventario para costeo FIFO/AVCO'; COMMENT ON COLUMN inventory.stock_valuation_layers.remaining_qty IS 'Cantidad aún no consumida por FIFO'; COMMENT ON COLUMN inventory.stock_valuation_layers.parent_svl_id IS 'Referencia a capa padre cuando es corrección de vacío'; -- Vista materializada para valores agregados de SVL por producto CREATE MATERIALIZED VIEW inventory.product_valuation_summary AS SELECT svl.product_id, svl.company_id, svl.tenant_id, SUM(svl.quantity) AS quantity_svl, SUM(svl.value) AS value_svl, CASE WHEN SUM(svl.quantity) > 0 THEN SUM(svl.value) / SUM(svl.quantity) ELSE 0 END AS avg_cost FROM inventory.stock_valuation_layers svl GROUP BY svl.product_id, svl.company_id, svl.tenant_id; CREATE UNIQUE INDEX idx_product_valuation_pk ON inventory.product_valuation_summary (product_id, company_id, tenant_id); COMMENT ON MATERIALIZED VIEW inventory.product_valuation_summary IS 'Resumen de valoración por producto - refrescar con REFRESH MATERIALIZED VIEW CONCURRENTLY'; -- Configuración de cuentas por categoría de producto CREATE TABLE inventory.category_stock_accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), category_id UUID NOT NULL REFERENCES core.product_categories(id), company_id UUID NOT NULL REFERENCES auth.companies(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), -- Cuentas de valoración stock_input_account_id UUID REFERENCES financial.accounts(id), -- Entrada de stock stock_output_account_id UUID REFERENCES financial.accounts(id), -- Salida de stock stock_valuation_account_id UUID REFERENCES financial.accounts(id), -- Valoración (activo) expense_account_id UUID REFERENCES financial.accounts(id), -- Gasto/COGS -- Diario para asientos de stock stock_journal_id UUID REFERENCES financial.journals(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ, CONSTRAINT uq_category_stock_accounts UNIQUE (category_id, company_id, tenant_id) ); COMMENT ON TABLE inventory.category_stock_accounts IS 'Cuentas contables para valoración de inventario por categoría'; -- Parámetros de valoración por tenant CREATE TABLE inventory.valuation_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), company_id UUID REFERENCES auth.companies(id), allow_negative_stock BOOLEAN NOT NULL DEFAULT FALSE, default_cost_method VARCHAR(20) NOT NULL DEFAULT 'fifo' CHECK (default_cost_method IN ('standard', 'average', 'fifo')), default_valuation VARCHAR(20) NOT NULL DEFAULT 'real_time' CHECK (default_valuation IN ('manual', 'real_time')), auto_vacuum_enabled BOOLEAN NOT NULL DEFAULT TRUE, vacuum_batch_size INTEGER NOT NULL DEFAULT 100, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_valuation_settings_tenant_company UNIQUE (tenant_id, company_id) ); COMMENT ON TABLE inventory.valuation_settings IS 'Configuración de valoración de inventario por tenant/empresa'; -- Extensión de product_categories para costeo (tabla en schema core) ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS cost_method VARCHAR(20) NOT NULL DEFAULT 'fifo' CHECK (cost_method IN ('standard', 'average', 'fifo')); ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS valuation_method VARCHAR(20) NOT NULL DEFAULT 'real_time' CHECK (valuation_method IN ('manual', 'real_time')); -- Extensión de products para costeo ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS standard_price DECIMAL(16,6) NOT NULL DEFAULT 0; ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS lot_valuated BOOLEAN NOT NULL DEFAULT FALSE; -- ===================================================== -- PARTE 2: TRAZABILIDAD DE LOTES Y SERIES -- ===================================================== -- Extensión de products para tracking ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS tracking VARCHAR(16) NOT NULL DEFAULT 'none' CHECK (tracking IN ('none', 'lot', 'serial')); -- Configuración de caducidad ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS use_expiration_date BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS expiration_time INTEGER; -- Días hasta caducidad desde recepción ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS use_time INTEGER; -- Días antes de caducidad para "consumir preferentemente" ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS removal_time INTEGER; -- Días antes de caducidad para remover de venta ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS alert_time INTEGER; -- Días antes de caducidad para alertar -- Propiedades dinámicas por lote ALTER TABLE inventory.products ADD COLUMN IF NOT EXISTS lot_properties_definition JSONB DEFAULT '[]'; -- Constraint de consistencia ALTER TABLE inventory.products ADD CONSTRAINT chk_expiration_config CHECK ( use_expiration_date = FALSE OR ( expiration_time IS NOT NULL AND expiration_time > 0 ) ); -- Índice para productos con tracking CREATE INDEX idx_products_tracking ON inventory.products(tracking) WHERE tracking != 'none'; -- Tabla: lots (Lotes y números de serie) CREATE TABLE inventory.lots ( -- Identificación id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(128) NOT NULL, ref VARCHAR(256), -- Referencia interna/externa -- Relaciones product_id UUID NOT NULL REFERENCES inventory.products(id), company_id UUID NOT NULL REFERENCES auth.companies(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), -- Fechas de caducidad expiration_date TIMESTAMPTZ, use_date TIMESTAMPTZ, -- Best-before removal_date TIMESTAMPTZ, -- Fecha de retiro FEFO alert_date TIMESTAMPTZ, -- Fecha de alerta -- Control de alertas expiry_alerted BOOLEAN NOT NULL DEFAULT FALSE, -- Propiedades dinámicas (heredadas del producto) lot_properties JSONB DEFAULT '{}', -- Ubicación (si solo hay una) location_id UUID REFERENCES inventory.locations(id), -- Auditoría created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID REFERENCES auth.users(id), -- Constraints CONSTRAINT uk_lot_product_company UNIQUE (product_id, name, company_id) ); -- Índices para lots CREATE INDEX idx_lots_product ON inventory.lots(product_id); CREATE INDEX idx_lots_tenant ON inventory.lots(tenant_id); CREATE INDEX idx_lots_expiration ON inventory.lots(expiration_date) WHERE expiration_date IS NOT NULL; CREATE INDEX idx_lots_removal ON inventory.lots(removal_date) WHERE removal_date IS NOT NULL; CREATE INDEX idx_lots_alert ON inventory.lots(alert_date) WHERE alert_date IS NOT NULL AND NOT expiry_alerted; -- Extensión para búsqueda por trigram (requiere pg_trgm) -- CREATE INDEX idx_lots_name_trgm ON inventory.lots USING GIN (name gin_trgm_ops); COMMENT ON TABLE inventory.lots IS 'Lotes y números de serie para trazabilidad de productos'; -- Extensión de quants para lotes ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS lot_id UUID REFERENCES inventory.lots(id); ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS in_date TIMESTAMPTZ NOT NULL DEFAULT NOW(); -- Fecha de remoción para FEFO (heredada del lote) ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS removal_date TIMESTAMPTZ; -- Índices optimizados para quants CREATE INDEX idx_quants_lot ON inventory.quants(lot_id) WHERE lot_id IS NOT NULL; CREATE INDEX idx_quants_fefo ON inventory.quants(product_id, location_id, removal_date, in_date) WHERE quantity > 0; CREATE INDEX idx_quants_fifo ON inventory.quants(product_id, location_id, in_date) WHERE quantity > 0; -- Extensión de stock_moves para lotes (tracking de lotes en movimientos) ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS lot_id UUID REFERENCES inventory.lots(id); ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS lot_name VARCHAR(128); -- Para creación on-the-fly ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS tracking VARCHAR(16); -- Copia del producto (none, lot, serial) -- Índices para lotes en movimientos CREATE INDEX IF NOT EXISTS idx_stock_moves_lot ON inventory.stock_moves(lot_id) WHERE lot_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_stock_moves_lot_name ON inventory.stock_moves(lot_name) WHERE lot_name IS NOT NULL; -- Tabla de relación para trazabilidad de manufactura (consume/produce) CREATE TABLE inventory.stock_move_consume_rel ( consume_move_id UUID NOT NULL REFERENCES inventory.stock_moves(id) ON DELETE CASCADE, produce_move_id UUID NOT NULL REFERENCES inventory.stock_moves(id) ON DELETE CASCADE, quantity DECIMAL(16,4) NOT NULL DEFAULT 0, -- Cantidad consumida/producida PRIMARY KEY (consume_move_id, produce_move_id) ); CREATE INDEX idx_consume_rel_consume ON inventory.stock_move_consume_rel(consume_move_id); CREATE INDEX idx_consume_rel_produce ON inventory.stock_move_consume_rel(produce_move_id); COMMENT ON TABLE inventory.stock_move_consume_rel IS 'Relación M:N para trazabilidad de consumo en manufactura'; -- Tabla: removal_strategies (Estrategias de salida) CREATE TABLE inventory.removal_strategies ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(64) NOT NULL, code VARCHAR(16) NOT NULL UNIQUE CHECK (code IN ('fifo', 'lifo', 'fefo', 'closest')), description TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE ); -- Datos iniciales de estrategias INSERT INTO inventory.removal_strategies (name, code, description) VALUES ('First In, First Out', 'fifo', 'El stock más antiguo sale primero'), ('Last In, First Out', 'lifo', 'El stock más reciente sale primero'), ('First Expiry, First Out', 'fefo', 'El stock que caduca primero sale primero'), ('Closest Location', 'closest', 'El stock de ubicación más cercana sale primero') ON CONFLICT (code) DO NOTHING; COMMENT ON TABLE inventory.removal_strategies IS 'Estrategias de salida de inventario (FIFO, LIFO, FEFO)'; -- Agregar estrategia a categorías y ubicaciones ALTER TABLE core.product_categories ADD COLUMN IF NOT EXISTS removal_strategy_id UUID REFERENCES inventory.removal_strategies(id); ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS removal_strategy_id UUID REFERENCES inventory.removal_strategies(id); -- ===================================================== -- PARTE 3: CONTEOS CÍCLICOS -- ===================================================== -- Extensión de locations para conteo cíclico ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS cyclic_inventory_frequency INTEGER DEFAULT 0; ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS last_inventory_date DATE; ALTER TABLE inventory.locations ADD COLUMN IF NOT EXISTS abc_classification VARCHAR(1) DEFAULT 'C' CHECK (abc_classification IN ('A', 'B', 'C')); COMMENT ON COLUMN inventory.locations.cyclic_inventory_frequency IS 'Días entre conteos cíclicos. 0 = deshabilitado'; COMMENT ON COLUMN inventory.locations.abc_classification IS 'Clasificación ABC: A=Alta rotación, B=Media, C=Baja'; -- Índice para ubicaciones pendientes de conteo CREATE INDEX idx_locations_cyclic_inventory ON inventory.locations(last_inventory_date, cyclic_inventory_frequency) WHERE cyclic_inventory_frequency > 0; -- Extensión de quants para inventario ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS inventory_quantity DECIMAL(18,4); ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS inventory_quantity_set BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS inventory_date DATE; ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS last_count_date DATE; ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS is_outdated BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS assigned_user_id UUID REFERENCES auth.users(id); ALTER TABLE inventory.quants ADD COLUMN IF NOT EXISTS count_notes TEXT; COMMENT ON COLUMN inventory.quants.inventory_quantity IS 'Cantidad contada por el usuario'; COMMENT ON COLUMN inventory.quants.is_outdated IS 'TRUE si quantity cambió después de establecer inventory_quantity'; -- Índices para conteo CREATE INDEX idx_quants_inventory_date ON inventory.quants(inventory_date) WHERE inventory_date IS NOT NULL; CREATE INDEX idx_quants_assigned_user ON inventory.quants(assigned_user_id) WHERE assigned_user_id IS NOT NULL; -- Tabla: inventory_count_sessions (Sesiones de conteo) CREATE TABLE inventory.inventory_count_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), code VARCHAR(20) NOT NULL, name VARCHAR(200), -- Alcance del conteo location_ids UUID[] NOT NULL, -- Ubicaciones a contar product_ids UUID[], -- NULL = todos los productos category_ids UUID[], -- Filtrar por categorías -- Configuración count_type VARCHAR(20) NOT NULL DEFAULT 'cycle' CHECK (count_type IN ('cycle', 'full', 'spot')), -- 'cycle': Conteo cíclico programado -- 'full': Inventario físico completo -- 'spot': Conteo puntual/aleatorio -- Estado state VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (state IN ('draft', 'in_progress', 'pending_review', 'done', 'cancelled')), -- Fechas scheduled_date DATE, started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, -- Asignación responsible_id UUID REFERENCES auth.users(id), team_ids UUID[], -- Usuarios asignados al conteo -- Resultados total_quants INTEGER DEFAULT 0, counted_quants INTEGER DEFAULT 0, discrepancy_quants INTEGER DEFAULT 0, total_value_diff DECIMAL(18,2) DEFAULT 0, -- Auditoría company_id UUID NOT NULL REFERENCES auth.companies(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), warehouse_id UUID REFERENCES inventory.warehouses(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL REFERENCES auth.users(id), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Índices para sesiones CREATE INDEX idx_count_sessions_state ON inventory.inventory_count_sessions(state); CREATE INDEX idx_count_sessions_scheduled ON inventory.inventory_count_sessions(scheduled_date); CREATE INDEX idx_count_sessions_tenant ON inventory.inventory_count_sessions(tenant_id); -- Secuencia para código de sesión CREATE SEQUENCE IF NOT EXISTS inventory.inventory_count_seq START 1; COMMENT ON TABLE inventory.inventory_count_sessions IS 'Sesiones de conteo cíclico de inventario'; -- Tabla: inventory_count_lines (Líneas de conteo detalladas) CREATE TABLE inventory.inventory_count_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES inventory.inventory_count_sessions(id) ON DELETE CASCADE, quant_id UUID REFERENCES inventory.quants(id), -- Producto product_id UUID NOT NULL REFERENCES inventory.products(id), location_id UUID NOT NULL REFERENCES inventory.locations(id), lot_id UUID REFERENCES inventory.lots(id), -- package_id: Reservado para futura extensión de empaquetado -- package_id UUID REFERENCES inventory.packages(id), -- Cantidades theoretical_qty DECIMAL(18,4) NOT NULL DEFAULT 0, -- Del sistema counted_qty DECIMAL(18,4), -- Contada -- Valoración unit_cost DECIMAL(18,6), -- Estado state VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'counted', 'conflict', 'applied')), -- Conteo counted_by UUID REFERENCES auth.users(id), counted_at TIMESTAMPTZ, notes TEXT, -- Resolución de conflictos conflict_reason VARCHAR(100), resolution VARCHAR(20) CHECK (resolution IS NULL OR resolution IN ('keep_counted', 'keep_system', 'recount')), resolved_by UUID REFERENCES auth.users(id), resolved_at TIMESTAMPTZ, -- Movimiento generado stock_move_id UUID REFERENCES inventory.stock_moves(id) ); -- Índices para líneas de conteo CREATE INDEX idx_count_lines_session ON inventory.inventory_count_lines(session_id); CREATE INDEX idx_count_lines_state ON inventory.inventory_count_lines(state); CREATE INDEX idx_count_lines_product ON inventory.inventory_count_lines(product_id); COMMENT ON TABLE inventory.inventory_count_lines IS 'Líneas detalladas de conteo de inventario'; -- Tabla: abc_classification_rules (Reglas de clasificación ABC) CREATE TABLE inventory.abc_classification_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(100) NOT NULL, -- Criterio de clasificación classification_method VARCHAR(20) NOT NULL DEFAULT 'value' CHECK (classification_method IN ('value', 'movement', 'revenue')), -- 'value': Por valor de inventario -- 'movement': Por frecuencia de movimiento -- 'revenue': Por ingresos generados -- Umbrales (porcentaje acumulado) threshold_a DECIMAL(5,2) NOT NULL DEFAULT 80.00, -- Top 80% threshold_b DECIMAL(5,2) NOT NULL DEFAULT 95.00, -- 80-95% -- Resto es C (95-100%) -- Frecuencias de conteo recomendadas (días) frequency_a INTEGER NOT NULL DEFAULT 7, -- Clase A: semanal frequency_b INTEGER NOT NULL DEFAULT 30, -- Clase B: mensual frequency_c INTEGER NOT NULL DEFAULT 90, -- Clase C: trimestral -- Aplicación warehouse_id UUID REFERENCES inventory.warehouses(id), category_ids UUID[], -- Categorías a las que aplica -- Estado is_active BOOLEAN NOT NULL DEFAULT TRUE, last_calculation TIMESTAMPTZ, -- Auditoría company_id UUID NOT NULL REFERENCES auth.companies(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by UUID NOT NULL REFERENCES auth.users(id), CONSTRAINT chk_thresholds CHECK (threshold_a < threshold_b AND threshold_b <= 100) ); COMMENT ON TABLE inventory.abc_classification_rules IS 'Reglas de clasificación ABC para priorización de conteos'; -- Tabla: product_abc_classification (Clasificación ABC por producto) CREATE TABLE inventory.product_abc_classification ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), product_id UUID NOT NULL REFERENCES inventory.products(id), rule_id UUID NOT NULL REFERENCES inventory.abc_classification_rules(id), -- Clasificación classification VARCHAR(1) NOT NULL CHECK (classification IN ('A', 'B', 'C')), -- Métricas calculadas metric_value DECIMAL(18,2) NOT NULL, -- Valor usado para clasificar cumulative_percent DECIMAL(5,2) NOT NULL, -- % acumulado rank_position INTEGER NOT NULL, -- Posición en ranking -- Período de cálculo period_start DATE NOT NULL, period_end DATE NOT NULL, -- Frecuencia asignada assigned_frequency INTEGER NOT NULL, calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_product_rule UNIQUE (product_id, rule_id) ); -- Índice para búsqueda de clasificación CREATE INDEX idx_product_abc ON inventory.product_abc_classification(product_id, rule_id); COMMENT ON TABLE inventory.product_abc_classification IS 'Clasificación ABC calculada por producto'; -- Extensión de stock_moves para marcar movimientos de inventario ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS is_inventory BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE inventory.stock_moves ADD COLUMN IF NOT EXISTS inventory_session_id UUID REFERENCES inventory.inventory_count_sessions(id); CREATE INDEX idx_moves_is_inventory ON inventory.stock_moves(is_inventory) WHERE is_inventory = TRUE; -- ===================================================== -- PARTE 4: FUNCIONES DE UTILIDAD -- ===================================================== -- Función: Ejecutar algoritmo FIFO para consumo de capas CREATE OR REPLACE FUNCTION inventory.run_fifo( p_product_id UUID, p_quantity DECIMAL, p_company_id UUID, p_lot_id UUID DEFAULT NULL ) RETURNS TABLE( total_value DECIMAL, unit_cost DECIMAL, remaining_qty DECIMAL ) AS $$ DECLARE v_candidate RECORD; v_qty_to_take DECIMAL; v_qty_taken DECIMAL; v_value_taken DECIMAL; v_total_value DECIMAL := 0; v_qty_pending DECIMAL := p_quantity; v_last_unit_cost DECIMAL := 0; BEGIN -- Obtener candidatos FIFO ordenados FOR v_candidate IN SELECT id, remaining_qty as r_qty, remaining_value as r_val, unit_cost as u_cost FROM inventory.stock_valuation_layers WHERE product_id = p_product_id AND remaining_qty > 0 AND company_id = p_company_id AND (p_lot_id IS NULL OR lot_id = p_lot_id) ORDER BY created_at ASC, id ASC FOR UPDATE LOOP EXIT WHEN v_qty_pending <= 0; v_qty_taken := LEAST(v_candidate.r_qty, v_qty_pending); v_value_taken := ROUND(v_qty_taken * (v_candidate.r_val / v_candidate.r_qty), 4); -- Actualizar capa candidata UPDATE inventory.stock_valuation_layers SET remaining_qty = remaining_qty - v_qty_taken, remaining_value = remaining_value - v_value_taken WHERE id = v_candidate.id; v_qty_pending := v_qty_pending - v_qty_taken; v_total_value := v_total_value + v_value_taken; v_last_unit_cost := v_candidate.u_cost; END LOOP; -- Si queda cantidad pendiente (stock negativo) IF v_qty_pending > 0 THEN v_total_value := v_total_value + (v_last_unit_cost * v_qty_pending); RETURN QUERY SELECT -v_total_value, v_total_value / p_quantity, -v_qty_pending; ELSE RETURN QUERY SELECT -v_total_value, v_total_value / p_quantity, 0::DECIMAL; END IF; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION inventory.run_fifo IS 'Ejecuta algoritmo FIFO y consume capas de valoración'; -- Función: Calcular clasificación ABC CREATE OR REPLACE FUNCTION inventory.calculate_abc_classification( p_rule_id UUID, p_period_months INTEGER DEFAULT 12 ) RETURNS TABLE ( product_id UUID, classification VARCHAR(1), metric_value DECIMAL, cumulative_percent DECIMAL, rank_position INTEGER ) AS $$ DECLARE v_rule RECORD; v_total_value DECIMAL; BEGIN -- Obtener regla SELECT * INTO v_rule FROM inventory.abc_classification_rules WHERE id = p_rule_id; IF NOT FOUND THEN RAISE EXCEPTION 'Regla ABC no encontrada: %', p_rule_id; END IF; -- Crear tabla temporal con métricas CREATE TEMP TABLE tmp_abc_metrics AS SELECT q.product_id, SUM(q.quantity * COALESCE(p.standard_price, 0)) as metric_value FROM inventory.quants q JOIN inventory.products p ON p.id = q.product_id WHERE q.quantity > 0 AND (v_rule.warehouse_id IS NULL OR q.warehouse_id = v_rule.warehouse_id) GROUP BY q.product_id; -- Calcular total SELECT COALESCE(SUM(metric_value), 0) INTO v_total_value FROM tmp_abc_metrics; -- Retornar clasificación RETURN QUERY WITH ranked AS ( SELECT tm.product_id, tm.metric_value, ROW_NUMBER() OVER (ORDER BY tm.metric_value DESC) as rank_pos, SUM(tm.metric_value) OVER (ORDER BY tm.metric_value DESC) / NULLIF(v_total_value, 0) * 100 as cum_pct FROM tmp_abc_metrics tm ) SELECT r.product_id, CASE WHEN r.cum_pct <= v_rule.threshold_a THEN 'A'::VARCHAR(1) WHEN r.cum_pct <= v_rule.threshold_b THEN 'B'::VARCHAR(1) ELSE 'C'::VARCHAR(1) END as classification, r.metric_value, ROUND(r.cum_pct, 2), r.rank_pos::INTEGER FROM ranked r; DROP TABLE IF EXISTS tmp_abc_metrics; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION inventory.calculate_abc_classification IS 'Calcula clasificación ABC para productos según regla'; -- Función: Obtener próximos conteos programados CREATE OR REPLACE FUNCTION inventory.get_pending_counts( p_days_ahead INTEGER DEFAULT 7 ) RETURNS TABLE ( location_id UUID, location_name VARCHAR, next_inventory_date DATE, days_overdue INTEGER, quant_count INTEGER, total_value DECIMAL ) AS $$ BEGIN RETURN QUERY SELECT l.id, l.name, (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE as next_inv_date, (CURRENT_DATE - (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE)::INTEGER as days_over, COUNT(q.id)::INTEGER as q_count, COALESCE(SUM(q.quantity * COALESCE(p.standard_price, 0)), 0) as t_value FROM inventory.locations l LEFT JOIN inventory.quants q ON q.location_id = l.id LEFT JOIN inventory.products p ON p.id = q.product_id WHERE l.cyclic_inventory_frequency > 0 AND (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE <= CURRENT_DATE + p_days_ahead AND l.location_type = 'internal' GROUP BY l.id, l.name, l.last_inventory_date, l.cyclic_inventory_frequency ORDER BY next_inv_date; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION inventory.get_pending_counts IS 'Obtiene ubicaciones con conteos cíclicos pendientes'; -- Función: Marcar quants como desactualizados cuando cambia cantidad CREATE OR REPLACE FUNCTION inventory.mark_quants_outdated() RETURNS TRIGGER AS $$ BEGIN IF OLD.quantity != NEW.quantity AND OLD.inventory_quantity_set = TRUE THEN NEW.is_outdated := TRUE; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_quant_outdated BEFORE UPDATE OF quantity ON inventory.quants FOR EACH ROW EXECUTE FUNCTION inventory.mark_quants_outdated(); -- Función: Calcular fechas de caducidad al crear lote CREATE OR REPLACE FUNCTION inventory.compute_lot_expiration_dates() RETURNS TRIGGER AS $$ DECLARE v_product RECORD; BEGIN -- Obtener configuración del producto SELECT use_expiration_date, expiration_time, use_time, removal_time, alert_time INTO v_product FROM inventory.products WHERE id = NEW.product_id; -- Si el producto usa fechas de caducidad y no se especificó expiration_date IF v_product.use_expiration_date AND NEW.expiration_date IS NULL THEN NEW.expiration_date := NOW() + (v_product.expiration_time || ' days')::INTERVAL; IF v_product.use_time IS NOT NULL THEN NEW.use_date := NEW.expiration_date - (v_product.use_time || ' days')::INTERVAL; END IF; IF v_product.removal_time IS NOT NULL THEN NEW.removal_date := NEW.expiration_date - (v_product.removal_time || ' days')::INTERVAL; END IF; IF v_product.alert_time IS NOT NULL THEN NEW.alert_date := NEW.expiration_date - (v_product.alert_time || ' days')::INTERVAL; END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_lot_expiration_dates BEFORE INSERT ON inventory.lots FOR EACH ROW EXECUTE FUNCTION inventory.compute_lot_expiration_dates(); -- Función: Limpiar valor de la vista materializada CREATE OR REPLACE FUNCTION inventory.refresh_product_valuation_summary() RETURNS void AS $$ BEGIN REFRESH MATERIALIZED VIEW CONCURRENTLY inventory.product_valuation_summary; END; $$ LANGUAGE plpgsql; COMMENT ON FUNCTION inventory.refresh_product_valuation_summary IS 'Refresca la vista materializada de valoración de productos'; -- ===================================================== -- PARTE 5: TRIGGERS DE ACTUALIZACIÓN -- ===================================================== -- Trigger: Actualizar updated_at para lots CREATE OR REPLACE FUNCTION inventory.update_lots_timestamp() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_lots_updated_at BEFORE UPDATE ON inventory.lots FOR EACH ROW EXECUTE FUNCTION inventory.update_lots_timestamp(); -- Trigger: Actualizar updated_at para count_sessions CREATE TRIGGER trg_count_sessions_updated_at BEFORE UPDATE ON inventory.inventory_count_sessions FOR EACH ROW EXECUTE FUNCTION inventory.update_lots_timestamp(); -- Trigger: Actualizar estadísticas de sesión al modificar líneas CREATE OR REPLACE FUNCTION inventory.update_session_stats() RETURNS TRIGGER AS $$ BEGIN UPDATE inventory.inventory_count_sessions SET counted_quants = ( SELECT COUNT(*) FROM inventory.inventory_count_lines WHERE session_id = COALESCE(NEW.session_id, OLD.session_id) AND state IN ('counted', 'applied') ), discrepancy_quants = ( SELECT COUNT(*) FROM inventory.inventory_count_lines WHERE session_id = COALESCE(NEW.session_id, OLD.session_id) AND state = 'counted' AND (counted_qty - theoretical_qty) != 0 ), total_value_diff = ( SELECT COALESCE(SUM(ABS((counted_qty - theoretical_qty) * COALESCE(unit_cost, 0))), 0) FROM inventory.inventory_count_lines WHERE session_id = COALESCE(NEW.session_id, OLD.session_id) AND counted_qty IS NOT NULL ), updated_at = NOW() WHERE id = COALESCE(NEW.session_id, OLD.session_id); RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_update_session_stats AFTER INSERT OR UPDATE OR DELETE ON inventory.inventory_count_lines FOR EACH ROW EXECUTE FUNCTION inventory.update_session_stats(); -- ===================================================== -- PARTE 6: VISTAS -- ===================================================== -- Vista: Lotes próximos a caducar CREATE OR REPLACE VIEW inventory.expiring_lots_view AS SELECT l.id, l.name as lot_name, l.product_id, p.name as product_name, p.default_code as sku, l.expiration_date, l.removal_date, EXTRACT(DAY FROM l.expiration_date - NOW()) as days_until_expiry, COALESCE(SUM(q.quantity), 0) as stock_qty, l.company_id, l.tenant_id FROM inventory.lots l JOIN inventory.products p ON p.id = l.product_id LEFT JOIN inventory.quants q ON q.lot_id = l.id LEFT JOIN inventory.locations loc ON q.location_id = loc.id WHERE l.expiration_date IS NOT NULL AND l.expiration_date > NOW() AND loc.location_type = 'internal' GROUP BY l.id, p.id HAVING COALESCE(SUM(q.quantity), 0) > 0; COMMENT ON VIEW inventory.expiring_lots_view IS 'Vista de lotes con stock próximos a caducar'; -- Vista: Resumen de conteos por ubicación CREATE OR REPLACE VIEW inventory.location_count_summary_view AS SELECT l.id as location_id, l.name as location_name, l.warehouse_id, w.name as warehouse_name, l.cyclic_inventory_frequency, l.last_inventory_date, (l.last_inventory_date + (l.cyclic_inventory_frequency || ' days')::INTERVAL)::DATE as next_inventory_date, l.abc_classification, COUNT(q.id) as quant_count, COALESCE(SUM(q.quantity * COALESCE(p.standard_price, 0)), 0) as total_value FROM inventory.locations l LEFT JOIN inventory.warehouses w ON l.warehouse_id = w.id LEFT JOIN inventory.quants q ON q.location_id = l.id AND q.quantity > 0 LEFT JOIN inventory.products p ON q.product_id = p.id WHERE l.location_type = 'internal' AND l.cyclic_inventory_frequency > 0 GROUP BY l.id, w.id; COMMENT ON VIEW inventory.location_count_summary_view IS 'Resumen de configuración de conteo cíclico por ubicación'; -- ===================================================== -- COMENTARIOS EN TABLAS -- ===================================================== COMMENT ON TABLE inventory.stock_valuation_layers IS 'Capas de valoración de inventario para costeo FIFO/AVCO'; COMMENT ON TABLE inventory.lots IS 'Lotes y números de serie para trazabilidad'; -- Nota: La tabla anterior se renombró a stock_move_consume_rel COMMENT ON TABLE inventory.removal_strategies IS 'Estrategias de salida de inventario (FIFO/LIFO/FEFO)'; COMMENT ON TABLE inventory.inventory_count_sessions IS 'Sesiones de conteo cíclico de inventario'; COMMENT ON TABLE inventory.inventory_count_lines IS 'Líneas detalladas de conteo de inventario'; COMMENT ON TABLE inventory.abc_classification_rules IS 'Reglas de clasificación ABC para priorización'; COMMENT ON TABLE inventory.product_abc_classification IS 'Clasificación ABC calculada por producto'; COMMENT ON TABLE inventory.category_stock_accounts IS 'Cuentas contables de valoración por categoría'; COMMENT ON TABLE inventory.valuation_settings IS 'Configuración de valoración por tenant/empresa'; -- ===================================================== -- FIN DE EXTENSIONES INVENTORY -- =====================================================