Files
hotel-hacienda/scripts_sql/fix_inventory_flow_complete.sql
Consultoria AS 194039650d fix(sql): flujo completo de inventario sin duplicacion
- setpaymentstatusv2: pago no toca stock, cancelacion revierte stock
- newexpensev2/updateexpensev4: ya no crean stock_movements
- purchaseentry: actualiza id_stock_mov en purchase_detail
- Nuevo tipo de movimiento Cancellation
- Rollback completo incluido
2026-06-25 12:04:16 -07:00

610 lines
20 KiB
PL/PgSQL

-- ============================================================
-- 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;