mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 18:43:18 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Validación de endpoints y SPs Se probaron todos los endpoints nuevos (gastos, almacén, control presupuestal, IAM) y sus RPCs asociados. ### Bugs corregidos en migraciones/SQL 1. **IAM grants en schema core** — `iam-004e` intentaba `GRANT` sobre `core` con rol `iam_owner` (sin permiso). Los grants cruzados se movieron a `core-027-iam-rpc-cross-grants.sql`. 2. **`_cost_settings` ambiguo** — columnas `budget_warn_pct` etc. colisionaban con `RETURNS TABLE` en PL/pgSQL, rompiendo `fn_expense_create` y control presupuestal. Corregido en `core-028-fix-cost-settings-ambiguous.sql`. ### Tests añadidos - `api/cost_modules_test.ts` — CRUD RPC gastos, flujo almacén completo, control presupuestal, IAM permisos - `scripts/crud-smoke-test.sh` — smoke HTTP de todos los endpoints nuevos ### Resultados - `deno test cost_modules_test.ts` — 5/5 OK - `./scripts/crud-smoke-test.sh` — todos los checks OK (gastos CRUD, IVA, cost-settings, almacén, transferencias, cost-control, IAM) <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-eeec3b5c-f789-43e8-a353-0755e4706377?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-eeec3b5c-f789-43e8-a353-0755e4706377&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
486 lines
23 KiB
PL/PgSQL
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;
|