From e528d217d3cda208b4791a0f9b443cc7e9b7c8f8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 20:41:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20almac=C3=A9n=20global,=20abrir/cerrar?= =?UTF-8?q?=20por=20obra=20y=20cat=C3=A1logo=20de=20materiales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El árbol de Almacén deja de listar todas las obras. El almacén global siempre está disponible y consolida el stock de los almacenes abiertos. Las obras abren almacén a demanda y al cerrarlo pueden devolver el remanente al global. Entradas y traspasos usan el catálogo de materiales con unidades de medida y uno o varios proveedores. Co-authored-by: alberto.martinez --- api/cost_modules_test.ts | 53 ++ api/expense_catalogs_http.ts | 4 +- api/warehouse_http.ts | 36 +- db/core/changelog-master.xml | 1 + .../035-warehouse-materials-catalog.sql | 464 +++++++++++++ .../components/MaterialCatalogDialog.vue | 269 ++++++++ web-panel/pages/almacen.vue | 628 +++++++++++++++--- 7 files changed, 1371 insertions(+), 84 deletions(-) create mode 100644 db/core/changesets/035-warehouse-materials-catalog.sql create mode 100644 web-panel/components/MaterialCatalogDialog.vue diff --git a/api/cost_modules_test.ts b/api/cost_modules_test.ts index 8c445ad..ce4a2b9 100644 --- a/api/cost_modules_test.ts +++ b/api/cost_modules_test.ts @@ -202,6 +202,59 @@ Deno.test("RPC almacén: central, obra, entrada, transferencia, salida", async ( global: true, }); assertEquals(globalStock.ok, true); + const consolidated = (globalStock.data as { consolidated: Array<{ material_id: number; qty_on_hand: number }> }).consolidated; + const cem = consolidated.find((row) => Number(row.material_id) === materialId); + assertExists(cem); + assertEquals(Number(cem.qty_on_hand), 45); + + await db.prepare( + `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id) + VALUES ('COST-0002', 'Obra sin almacén', 'activo', 'arctec-dos-logos-fold', ?, ?)`, + ).run(fx.companyId, TENANT_ID); + const listedAgain = await callCoreFn(db, "core.fn_warehouse_list", base); + const warehouses = (listedAgain.data as { warehouses: Array<{ kind: string; project_id?: number }> }).warehouses; + assertEquals(warehouses.filter((w) => w.kind === "central").length, 1); + assertEquals(warehouses.filter((w) => w.kind === "project").length, 1); + + const supplier = await callCoreFn(db, "core.fn_supplier_create", { + tenant_id: TENANT_ID, + name: "Cemex Test", + }); + assertEquals(supplier.ok, true); + const supplierId = Number(((supplier.data as { supplier: { id: number } }).supplier).id); + + const units = await callCoreFn(db, "core.fn_measure_unit_list", base); + assertEquals(units.ok, true); + const bulto = ((units.data as { units: Array<{ id: number; code: string }> }).units) + .find((u) => u.code === "bulto"); + assertExists(bulto); + + const catalog = await callCoreFn(db, "core.fn_warehouse_material_upsert", { + ...base, + code: "VAR001", + name: "Varilla 3/8", + unit_id: bulto.id, + supplier_ids: [supplierId], + }); + assertEquals(catalog.ok, true); + const listedMats = await callCoreFn(db, "core.fn_warehouse_material_list", base); + assertEquals(listedMats.ok, true); + const mats = (listedMats.data as { materials: Array<{ code: string; suppliers: Array<{ id: number }> }> }).materials; + const varilla = mats.find((m) => m.code === "VAR001"); + assertExists(varilla); + assertEquals(varilla.suppliers.length, 1); + + const closed = await callCoreFn(db, "core.fn_warehouse_close_project", { + project_id: fx.projectId, + transfer_remaining: true, + closed_by_name: "Tester", + close_notes: "Cierre de prueba", + }); + assertEquals(closed.ok, true); + const afterClose = await callCoreFn(db, "core.fn_warehouse_list", base); + const openProjectWh = ((afterClose.data as { warehouses: Array<{ kind: string; status: string }> }).warehouses) + .filter((w) => w.kind === "project" && w.status === "open"); + assertEquals(openProjectWh.length, 0); }); }); diff --git a/api/expense_catalogs_http.ts b/api/expense_catalogs_http.ts index 2d38c6c..f2d133c 100644 --- a/api/expense_catalogs_http.ts +++ b/api/expense_catalogs_http.ts @@ -14,8 +14,8 @@ function tid(c: { get: (k: "user") => AuthUser }): number { } export function registerExpenseCatalogRoutes(app: App) { - const view = [...requireCoreAuth, requireAnyPermission("expenses.view", "settings.view")] as const; - const write = [...requireCoreAuth, requireAnyPermission("expenses.create", "settings.update")] as const; + const view = [...requireCoreAuth, requireAnyPermission("expenses.view", "settings.view", "warehouse.view")] as const; + const write = [...requireCoreAuth, requireAnyPermission("expenses.create", "settings.update", "warehouse.update")] as const; app.get("/v1/suppliers", ...view, async (c) => { const db = c.get("db"); diff --git a/api/warehouse_http.ts b/api/warehouse_http.ts index 471e78c..b3e7e20 100644 --- a/api/warehouse_http.ts +++ b/api/warehouse_http.ts @@ -68,12 +68,15 @@ export function registerWarehouseRoutes(app: App) { app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { const projectId = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, projectId)) return; - const body = await c.req.json<{ force?: boolean; close_notes?: string }>().catch(() => ({} as { force?: boolean; close_notes?: string })); + const body = await c.req.json<{ force?: boolean; close_notes?: string; transfer_remaining?: boolean }>().catch( + () => ({} as { force?: boolean; close_notes?: string; transfer_remaining?: boolean }), + ); const db = c.get("db"); const a = actor(c); const env = await callCoreFn(db, "core.fn_warehouse_close_project", { project_id: projectId, force: body.force, + transfer_remaining: body.transfer_remaining, closed_by_name: a.name, close_notes: body.close_notes, }, { route: routeLabel(c) }); @@ -119,12 +122,32 @@ export function registerWarehouseRoutes(app: App) { if (!await denyUnlessWarehouseCentral(c)) return; } else if (!await denyUnlessProjectScope(c, wh.project_id)) return; const env = await callCoreFn(db, "core.fn_warehouse_movement_list", { + tenant_id: tid(c), warehouse_id: whId, material_id: c.req.query("material_id"), }, { route: routeLabel(c) }); return respondRpc(c, env); }); + app.get("/v1/warehouses/movements/global", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => { + if (!await denyUnlessWarehouseCentral(c)) return; + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_warehouse_movement_list", { + tenant_id: tid(c), + global: true, + material_id: c.req.query("material_id"), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.get("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => { + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_warehouse_material_list", { + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => { const body = await c.req.json>(); const db = c.get("db"); @@ -132,6 +155,17 @@ export function registerWarehouseRoutes(app: App) { return respondRpc(c, env); }); + app.patch("/v1/warehouse-materials/:id", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { + const body = await c.req.json>(); + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", { + ...body, + id: Number(c.req.param("id")), + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => { const whId = Number(c.req.param("id")); const db = c.get("db"); diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index 68c42c2..f57fa62 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -40,5 +40,6 @@ + diff --git a/db/core/changesets/035-warehouse-materials-catalog.sql b/db/core/changesets/035-warehouse-materials-catalog.sql new file mode 100644 index 0000000..5193cc4 --- /dev/null +++ b/db/core/changesets/035-warehouse-materials-catalog.sql @@ -0,0 +1,464 @@ +--liquibase formatted sql +-- PANELS · core · catálogo de materiales de almacén (unidades + proveedores) + +--changeset panel:core-035a-warehouse-materials-unit-id endDelimiter:; splitStatements:true +ALTER TABLE core.warehouse_materials + ADD COLUMN IF NOT EXISTS unit_id BIGINT REFERENCES core.measure_units(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_core_warehouse_materials_unit ON core.warehouse_materials(unit_id); + +--changeset panel:core-035b-warehouse-material-suppliers endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='warehouse_material_suppliers' +CREATE TABLE core.warehouse_material_suppliers ( + material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id) ON DELETE CASCADE, + supplier_id BIGINT NOT NULL REFERENCES core.suppliers(id) ON DELETE CASCADE, + PRIMARY KEY (material_id, supplier_id) +); +CREATE INDEX idx_core_wh_mat_suppliers_supplier ON core.warehouse_material_suppliers(supplier_id); +GRANT SELECT, INSERT, UPDATE, DELETE ON core.warehouse_material_suppliers TO panels_core_app; + +--changeset panel:core-035c-rls-warehouse-material-suppliers endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_material_suppliers' AND policyname='tenant_isolation' +ALTER TABLE core.warehouse_material_suppliers ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON core.warehouse_material_suppliers + USING (EXISTS ( + SELECT 1 FROM core.warehouse_materials m + WHERE m.id = material_id + AND m.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM core.warehouse_materials m + WHERE m.id = material_id + AND m.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer + )); + +--changeset panel:core-035d-fn-warehouse-material-list splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_warehouse_material_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_rows jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY lower(x.name), x.code), '[]'::jsonb) + INTO v_rows + FROM ( + SELECT + m.id, + m.code, + m.name, + m.unit, + m.unit_id, + COALESCE(u.label, m.unit) AS unit_label, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY lower(s.name)) + FROM warehouse_material_suppliers ms + JOIN suppliers s ON s.id = ms.supplier_id + WHERE ms.material_id = m.id + ), '[]'::jsonb) AS suppliers + FROM warehouse_materials m + LEFT JOIN measure_units u ON u.id = m.unit_id + WHERE m.tenant_id = v_tid + ) x; + RETURN core.rpc_ok( + jsonb_build_object('materials', v_rows, 'count', jsonb_array_length(v_rows)), + format('Catálogo de materiales del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_warehouse_material_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_warehouse_material_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-035e-fn-warehouse-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_id bigint := NULLIF(payload->>'id', '')::bigint; + 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_unit_id bigint := NULLIF(payload->>'unit_id', '')::bigint; + v_row jsonb; +BEGIN + IF v_unit_id IS NOT NULL THEN + SELECT code INTO v_unit FROM measure_units WHERE id = v_unit_id AND tenant_id = v_tid; + IF NOT FOUND THEN + RETURN core.rpc_err('VALIDATION', format('fn_warehouse_material_upsert: unidad id=%s no encontrada', v_unit_id), + jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'unit_id', v_unit_id)); + END IF; + END IF; + IF v_id IS NOT NULL THEN + IF NOT EXISTS (SELECT 1 FROM warehouse_materials WHERE id = v_id AND tenant_id = v_tid) THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_warehouse_material_upsert: material id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id)); + END IF; + IF v_code = '' THEN + SELECT code INTO v_code FROM warehouse_materials WHERE id = v_id; + END IF; + IF v_name = '' THEN + SELECT name INTO v_name FROM warehouse_materials WHERE id = v_id; + END IF; + UPDATE warehouse_materials + SET code = v_code, + name = v_name, + unit = CASE WHEN payload ? 'unit' OR payload ? 'unit_id' THEN v_unit ELSE unit END, + unit_id = CASE WHEN payload ? 'unit_id' THEN v_unit_id ELSE unit_id END + WHERE id = v_id AND tenant_id = v_tid; + ELSE + 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, unit_id) + VALUES (v_tid, v_code, v_name, v_unit, v_unit_id) + ON CONFLICT (tenant_id, code) DO UPDATE + SET name = EXCLUDED.name, + unit = EXCLUDED.unit, + unit_id = COALESCE(EXCLUDED.unit_id, warehouse_materials.unit_id) + RETURNING id INTO v_id; + END IF; + IF payload ? 'supplier_ids' THEN + DELETE FROM warehouse_material_suppliers WHERE material_id = v_id; + INSERT INTO warehouse_material_suppliers (material_id, supplier_id) + SELECT v_id, x.sid + FROM ( + SELECT DISTINCT NULLIF(elem #>> '{}', '')::bigint AS sid + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(payload->'supplier_ids') = 'array' + THEN payload->'supplier_ids' + ELSE '[]'::jsonb + END + ) elem + ) x + WHERE x.sid IS NOT NULL + AND EXISTS (SELECT 1 FROM suppliers s WHERE s.id = x.sid AND s.tenant_id = v_tid); + END IF; + SELECT to_jsonb(x) INTO v_row + FROM ( + SELECT + m.id, m.code, m.name, m.unit, m.unit_id, + COALESCE(u.label, m.unit) AS unit_label, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY lower(s.name)) + FROM warehouse_material_suppliers ms + JOIN suppliers s ON s.id = ms.supplier_id + WHERE ms.material_id = m.id + ), '[]'::jsonb) AS suppliers + FROM warehouse_materials m + LEFT JOIN measure_units u ON u.id = m.unit_id + WHERE m.id = v_id + ) x; + RETURN core.rpc_ok( + v_row, + format('Material %s (%s) registrado', v_code, v_name), + jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id) + ); +EXCEPTION WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', format('Ya existe un material con clave %s', v_code), + jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'code', v_code)); +WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_warehouse_material_upsert', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-035f-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; + v_consolidated jsonb := '[]'::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, + p.name AS project_name, + CASE WHEN w.kind = 'central' THEN 'Almacén global' + ELSE COALESCE(p.code || ' — ' || p.name, 'Obra') + END AS warehouse_label, + m.id AS material_id, + m.code AS material_code, + m.name AS material_name, + m.unit, + COALESCE(u.label, m.unit) AS unit_label, + 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 measure_units u ON u.id = m.unit_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; + SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_consolidated FROM ( + SELECT + m.id AS material_id, + m.code AS material_code, + m.name AS material_name, + m.unit, + COALESCE(u.label, m.unit) AS unit_label, + SUM(s.qty_on_hand) AS qty_on_hand, + CASE WHEN SUM(s.qty_on_hand) > 0 + THEN round(SUM(s.qty_on_hand * s.avg_unit_cost) / SUM(s.qty_on_hand), 4) + ELSE 0 + END AS avg_unit_cost, + round(SUM(s.qty_on_hand * s.avg_unit_cost), 2) AS stock_value, + COUNT(*) FILTER (WHERE s.qty_on_hand > 0)::integer AS warehouse_count + 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 measure_units u ON u.id = m.unit_id + WHERE w.tenant_id = v_tid AND w.status = 'open' AND s.qty_on_hand > 0 + GROUP BY m.id, m.code, m.name, m.unit, u.label + ) 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, + COALESCE(u.label, m.unit) AS unit_label, + CASE WHEN w.kind = 'central' THEN 'Almacén global' + ELSE COALESCE(p.code || ' — ' || p.name, 'Obra') + END AS warehouse_label, + w.kind, + w.project_id, + p.code AS project_code, + 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 measure_units u ON u.id = m.unit_id + LEFT JOIN projects p ON p.id = w.project_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', COALESCE(v_rows, '[]'::jsonb), 'consolidated', v_consolidated), + 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-035g-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_from record; + v_to record; + 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_from FROM warehouses WHERE id = v_from_id AND tenant_id = v_tid; + IF NOT FOUND OR v_from.status <> 'open' THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: el almacén origen no está abierto', + jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'from_warehouse_id', v_from_id)); + END IF; + SELECT * INTO v_to FROM warehouses WHERE id = v_to_id AND tenant_id = v_tid; + IF NOT FOUND OR v_to.status <> 'open' THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: el almacén destino no está abierto', + jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'to_warehouse_id', v_to_id)); + 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-035h-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_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_global boolean := COALESCE((payload->>'global')::boolean, false); + 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, + CASE WHEN w.kind = 'central' THEN 'Almacén global' + ELSE COALESCE(p.code || ' — ' || p.name, 'Obra') + END AS warehouse_label + FROM warehouse_movements m + JOIN warehouse_materials mat ON mat.id = m.material_id + JOIN warehouses w ON w.id = m.warehouse_id + LEFT JOIN projects p ON p.id = w.project_id + LEFT JOIN budget_items bi ON bi.id = m.budget_item_id + WHERE (v_tid <= 0 OR m.tenant_id = v_tid) + AND (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-035i-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_transfer_remaining boolean := COALESCE((payload->>'transfer_remaining')::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_central_id bigint; + v_stock_count integer; + v_line record; + v_xfer jsonb; +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; + IF v_transfer_remaining THEN + PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_wh.tenant_id)); + SELECT id INTO v_central_id FROM warehouses WHERE tenant_id = v_wh.tenant_id AND kind = 'central' LIMIT 1; + IF v_central_id IS NOT NULL THEN + FOR v_line IN + SELECT material_id, qty_on_hand + FROM warehouse_stock + WHERE warehouse_id = v_wh.id AND qty_on_hand > 0 + LOOP + v_xfer := core.fn_warehouse_transfer_create(jsonb_build_object( + 'tenant_id', v_wh.tenant_id, + 'from_warehouse_id', v_wh.id, + 'to_warehouse_id', v_central_id, + 'material_id', v_line.material_id, + 'quantity', v_line.qty_on_hand, + 'notes', 'Traspaso automático al cerrar almacén de obra', + 'created_by_name', v_closed_by + )); + IF NOT COALESCE((v_xfer->>'ok')::boolean, false) THEN + RETURN v_xfer; + END IF; + END LOOP; + END IF; + 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 al almacén global 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, + 'transferred_remaining', v_transfer_remaining + ), + 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-035j-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_warehouse_material_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_stock_list(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; diff --git a/web-panel/components/MaterialCatalogDialog.vue b/web-panel/components/MaterialCatalogDialog.vue new file mode 100644 index 0000000..68562b1 --- /dev/null +++ b/web-panel/components/MaterialCatalogDialog.vue @@ -0,0 +1,269 @@ + + + diff --git a/web-panel/pages/almacen.vue b/web-panel/pages/almacen.vue index 6f30a90..b3b8548 100644 --- a/web-panel/pages/almacen.vue +++ b/web-panel/pages/almacen.vue @@ -1,17 +1,55 @@