-- ============================================================ -- FIX COMPLETO: Flujo de inventario sin duplicacion -- Fecha: 2026-06-09 -- Autor: Analisis automatico del sistema -- ============================================================ -- PROBLEMA IDENTIFICADO: -- El stock se duplicaba al pagar un expense porque: -- 1. newexpensev2 y updateexpensev4 creaban movimientos de -- stock al crear/editar un expense (movimientos fantasma). -- 2. purchaseentry tambien creaba movimientos y actualizaba -- el stock al recibir fisicamente. -- 3. setpaymentstatusv2 sumaba de nuevo al stock al pagar. -- -- ADEMAS: -- La unica forma de cancelar un expense es rechazando el pago -- (status = 3), pero setpaymentstatusv2 no revertia el stock -- al cancelar. -- -- SOLUCION APLICADA: -- 1. newexpensev2 y updateexpensev4 ya NO tocan stock ni -- stock_movements. Solo manejan expenses y purchase_detail. -- 2. purchaseentry es la UNICA funcion que ingresa stock al -- recibir mercancia fisicamente. -- 3. setpaymentstatusv2(status = 1) solo paga, no toca stock. -- 4. setpaymentstatusv2(status = 3) revierte el stock si ya -- se habia recibido fisicamente (purchaseentry), creando -- un movimiento de tipo "Cancellation". -- 5. Se crea el tipo de movimiento "Cancellation" en -- movement_type si no existe. -- -- NOTA: Este cambio es reversible. Si algo falla, ejecutar -- rollback_inventory_flow_complete.sql -- ============================================================ -- ============================================================ -- 1. CREAR TIPO DE MOVIMIENTO "Cancellation" SI NO EXISTE -- ============================================================ DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM movement_type WHERE name_mov_type = 'Cancellation' ) THEN INSERT INTO movement_type(name_mov_type) VALUES ('Cancellation'); END IF; END $$; -- ============================================================ -- 2. FIX: setpaymentstatusv2 -- ============================================================ -- Pagar (status = 1): solo actualiza payments. -- Cancelar/Rechazar pago (status = 3): revierte stock si hubo -- recepcion fisica previa (purchaseentry). -- ============================================================ CREATE OR REPLACE FUNCTION public.setpaymentstatusv2( expense integer, status integer) RETURNS integer LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$ DECLARE rows_updated INT := 0; details RECORD; approval INT := 0; stock_anterior BIGINT; stock_actual BIGINT; v_id_stock_mov INT; cancellation_type_id INT; BEGIN SELECT id_approval_stat INTO approval FROM expenses WHERE id_expense = expense; -- Solo procesar si el gasto esta aprobado IF approval = 1 THEN -- ============================================================ -- PAGAR: status = 1 -- ============================================================ IF status = 1 THEN UPDATE payments SET id_payment_status = status, payment_date = CURRENT_DATE WHERE id_expense = expense; GET DIAGNOSTICS rows_updated = ROW_COUNT; IF rows_updated = 0 THEN RETURN -1; END IF; -- NO se toca stock. purchaseentry ya lo hizo correctamente. RETURN status; -- ============================================================ -- CANCELAR/RECHAZAR PAGO: status = 3 -- ============================================================ ELSIF status = 3 THEN UPDATE payments SET id_payment_status = status, payment_date = CURRENT_DATE WHERE id_expense = expense; GET DIAGNOSTICS rows_updated = ROW_COUNT; IF rows_updated = 0 THEN RETURN -1; END IF; -- Obtener id del tipo de movimiento Cancellation SELECT id_mov_type INTO cancellation_type_id FROM movement_type WHERE name_mov_type = 'Cancellation'; -- Revertir stock si ya se habia recibido fisicamente IF EXISTS ( SELECT 1 FROM purchase_detail pd WHERE pd.id_expense = expense AND pd.delivered > 0 ) THEN FOR details IN SELECT pd.id_product, pd.delivered FROM purchase_detail pd WHERE pd.id_expense = expense AND pd.delivered > 0 LOOP SELECT stock INTO stock_anterior FROM stock WHERE id_product = details.id_product FOR UPDATE; stock_actual := stock_anterior - details.delivered; UPDATE stock SET stock = stock_actual WHERE id_product = details.id_product; INSERT INTO stock_movements( id_product, id_mov_type, date_st_mov, id_expense, quantity, stock_before, stock_current ) VALUES ( details.id_product, cancellation_type_id, CURRENT_DATE, expense, details.delivered * (-1), stock_anterior, stock_actual ) RETURNING id_sto_mov INTO v_id_stock_mov; END LOOP; END IF; RETURN status; ELSE -- Status invalido RETURN -2; END IF; ELSE -- No esta aprobado, no se puede actualizar el pago RETURN -3; END IF; END; $BODY$; -- ============================================================ -- 3. FIX: newexpensev2 -- ============================================================ -- Ya NO crea stock_movements. Solo crea expenses y -- purchase_detail. -- ============================================================ CREATE OR REPLACE FUNCTION public.newexpensev2( new_description character varying, suppliers_id integer, new_request_date date, new_payment_deadline date, request_by integer, area integer, expense_cat integer, currency_id integer, needtoapprove boolean, products jsonb DEFAULT NULL::jsonb, new_subtotal numeric DEFAULT NULL::numeric, new_iva numeric DEFAULT NULL::numeric, new_ieps numeric DEFAULT NULL::numeric, new_total numeric DEFAULT NULL::numeric) RETURNS integer LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$ DECLARE new_expense INT; product JSONB; approval_status INT := 3; approval_datevar DATE := NULL; monedacambio NUMERIC := 1; fecha_busqueda DATE; productsubtotal NUMERIC := 0; producttotal NUMERIC := 0; productsiva NUMERIC := 0; subtotalcambiomoneda NUMERIC := 0; totalcambiomoneda NUMERIC := 0; ivacambiomoneda NUMERIC := 0; iepscambiomoneda NUMERIC := 0; BEGIN IF needtoapprove THEN approval_status := 3; approval_datevar := NULL; ELSE approval_status := 1; approval_datevar := CURRENT_DATE; END IF; -- Si la moneda es dolares (2), buscar tipo de cambio IF (currency_id = 2) THEN IF (new_payment_deadline < CURRENT_DATE) THEN fecha_busqueda := new_payment_deadline; ELSE fecha_busqueda := CURRENT_DATE; END IF; LOOP SELECT onegetchange(fecha_busqueda) INTO monedacambio; EXIT WHEN monedacambio IS NOT NULL; fecha_busqueda := fecha_busqueda - INTERVAL '1 day'; IF fecha_busqueda < CURRENT_DATE - INTERVAL '30 day' THEN RAISE NOTICE 'No se encontro tipo de cambio valido en los ultimos 30 dias desde %', fecha_busqueda; monedacambio := 1; EXIT; END IF; END LOOP; END IF; -- Calcular totales de productos IF jsonb_array_length(products) > 0 THEN SELECT SUM((prod->>'quantity')::NUMERIC * (prod->>'unit_cost')::NUMERIC) INTO productsubtotal FROM jsonb_array_elements(products) AS prod; SELECT SUM((prod->>'quantity')::NUMERIC * (prod->>'unit_cost')::NUMERIC * t.number) INTO productsiva FROM jsonb_array_elements(products) AS prod LEFT JOIN taxes t ON t.id_tax = (prod->>'id_tax')::INT; SELECT SUM((prod->>'quantity')::NUMERIC * (prod->>'unit_cost')::NUMERIC * (1 + t.number)) INTO producttotal FROM jsonb_array_elements(products) AS prod LEFT JOIN taxes t ON t.id_tax = (prod->>'id_tax')::INT; producttotal := producttotal + COALESCE(new_ieps, 0); ELSE productsubtotal := new_subtotal; producttotal := new_total; productsiva := new_iva; END IF; -- Aplicar cambio de moneda IF (currency_id = 2) THEN subtotalcambiomoneda := productsubtotal * monedacambio; ivacambiomoneda := productsiva * monedacambio; iepscambiomoneda := new_ieps * monedacambio; totalcambiomoneda := producttotal * monedacambio; ELSE subtotalcambiomoneda := productsubtotal; totalcambiomoneda := producttotal; ivacambiomoneda := productsiva; iepscambiomoneda := new_ieps; END IF; INSERT INTO expenses( id_properties, id_expense_type, id_recurrence, description, id_suppliers, request_date, payment_deadline, approval_date, id_request_by, id_approval_by, id_area, id_expense_cat, id_approval_stat, id_currency, subtotal, iva, ieps, total ) VALUES ( 1, 2, 3, new_description, suppliers_id, new_request_date, new_payment_deadline, approval_datevar, request_by, null, area, expense_cat, approval_status, currency_id, COALESCE(subtotalcambiomoneda, 0), COALESCE(ivacambiomoneda, 0), COALESCE(iepscambiomoneda, 0), COALESCE(totalcambiomoneda, 0) ) RETURNING id_expense INTO new_expense; INSERT INTO payments(id_expense, id_payment_status, payment_date) VALUES (new_expense, 2, NULL); -- Crear purchase_detail. NO se crea stock_movements aqui. IF products IS NOT NULL AND jsonb_array_length(products) > 0 THEN FOR product IN SELECT jsonb_array_elements(products) LOOP INSERT INTO purchase_detail ( id_expense, id_product, quantity, id_tax, unit_cost, total, id_stock_mov ) VALUES ( new_expense, (product->>'id_product')::INT, (product->>'quantity')::INT, (product->>'id_tax')::INT, ((product->>'unit_cost')::NUMERIC * monedacambio), COALESCE( (SELECT ((product->>'quantity')::NUMERIC * (product->>'unit_cost')::NUMERIC * (1 + COALESCE(t.number, 0))) * monedacambio FROM taxes t WHERE t.id_tax = (product->>'id_tax')::INT), 0 ), NULL -- id_stock_mov se asigna en purchaseentry ); END LOOP; END IF; RETURN new_expense; END; $BODY$; -- ============================================================ -- 4. FIX: updateexpensev4 -- ============================================================ -- Ya NO crea ni elimina stock_movements. Solo actualiza -- expenses y purchase_detail. -- ============================================================ CREATE OR REPLACE FUNCTION public.updateexpensev4( expense_id integer, up_description character varying, suppliers_id integer, up_req_date date, up_deadline date, up_currency integer, up_request_by integer, up_area integer, up_category integer, needtoapprove boolean, up_subtotal numeric DEFAULT NULL::numeric, up_iva numeric DEFAULT NULL::numeric, up_ieps numeric DEFAULT NULL::numeric, up_total numeric DEFAULT NULL::numeric, products jsonb DEFAULT NULL::jsonb) RETURNS integer LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$ DECLARE product JSONB; approval_status INT := 3; approval_datevar date := NULL; monedacambio NUMERIC := 1; fecha_busqueda DATE; productsubtotal NUMERIC := 0; producttotal NUMERIC := 0; productsiva NUMERIC := 0; subtotalcambiomoneda NUMERIC := 0; totalcambiomoneda NUMERIC := 0; ivacambiomoneda NUMERIC := 0; iepscambiomoneda NUMERIC := 0; BEGIN IF needtoapprove THEN approval_status := 3; approval_datevar := NULL; ELSE approval_status := 1; approval_datevar := CURRENT_DATE; END IF; -- Si la moneda es dolares (2), buscar tipo de cambio IF (up_currency = 2) THEN IF (up_deadline < CURRENT_DATE) THEN fecha_busqueda := up_deadline; ELSE fecha_busqueda := CURRENT_DATE; END IF; LOOP SELECT onegetchange(fecha_busqueda) INTO monedacambio; EXIT WHEN monedacambio IS NOT NULL; fecha_busqueda := fecha_busqueda - INTERVAL '1 day'; IF fecha_busqueda < CURRENT_DATE - INTERVAL '30 day' THEN RAISE NOTICE 'No se encontro tipo de cambio valido en los ultimos 30 dias desde %', fecha_busqueda; monedacambio := 1; EXIT; END IF; END LOOP; END IF; -- Calcular totales de productos IF jsonb_array_length(products) > 0 THEN SELECT SUM((prod->>'quantity')::NUMERIC * (prod->>'unit_cost')::NUMERIC) INTO productsubtotal FROM jsonb_array_elements(products) AS prod; SELECT SUM((prod->>'quantity')::NUMERIC * (prod->>'unit_cost')::NUMERIC * t.number) INTO productsiva FROM jsonb_array_elements(products) AS prod LEFT JOIN taxes t ON t.id_tax = (prod->>'id_tax')::INT; SELECT SUM((prod->>'quantity')::NUMERIC * (prod->>'unit_cost')::NUMERIC * (1 + t.number)) INTO producttotal FROM jsonb_array_elements(products) AS prod LEFT JOIN taxes t ON t.id_tax = (prod->>'id_tax')::INT; producttotal := producttotal + COALESCE(up_ieps, 0); ELSE productsubtotal := up_subtotal; producttotal := up_total; productsiva := up_iva; END IF; -- Aplicar cambio de moneda IF (up_currency = 2) THEN subtotalcambiomoneda := productsubtotal * monedacambio; ivacambiomoneda := productsiva * monedacambio; iepscambiomoneda := up_ieps * monedacambio; totalcambiomoneda := producttotal * monedacambio; ELSE subtotalcambiomoneda := productsubtotal; totalcambiomoneda := producttotal; ivacambiomoneda := productsiva; iepscambiomoneda := up_ieps; END IF; -- Actualizar la cabecera del gasto UPDATE expenses SET description = up_description, id_suppliers = suppliers_id, request_date = up_req_date, payment_deadline = up_deadline, id_currency = up_currency, id_request_by = up_request_by, id_area = up_area, id_expense_cat = up_category, approval_date = approval_datevar, id_approval_stat = approval_status, subtotal = COALESCE(subtotalcambiomoneda, 0), iva = COALESCE(ivacambiomoneda, 0), ieps = COALESCE(iepscambiomoneda, 0), total = COALESCE(totalcambiomoneda, 0) WHERE id_expense = expense_id; -- Manejar detalle de productos IF products IS NOT NULL THEN -- Eliminar purchase_detail que ya no vienen en el JSON DELETE FROM purchase_detail pd WHERE pd.id_expense = expense_id AND pd.id_product NOT IN ( SELECT (p->>'id_product')::INT FROM jsonb_array_elements(products) AS p ); -- Actualizar o insertar cada producto del JSON FOR product IN SELECT jsonb_array_elements(products) LOOP UPDATE purchase_detail SET quantity = (product->>'quantity')::INT, id_tax = (product->>'id_tax')::INT, unit_cost = ((product->>'unit_cost')::NUMERIC * monedacambio), total = COALESCE( (SELECT ((product->>'quantity')::NUMERIC * (product->>'unit_cost')::NUMERIC * (1 + COALESCE(t.number, 0))) * monedacambio FROM taxes t WHERE t.id_tax = (product->>'id_tax')::INT), 0 ) WHERE id_expense = expense_id AND id_product = (product->>'id_product')::INT; -- Si no actualizo nada (no existia), insertar IF NOT FOUND THEN INSERT INTO purchase_detail ( id_expense, id_product, quantity, id_tax, unit_cost, total, id_stock_mov ) VALUES ( expense_id, (product->>'id_product')::INT, (product->>'quantity')::INT, (product->>'id_tax')::INT, ((product->>'unit_cost')::NUMERIC * monedacambio), COALESCE( (SELECT ((product->>'quantity')::NUMERIC * (product->>'unit_cost')::NUMERIC * (1 + COALESCE(t.number, 0))) * monedacambio FROM taxes t WHERE t.id_tax = (product->>'id_tax')::INT), 0 ), NULL ); END IF; END LOOP; END IF; RETURN 1; END; $BODY$; -- ============================================================ -- 5. FIX: purchaseentry -- ============================================================ -- Actualiza purchase_detail.id_stock_mov con el movimiento -- recien creado, para mantener integridad referencial. -- ============================================================ CREATE OR REPLACE FUNCTION public.purchaseentry( purchase_id integer, checking integer) RETURNS integer LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$ DECLARE v_id_product INT; v_id_expense INT; v_quantity INT; v_unit_cost NUMERIC; v_delivered INT; stock_anterior BIGINT; stock_actual BIGINT; v_id_stock_mov INT; v_quantity_received INT; BEGIN -- Recuperamos la informacion de purchase_detail SELECT id_product, id_expense, quantity, unit_cost, delivered INTO v_id_product, v_id_expense, v_quantity, v_unit_cost, v_delivered FROM purchase_detail WHERE id_purchase_dt = purchase_id; -- Cantidad efectivamente recibida en este movimiento v_quantity_received := LEAST(checking, v_quantity - COALESCE(v_delivered, 0)); IF v_quantity_received <= 0 THEN RETURN 0; -- Ya se recibio todo o cantidad invalida END IF; UPDATE purchase_detail SET delivered = LEAST(COALESCE(delivered, 0) + checking, quantity) WHERE id_purchase_dt = purchase_id; SELECT stock INTO stock_anterior FROM stock WHERE id_product = v_id_product FOR UPDATE; stock_actual := stock_anterior + v_quantity_received; INSERT INTO stock_movements( id_product, id_mov_type, date_st_mov, id_purchase_dt, id_expense, quantity, stock_before, stock_current ) VALUES ( v_id_product, 1, CURRENT_DATE, purchase_id, v_id_expense, v_quantity_received, stock_anterior, stock_actual ) RETURNING id_sto_mov INTO v_id_stock_mov; UPDATE stock SET average_cost = CASE WHEN average_cost IS NULL THEN v_unit_cost ELSE ( ((average_cost * stock) + (v_quantity_received * v_unit_cost)) / (stock + v_quantity_received) ) END, stock = stock_actual WHERE id_product = v_id_product; -- Asignar el id del movimiento al purchase_detail UPDATE purchase_detail SET id_stock_mov = v_id_stock_mov WHERE id_purchase_dt = purchase_id; RETURN 1; END; $BODY$; -- Confirmacion SELECT 'Fix completo de flujo de inventario aplicado correctamente' AS resultado;