panels-origin/db/core/changesets/023-rpc-warehouse.sql
Cursor Agent ab89727522
feat: gastos, almacén central/obra y control presupuestal
Implementa el módulo integral de costos según el plan acordado:

- Esquema Liquibase: expense_entries, almacén (central + obra), IVA,
  tenant_cost_settings, permisos IAM y RLS
- Funciones RPC core: gastos, almacén, control presupuestal, sync nómina,
  cierre de almacén al concluir proyecto
- API HTTP: expenses_http, warehouse_http, cost_control_http, iam_http
- Middleware requirePermission con matriz IAM
- UI web-panel: /gastos, /almacen, /control-presupuesto, /usuarios
- Componentes BudgetItemPicker, CostSemaphore y semáforos CSS
- Smoke tests extendidos para gastos/almacén/control

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 04:04:17 +00:00

486 lines
23 KiB
PL/PgSQL

--liquibase formatted sql
-- PANELS · core · RPC almacén
--changeset panel:core-023a-fn-warehouse-ensure-central splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_ensure_central(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_id bigint;
v_row jsonb;
BEGIN
SELECT id INTO v_id FROM warehouses WHERE tenant_id = v_tid AND kind = 'central' LIMIT 1;
IF v_id IS NULL THEN
INSERT INTO warehouses (tenant_id, kind, status) VALUES (v_tid, 'central', 'open')
RETURNING id INTO v_id;
END IF;
SELECT to_jsonb(w) INTO v_row FROM warehouses w WHERE id = v_id;
RETURN core.rpc_ok(v_row, format('Almacén central del tenant %s listo (id=%s)', v_tid, v_id),
jsonb_build_object('fn', 'fn_warehouse_ensure_central', 'id', v_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_ensure_central', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023b-fn-warehouse-open-project splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_open_project(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_proj record;
v_id bigint;
v_row jsonb;
BEGIN
IF v_project_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_open_project: project_id es obligatorio',
jsonb_build_object('fn', 'fn_warehouse_open_project', 'field', 'project_id'));
END IF;
SELECT id, status, code, name INTO v_proj FROM projects WHERE id = v_project_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_warehouse_open_project: proyecto id=%s no encontrado', v_project_id),
jsonb_build_object('fn', 'fn_warehouse_open_project', 'project_id', v_project_id));
END IF;
IF v_proj.status NOT IN ('activo', 'pausado') THEN
RETURN core.rpc_err('VALIDATION',
format('fn_warehouse_open_project: proyecto %s está %s, no se abre almacén', v_proj.code, v_proj.status),
jsonb_build_object('fn', 'fn_warehouse_open_project', 'project_id', v_project_id));
END IF;
SELECT id INTO v_id FROM warehouses WHERE project_id = v_project_id AND kind = 'project';
IF v_id IS NULL THEN
INSERT INTO warehouses (tenant_id, kind, project_id, status) VALUES (v_tid, 'project', v_project_id, 'open')
RETURNING id INTO v_id;
ELSE
UPDATE warehouses SET status = 'open', closed_at = NULL, closed_by_name = '' WHERE id = v_id;
END IF;
PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_tid));
SELECT to_jsonb(w) INTO v_row FROM warehouses w WHERE id = v_id;
RETURN core.rpc_ok(v_row,
format('Almacén de obra abierto para proyecto %s (%s)', v_proj.code, v_proj.name),
jsonb_build_object('fn', 'fn_warehouse_open_project', 'id', v_id, 'project_id', v_project_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_open_project', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023c-fn-warehouse-stock-upsert splitStatements:false
CREATE OR REPLACE FUNCTION core._warehouse_stock_apply(
p_warehouse_id bigint,
p_material_id bigint,
p_qty_delta numeric,
p_unit_cost numeric
)
RETURNS void
LANGUAGE plpgsql
SET search_path = core
AS $$
DECLARE
v_cur record;
v_new_qty numeric;
v_new_avg numeric;
BEGIN
SELECT * INTO v_cur FROM warehouse_stock
WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id;
IF NOT FOUND THEN
IF p_qty_delta < 0 THEN
RAISE EXCEPTION 'Stock insuficiente en almacén % material %', p_warehouse_id, p_material_id;
END IF;
INSERT INTO warehouse_stock (warehouse_id, material_id, qty_on_hand, avg_unit_cost)
VALUES (p_warehouse_id, p_material_id, p_qty_delta, COALESCE(p_unit_cost, 0));
RETURN;
END IF;
v_new_qty := v_cur.qty_on_hand + p_qty_delta;
IF v_new_qty < 0 THEN
RAISE EXCEPTION 'Stock insuficiente: disponible % solicitado %', v_cur.qty_on_hand, -p_qty_delta;
END IF;
IF p_qty_delta > 0 AND p_unit_cost > 0 THEN
v_new_avg := CASE WHEN v_new_qty > 0 THEN
round((v_cur.qty_on_hand * v_cur.avg_unit_cost + p_qty_delta * p_unit_cost) / v_new_qty, 4)
ELSE 0 END;
ELSE
v_new_avg := v_cur.avg_unit_cost;
END IF;
UPDATE warehouse_stock SET qty_on_hand = v_new_qty, avg_unit_cost = v_new_avg
WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id;
END;
$$;
--changeset panel:core-023d-fn-warehouse-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_include_closed boolean := COALESCE((payload->>'include_closed')::boolean, false);
v_rows jsonb;
BEGIN
PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_tid));
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.kind, x.id), '[]'::jsonb) INTO v_rows
FROM (
SELECT w.*, p.code AS project_code, p.name AS project_name
FROM warehouses w
LEFT JOIN projects p ON p.id = w.project_id
WHERE w.tenant_id = v_tid
AND (v_include_closed OR w.status = 'open' OR w.kind = 'central')
AND (w.kind = 'central' OR w.status <> 'closed' OR v_include_closed)
) x;
RETURN core.rpc_ok(
jsonb_build_object('warehouses', v_rows),
format('Almacenes del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_warehouse_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023e-fn-warehouse-stock-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_stock_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
v_global boolean := COALESCE((payload->>'global')::boolean, false);
v_tid integer := core._cost_tenant_id(payload);
v_rows jsonb;
BEGIN
IF v_global THEN
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
SELECT w.id AS warehouse_id, w.kind, w.project_id, p.code AS project_code,
m.id AS material_id, m.code AS material_code, m.name AS material_name, m.unit,
s.qty_on_hand, s.avg_unit_cost, round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value
FROM warehouse_stock s
JOIN warehouses w ON w.id = s.warehouse_id
JOIN warehouse_materials m ON m.id = s.material_id
LEFT JOIN projects p ON p.id = w.project_id
WHERE w.tenant_id = v_tid AND w.status = 'open' AND s.qty_on_hand > 0
) x;
ELSE
IF v_warehouse_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_stock_list: warehouse_id es obligatorio si global=false',
jsonb_build_object('fn', 'fn_warehouse_stock_list'));
END IF;
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
SELECT s.*, m.code AS material_code, m.name AS material_name, m.unit,
round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value
FROM warehouse_stock s
JOIN warehouse_materials m ON m.id = s.material_id
WHERE s.warehouse_id = v_warehouse_id AND s.qty_on_hand > 0
) x;
END IF;
RETURN core.rpc_ok(jsonb_build_object('stock', v_rows),
format('Stock consultado: %s línea(s)', jsonb_array_length(COALESCE(v_rows, '[]'::jsonb))),
jsonb_build_object('fn', 'fn_warehouse_stock_list', 'warehouse_id', v_warehouse_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_stock_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023f-fn-material-upsert splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_material_upsert(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), ''));
v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
v_id bigint;
BEGIN
IF v_code = '' OR v_name = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_material_upsert: code y name son obligatorios',
jsonb_build_object('fn', 'fn_warehouse_material_upsert'));
END IF;
INSERT INTO warehouse_materials (tenant_id, code, name, unit)
VALUES (v_tid, v_code, v_name, v_unit)
ON CONFLICT (tenant_id, code) DO UPDATE SET name = EXCLUDED.name, unit = EXCLUDED.unit
RETURNING id INTO v_id;
RETURN core.rpc_ok(
(SELECT to_jsonb(m) FROM warehouse_materials m WHERE id = v_id),
format('Material %s (%s) registrado', v_code, v_name),
jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_material_upsert', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023g-fn-warehouse-entry-create splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_entry_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
v_material_code text := nullif(btrim(payload->>'material_code'), '');
v_material_name text := nullif(btrim(payload->>'material_name'), '');
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0);
v_unit_cost numeric := COALESCE((payload->>'unit_cost')::numeric, 0);
v_movement_date date := COALESCE(NULLIF(payload->>'movement_date', '')::date, CURRENT_DATE);
v_expense_id bigint := NULLIF(payload->>'expense_id', '')::bigint;
v_wh record;
v_mov_id bigint;
v_total numeric;
v_env jsonb;
BEGIN
IF v_warehouse_id IS NULL OR v_qty <= 0 THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_entry_create: warehouse_id y quantity>0 son obligatorios',
jsonb_build_object('fn', 'fn_warehouse_entry_create'));
END IF;
SELECT * INTO v_wh FROM warehouses WHERE id = v_warehouse_id AND tenant_id = v_tid;
IF NOT FOUND OR v_wh.status <> 'open' THEN
RETURN core.rpc_err('VALIDATION', format('fn_warehouse_entry_create: almacén id=%s no disponible', v_warehouse_id),
jsonb_build_object('fn', 'fn_warehouse_entry_create', 'warehouse_id', v_warehouse_id));
END IF;
IF v_material_id IS NULL AND v_material_code IS NOT NULL THEN
v_env := core.fn_warehouse_material_upsert(jsonb_build_object(
'tenant_id', v_tid, 'code', v_material_code, 'name', coalesce(v_material_name, v_material_code), 'unit', v_unit
));
IF NOT COALESCE((v_env->>'ok')::boolean, false) THEN
RETURN v_env;
END IF;
v_material_id := (v_env->'data'->>'id')::bigint;
END IF;
IF v_material_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_entry_create: material_id o material_code requerido',
jsonb_build_object('fn', 'fn_warehouse_entry_create'));
END IF;
v_total := round(v_qty * v_unit_cost, 2);
PERFORM core._warehouse_stock_apply(v_warehouse_id, v_material_id, v_qty, v_unit_cost);
INSERT INTO warehouse_movements (
tenant_id, warehouse_id, material_id, movement_type, movement_date,
quantity, unit_cost, total_cost, expense_id, created_by_id, created_by_name
) VALUES (
v_tid, v_warehouse_id, v_material_id, 'entry', v_movement_date,
v_qty, v_unit_cost, v_total, v_expense_id,
NULLIF(payload->>'created_by_id', '')::integer,
coalesce(nullif(btrim(payload->>'created_by_name'), ''), '')
) RETURNING id INTO v_mov_id;
RETURN core.rpc_created(
jsonb_build_object('movement_id', v_mov_id, 'warehouse_id', v_warehouse_id, 'quantity', v_qty, 'total_cost', v_total),
format('Entrada de %s unidades al almacén id=%s por %s', v_qty, v_warehouse_id, v_total),
jsonb_build_object('fn', 'fn_warehouse_entry_create', 'movement_id', v_mov_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_entry_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023h-fn-warehouse-exit-create splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_exit_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0);
v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
v_destination text := coalesce(nullif(btrim(payload->>'destination_note'), ''), '');
v_movement_date date := COALESCE(NULLIF(payload->>'movement_date', '')::date, CURRENT_DATE);
v_stock record;
v_unit_cost numeric;
v_total numeric;
v_mov_id bigint;
v_warning jsonb;
BEGIN
IF v_warehouse_id IS NULL OR v_material_id IS NULL OR v_qty <= 0 THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_exit_create: warehouse_id, material_id y quantity>0 obligatorios',
jsonb_build_object('fn', 'fn_warehouse_exit_create'));
END IF;
SELECT * INTO v_stock FROM warehouse_stock WHERE warehouse_id = v_warehouse_id AND material_id = v_material_id;
IF NOT FOUND OR v_stock.qty_on_hand < v_qty THEN
RETURN core.rpc_err('VALIDATION',
format('fn_warehouse_exit_create: stock insuficiente (disponible %s, solicitado %s)',
COALESCE(v_stock.qty_on_hand, 0), v_qty),
jsonb_build_object('fn', 'fn_warehouse_exit_create', 'warehouse_id', v_warehouse_id));
END IF;
v_unit_cost := v_stock.avg_unit_cost;
v_total := round(v_qty * v_unit_cost, 2);
PERFORM core._warehouse_stock_apply(v_warehouse_id, v_material_id, -v_qty, v_unit_cost);
INSERT INTO warehouse_movements (
tenant_id, warehouse_id, material_id, movement_type, movement_date,
quantity, unit_cost, total_cost, budget_item_id, destination_note,
created_by_id, created_by_name
) VALUES (
v_tid, v_warehouse_id, v_material_id, 'exit', v_movement_date,
v_qty, v_unit_cost, v_total, v_budget_item_id, v_destination,
NULLIF(payload->>'created_by_id', '')::integer,
coalesce(nullif(btrim(payload->>'created_by_name'), ''), '')
) RETURNING id INTO v_mov_id;
IF v_budget_item_id IS NOT NULL THEN
v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0);
END IF;
RETURN core.rpc_created(
jsonb_build_object('movement_id', v_mov_id, 'total_cost', v_total, 'budget_warning', v_warning),
format('Salida de %s unidades del almacén id=%s hacia concepto %s', v_qty, v_warehouse_id, v_budget_item_id),
jsonb_build_object('fn', 'fn_warehouse_exit_create', 'movement_id', v_mov_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_exit_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023i-fn-warehouse-transfer-create splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_transfer_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_from_id bigint := NULLIF(payload->>'from_warehouse_id', '')::bigint;
v_to_id bigint := NULLIF(payload->>'to_warehouse_id', '')::bigint;
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0);
v_transfer_date date := COALESCE(NULLIF(payload->>'transfer_date', '')::date, CURRENT_DATE);
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
v_stock record;
v_unit_cost numeric;
v_transfer_id bigint;
v_created_by text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
BEGIN
IF v_from_id IS NULL OR v_to_id IS NULL OR v_material_id IS NULL OR v_qty <= 0 OR v_from_id = v_to_id THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: origen, destino, material y cantidad válidos son obligatorios',
jsonb_build_object('fn', 'fn_warehouse_transfer_create'));
END IF;
SELECT * INTO v_stock FROM warehouse_stock WHERE warehouse_id = v_from_id AND material_id = v_material_id;
IF NOT FOUND OR v_stock.qty_on_hand < v_qty THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: stock insuficiente en almacén origen',
jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'from_warehouse_id', v_from_id));
END IF;
v_unit_cost := v_stock.avg_unit_cost;
INSERT INTO warehouse_transfers (
tenant_id, from_warehouse_id, to_warehouse_id, material_id, quantity, unit_cost,
status, transfer_date, notes, created_by_name
) VALUES (
v_tid, v_from_id, v_to_id, v_material_id, v_qty, v_unit_cost,
'completed', v_transfer_date, v_notes, v_created_by
) RETURNING id INTO v_transfer_id;
PERFORM core._warehouse_stock_apply(v_from_id, v_material_id, -v_qty, v_unit_cost);
PERFORM core._warehouse_stock_apply(v_to_id, v_material_id, v_qty, v_unit_cost);
INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name)
VALUES (v_tid, v_from_id, v_material_id, 'transfer_out', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by);
INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name)
VALUES (v_tid, v_to_id, v_material_id, 'transfer_in', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by);
RETURN core.rpc_created(
jsonb_build_object('transfer_id', v_transfer_id, 'quantity', v_qty, 'unit_cost', v_unit_cost),
format('Transferencia %s unidades del almacén %s al %s', v_qty, v_from_id, v_to_id),
jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'transfer_id', v_transfer_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_transfer_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023j-fn-warehouse-movement-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_movement_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.movement_date DESC, x.id DESC), '[]'::jsonb) INTO v_rows
FROM (
SELECT m.*, mat.code AS material_code, mat.name AS material_name, bi.description AS budget_item_description
FROM warehouse_movements m
JOIN warehouse_materials mat ON mat.id = m.material_id
LEFT JOIN budget_items bi ON bi.id = m.budget_item_id
WHERE (v_warehouse_id IS NULL OR m.warehouse_id = v_warehouse_id)
AND (v_material_id IS NULL OR m.material_id = v_material_id)
) x;
RETURN core.rpc_ok(jsonb_build_object('movements', v_rows),
format('Kardex: %s movimiento(s)', jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_warehouse_movement_list', 'warehouse_id', v_warehouse_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_movement_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023k-fn-warehouse-close-project splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_warehouse_close_project(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_force boolean := COALESCE((payload->>'force')::boolean, false);
v_closed_by text := coalesce(nullif(btrim(payload->>'closed_by_name'), ''), '');
v_notes text := coalesce(nullif(btrim(payload->>'close_notes'), ''), '');
v_wh record;
v_stock_count integer;
BEGIN
IF v_project_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_close_project: project_id es obligatorio',
jsonb_build_object('fn', 'fn_warehouse_close_project'));
END IF;
SELECT * INTO v_wh FROM warehouses WHERE project_id = v_project_id AND kind = 'project';
IF NOT FOUND THEN
RETURN core.rpc_ok(jsonb_build_object('closed', false, 'reason', 'no_warehouse'),
format('Proyecto %s no tenía almacén abierto', v_project_id),
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
END IF;
IF v_wh.status = 'closed' THEN
RETURN core.rpc_ok(jsonb_build_object('warehouse_id', v_wh.id, 'status', 'closed'),
format('Almacén del proyecto %s ya estaba cerrado', v_project_id),
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
END IF;
SELECT COUNT(*)::integer INTO v_stock_count FROM warehouse_stock
WHERE warehouse_id = v_wh.id AND qty_on_hand > 0;
IF v_stock_count > 0 AND NOT v_force THEN
RETURN core.rpc_err('VALIDATION',
format('fn_warehouse_close_project: quedan %s material(es) con stock; transfiera o ajuste antes de cerrar', v_stock_count),
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id, 'stock_lines', v_stock_count));
END IF;
UPDATE warehouses SET status = 'closed', closed_at = now(), closed_by_name = v_closed_by, close_notes = v_notes
WHERE id = v_wh.id;
RETURN core.rpc_ok(
jsonb_build_object('warehouse_id', v_wh.id, 'status', 'closed', 'forced', v_force),
format('Almacén del proyecto %s cerrado', v_project_id),
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_warehouse_close_project', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-023l-warehouse-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core._warehouse_stock_apply(bigint, bigint, numeric, numeric) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_ensure_central(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_open_project(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_stock_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_material_upsert(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_entry_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_exit_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_transfer_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_movement_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_warehouse_close_project(jsonb) TO panels_core_app;