mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 12:43:18 +00:00
Pre-computed merge for change #16
This commit is contained in:
commit
4e09975eac
7 changed files with 1396 additions and 90 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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<Record<string, unknown>>();
|
||||
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<Record<string, unknown>>();
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -40,5 +40,6 @@
|
|||
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/035-warehouse-materials-catalog.sql" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
464
db/core/changesets/035-warehouse-materials-catalog.sql
Normal file
464
db/core/changesets/035-warehouse-materials-catalog.sql
Normal file
|
|
@ -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;
|
||||
269
web-panel/components/MaterialCatalogDialog.vue
Normal file
269
web-panel/components/MaterialCatalogDialog.vue
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
header="Catálogo de materiales"
|
||||
modal
|
||||
:style="{ width: '920px' }"
|
||||
:breakpoints="{ '960px': '95vw' }"
|
||||
>
|
||||
<DeskTableBar v-model="query" placeholder="Buscar clave, material o proveedor">
|
||||
<Chip :label="`${filtered.length} materiales`" icon="pi pi-box" />
|
||||
<Button label="Nuevo material" icon="pi pi-plus" size="small" @click="openNew" />
|
||||
</DeskTableBar>
|
||||
<DataTable
|
||||
:value="filtered"
|
||||
data-key="id"
|
||||
paginator
|
||||
:rows="12"
|
||||
scrollable
|
||||
scroll-height="22rem"
|
||||
empty-message="No hay materiales. Capture el catálogo para usarlo en entradas y traspasos."
|
||||
@row-click="(e: { data: Material }) => openEdit(e.data)"
|
||||
>
|
||||
<Column field="code" header="Clave" style="width: 8rem">
|
||||
<template #body="{ data }"><span class="identifier">{{ data.code }}</span></template>
|
||||
</Column>
|
||||
<Column field="name" header="Material" />
|
||||
<Column field="unit_label" header="Unidad" style="width: 8rem">
|
||||
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
|
||||
</Column>
|
||||
<Column header="Proveedores">
|
||||
<template #body="{ data }">{{ supplierNames(data) }}</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="showForm"
|
||||
:header="form.id ? 'Editar material' : 'Nuevo material'"
|
||||
modal
|
||||
:style="{ width: '640px' }"
|
||||
>
|
||||
<div class="form-grid form-grid-compact">
|
||||
<FormField label="Clave" required>
|
||||
<InputText v-model="form.code" class="w-full identifier-input" placeholder="CEM-001" />
|
||||
</FormField>
|
||||
<FormField label="Unidad" required>
|
||||
<div class="flex gap-2 align-items-center">
|
||||
<Select
|
||||
v-model="form.unit_id"
|
||||
:options="units"
|
||||
option-label="label"
|
||||
option-value="id"
|
||||
placeholder="Catálogo de unidades"
|
||||
filter
|
||||
class="w-full"
|
||||
/>
|
||||
<Button icon="pi pi-plus" outlined aria-label="Nueva unidad" title="Nueva unidad" @click="showUnitForm = true" />
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Nombre" required class="span-2">
|
||||
<InputText v-model="form.name" class="w-full" placeholder="Cemento gris 50 kg" />
|
||||
</FormField>
|
||||
<FormField label="Proveedores" class="span-2">
|
||||
<div class="flex gap-2 align-items-center">
|
||||
<MultiSelect
|
||||
v-model="form.supplier_ids"
|
||||
:options="supplierOptions"
|
||||
option-label="label"
|
||||
option-value="id"
|
||||
placeholder="Uno o varios proveedores"
|
||||
filter
|
||||
display="chip"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button icon="pi pi-plus" outlined aria-label="Nuevo proveedor" title="Nuevo proveedor" @click="showSupplierForm = true" />
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="showForm = false" />
|
||||
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="save" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showUnitForm" header="Nueva unidad" modal :style="{ width: '420px' }">
|
||||
<div class="form-grid form-grid-compact">
|
||||
<FormField label="Código" required>
|
||||
<InputText v-model="unitForm.code" class="w-full identifier-input" placeholder="bulto" />
|
||||
</FormField>
|
||||
<FormField label="Nombre" required>
|
||||
<InputText v-model="unitForm.label" class="w-full" placeholder="Bulto" />
|
||||
</FormField>
|
||||
</div>
|
||||
<Message v-if="unitError" severity="error" class="mt-3" :closable="false">{{ unitError }}</Message>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="showUnitForm = false" />
|
||||
<Button label="Guardar" icon="pi pi-check" @click="saveUnit" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<SupplierFormDialog v-model="showSupplierForm" @saved="onSupplierSaved" />
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FormField from "~/components/padron/FormField.vue";
|
||||
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
|
||||
import { displayErrorMessage } from "~/composables/api-response";
|
||||
|
||||
export type Material = {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
unit?: string;
|
||||
unit_id?: number | null;
|
||||
unit_label?: string;
|
||||
suppliers?: { id: number; name: string }[];
|
||||
};
|
||||
|
||||
type MeasureUnit = { id: number; code: string; label: string };
|
||||
|
||||
const visible = defineModel<boolean>({ default: false });
|
||||
const emit = defineEmits<{ saved: [material: Material] }>();
|
||||
|
||||
const { api } = useApi();
|
||||
const toast = useToast();
|
||||
|
||||
const query = ref("");
|
||||
const materials = ref<Material[]>([]);
|
||||
const units = ref<MeasureUnit[]>([]);
|
||||
const suppliers = ref<Supplier[]>([]);
|
||||
const showForm = ref(false);
|
||||
const showUnitForm = ref(false);
|
||||
const showSupplierForm = ref(false);
|
||||
const saving = ref(false);
|
||||
const formError = ref("");
|
||||
const unitError = ref("");
|
||||
const unitForm = reactive({ code: "", label: "" });
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
code: "",
|
||||
name: "",
|
||||
unit_id: null as number | null,
|
||||
supplier_ids: [] as number[],
|
||||
});
|
||||
|
||||
const supplierOptions = computed(() =>
|
||||
suppliers.value.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.rfc ? `${s.name} · ${s.rfc}` : s.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
return materials.value.filter((m) =>
|
||||
!q || [m.code, m.name, m.unit, m.unit_label, supplierNames(m)].join(" ").toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
function supplierNames(row: Material) {
|
||||
return (row.suppliers ?? []).map((s) => s.name).join(", ") || "—";
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.id = 0;
|
||||
form.code = "";
|
||||
form.name = "";
|
||||
form.unit_id = units.value[0]?.id ?? null;
|
||||
form.supplier_ids = [];
|
||||
formError.value = "";
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
resetForm();
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Material) {
|
||||
form.id = row.id;
|
||||
form.code = row.code;
|
||||
form.name = row.name;
|
||||
form.unit_id = row.unit_id ?? null;
|
||||
form.supplier_ids = (row.suppliers ?? []).map((s) => s.id);
|
||||
formError.value = "";
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const [mats, un, sup] = await Promise.all([
|
||||
api<{ materials?: Material[] }>("/v1/warehouse-materials"),
|
||||
api<{ units?: MeasureUnit[] }>("/v1/measure-units"),
|
||||
api<{ suppliers?: Supplier[] }>("/v1/suppliers?status=activo"),
|
||||
]);
|
||||
materials.value = mats.materials ?? [];
|
||||
units.value = un.units ?? [];
|
||||
suppliers.value = sup.suppliers ?? [];
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.code.trim() || !form.name.trim()) {
|
||||
formError.value = "Clave y nombre son obligatorios";
|
||||
return;
|
||||
}
|
||||
if (!form.unit_id) {
|
||||
formError.value = "Seleccione una unidad del catálogo";
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
formError.value = "";
|
||||
try {
|
||||
const payload = {
|
||||
code: form.code,
|
||||
name: form.name,
|
||||
unit_id: form.unit_id,
|
||||
supplier_ids: form.supplier_ids,
|
||||
};
|
||||
const r = form.id
|
||||
? await api<Material>(`/v1/warehouse-materials/${form.id}`, { method: "PATCH", json: payload })
|
||||
: await api<Material>("/v1/warehouse-materials", { method: "POST", json: payload });
|
||||
toast.add({ severity: "success", summary: "Material guardado", life: 2000 });
|
||||
showForm.value = false;
|
||||
await load();
|
||||
const saved = materials.value.find((m) => m.id === Number(r.id)) ?? r;
|
||||
emit("saved", saved);
|
||||
} catch (e) {
|
||||
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUnit() {
|
||||
unitError.value = "";
|
||||
if (!unitForm.code.trim()) {
|
||||
unitError.value = "El código es obligatorio";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api<{ unit?: MeasureUnit }>("/v1/measure-units", {
|
||||
method: "POST",
|
||||
json: { code: unitForm.code, label: unitForm.label },
|
||||
});
|
||||
const created = r.unit;
|
||||
if (created) {
|
||||
units.value = [...units.value.filter((u) => u.id !== created.id), created]
|
||||
.sort((a, b) => a.label.localeCompare(b.label, "es"));
|
||||
form.unit_id = created.id;
|
||||
}
|
||||
unitForm.code = "";
|
||||
unitForm.label = "";
|
||||
showUnitForm.value = false;
|
||||
} catch (e) {
|
||||
unitError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
||||
}
|
||||
}
|
||||
|
||||
function onSupplierSaved(supplier: Supplier) {
|
||||
suppliers.value = [...suppliers.value.filter((s) => s.id !== supplier.id), supplier]
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "es"));
|
||||
if (!form.supplier_ids.includes(supplier.id)) form.supplier_ids = [...form.supplier_ids, supplier.id];
|
||||
}
|
||||
|
||||
watch(visible, async (on) => {
|
||||
if (on) await load();
|
||||
});
|
||||
|
||||
defineExpose({ load, materials });
|
||||
</script>
|
||||
|
|
@ -1,17 +1,55 @@
|
|||
<template>
|
||||
<div class="almacen-page">
|
||||
<DeskTableBar v-model="tableQ" placeholder="Buscar material o obra">
|
||||
<Chip label="Almacén global" icon="pi pi-box" />
|
||||
<Chip :label="`${stockRows.length} líneas`" icon="pi pi-list" />
|
||||
<DeskTableBar v-model="tableQ" :placeholder="isGlobalView ? 'Buscar material o almacén' : 'Buscar material'">
|
||||
<Chip :label="currentLabel" icon="pi pi-box" />
|
||||
<Chip :label="`${visibleStock.length} líneas`" icon="pi pi-list" />
|
||||
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
|
||||
</DeskTableBar>
|
||||
|
||||
<TabView>
|
||||
<TabPanel header="Stock global">
|
||||
<DataTable :value="filteredStock" data-key="material_id" paginator :rows="25" scrollable scroll-height="flex">
|
||||
<Column field="project_code" header="Obra / Central" />
|
||||
<Column field="material_code" header="Clave" style="width: 8rem" />
|
||||
<TabPanel :header="isGlobalView ? 'Consolidado' : 'Stock'">
|
||||
<DataTable
|
||||
:value="visibleStock"
|
||||
data-key="row_key"
|
||||
paginator
|
||||
:rows="25"
|
||||
scrollable
|
||||
scroll-height="flex"
|
||||
empty-message="No hay existencias en este almacén."
|
||||
>
|
||||
<Column v-if="isGlobalView && stockMode === 'detail'" field="warehouse_label" header="Almacén" />
|
||||
<Column field="material_code" header="Clave" style="width: 8rem">
|
||||
<template #body="{ data }"><span class="identifier">{{ data.material_code }}</span></template>
|
||||
</Column>
|
||||
<Column field="material_name" header="Material" />
|
||||
<Column field="unit_label" header="Unidad" style="width: 8rem">
|
||||
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
|
||||
</Column>
|
||||
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
|
||||
<Column v-if="isGlobalView && stockMode === 'consolidated'" field="warehouse_count" header="Almacenes" style="width: 8rem" />
|
||||
<Column field="stock_value" header="Valor" style="width: 9rem">
|
||||
<template #body="{ data }">{{ money(data.stock_value) }}</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</TabPanel>
|
||||
<TabPanel v-if="isGlobalView" header="Por almacén">
|
||||
<DataTable
|
||||
:value="filteredDetail"
|
||||
data-key="row_key"
|
||||
paginator
|
||||
:rows="25"
|
||||
scrollable
|
||||
scroll-height="flex"
|
||||
empty-message="No hay existencias en almacenes abiertos."
|
||||
>
|
||||
<Column field="warehouse_label" header="Almacén" />
|
||||
<Column field="material_code" header="Clave" style="width: 8rem">
|
||||
<template #body="{ data }"><span class="identifier">{{ data.material_code }}</span></template>
|
||||
</Column>
|
||||
<Column field="material_name" header="Material" />
|
||||
<Column field="unit_label" header="Unidad" style="width: 8rem">
|
||||
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
|
||||
</Column>
|
||||
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
|
||||
<Column field="stock_value" header="Valor" style="width: 9rem">
|
||||
<template #body="{ data }">{{ money(data.stock_value) }}</template>
|
||||
|
|
@ -19,9 +57,12 @@
|
|||
</DataTable>
|
||||
</TabPanel>
|
||||
<TabPanel header="Kardex">
|
||||
<DataTable :value="movements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
|
||||
<DataTable :value="filteredMovements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
|
||||
<Column field="movement_date" header="Fecha" style="width: 7rem" />
|
||||
<Column field="movement_type" header="Tipo" style="width: 8rem" />
|
||||
<Column v-if="isGlobalView" field="warehouse_label" header="Almacén" />
|
||||
<Column header="Tipo" style="width: 9rem">
|
||||
<template #body="{ data }">{{ movementLabel(data.movement_type) }}</template>
|
||||
</Column>
|
||||
<Column field="material_name" header="Material" />
|
||||
<Column field="quantity" header="Cant." style="width: 7rem" />
|
||||
<Column field="total_cost" header="Importe" style="width: 9rem">
|
||||
|
|
@ -32,23 +73,82 @@
|
|||
</TabPanel>
|
||||
</TabView>
|
||||
|
||||
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '640px' }">
|
||||
<Dialog v-model:visible="showOpen" header="Abrir almacén de obra" modal :style="{ width: '520px' }">
|
||||
<p class="muted mb-3">
|
||||
No todas las obras tienen almacén. Elija el proyecto para abrirlo. El almacén global permanece disponible.
|
||||
</p>
|
||||
<FormField label="Proyecto" required>
|
||||
<Select
|
||||
v-model="openProjectId"
|
||||
:options="projectsWithoutWarehouse"
|
||||
option-label="label"
|
||||
option-value="id"
|
||||
filter
|
||||
placeholder="Seleccione una obra"
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<Message v-if="!projectsWithoutWarehouse.length" severity="info" class="mt-3" :closable="false">
|
||||
Todas las obras activas ya tienen almacén abierto.
|
||||
</Message>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="showOpen = false" />
|
||||
<Button label="Abrir almacén" icon="pi pi-folder-open" :disabled="!openProjectId" @click="openWarehouse" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showClose" header="Cerrar almacén de obra" modal :style="{ width: '560px' }">
|
||||
<p class="muted mb-3">
|
||||
El almacén global no se cierra. Al terminar la obra puede cerrar este almacén y devolver el stock restante.
|
||||
</p>
|
||||
<Message :closable="false" class="mb-3">{{ selectedWarehouse?.label }}</Message>
|
||||
<FormField label="Notas de cierre">
|
||||
<Textarea v-model="closeNotes" class="w-full" rows="2" auto-resize />
|
||||
</FormField>
|
||||
<div class="flex align-items-center gap-2 mt-3">
|
||||
<Checkbox v-model="transferRemaining" binary input-id="xfer-remaining" />
|
||||
<label for="xfer-remaining">Traspasar stock restante al almacén global</label>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="showClose = false" />
|
||||
<Button label="Cerrar almacén" icon="pi pi-lock" severity="danger" @click="closeWarehouse" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '680px' }">
|
||||
<div class="form-grid form-grid-compact">
|
||||
<FormField label="Almacén">
|
||||
<Select v-model="entryForm.warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
||||
<FormField label="Almacén destino" required>
|
||||
<Select v-model="entryForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Código material">
|
||||
<InputText v-model="entryForm.material_code" class="w-full identifier-input" />
|
||||
<FormField label="Fecha">
|
||||
<DatePicker v-model="entryDate" date-format="dd/mm/yy" show-icon class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Nombre material">
|
||||
<InputText v-model="entryForm.material_name" class="w-full" />
|
||||
<FormField label="Material" required class="span-2">
|
||||
<div class="flex gap-2 align-items-center">
|
||||
<Select
|
||||
v-model="entryForm.material_id"
|
||||
:options="materialSelectOptions"
|
||||
option-label="label"
|
||||
option-value="id"
|
||||
filter
|
||||
placeholder="Seleccione del catálogo"
|
||||
class="w-full"
|
||||
/>
|
||||
<Button icon="pi pi-plus" outlined aria-label="Catálogo" title="Catálogo de materiales" @click="showCatalog = true" />
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="Cantidad">
|
||||
<InputNumber v-model="entryForm.quantity" :min="0" class="w-full" />
|
||||
<FormField label="Unidad">
|
||||
<InputText :model-value="selectedEntryMaterial?.unit_label || selectedEntryMaterial?.unit || ''" class="w-full" readonly />
|
||||
</FormField>
|
||||
<FormField label="Cantidad" required>
|
||||
<InputNumber v-model="entryForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Costo unitario">
|
||||
<InputNumber v-model="entryForm.unit_cost" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||
</FormField>
|
||||
<FormField v-if="selectedEntrySuppliers" label="Proveedores del material" class="span-2">
|
||||
<small class="muted">{{ selectedEntrySuppliers }}</small>
|
||||
</FormField>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="showEntry = false" />
|
||||
|
|
@ -56,19 +156,27 @@
|
|||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showExit" header="Salida a obra / concepto" modal :style="{ width: '720px' }">
|
||||
<Dialog v-model:visible="showExit" header="Salida de material" modal :style="{ width: '720px' }">
|
||||
<div class="form-grid form-grid-compact">
|
||||
<FormField label="Almacén obra">
|
||||
<Select v-model="exitForm.warehouse_id" :options="projectWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||
<FormField label="Almacén" required>
|
||||
<Select v-model="exitForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Material">
|
||||
<Select v-model="exitForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
|
||||
<FormField label="Material" required>
|
||||
<Select
|
||||
v-model="exitForm.material_id"
|
||||
:options="stockMaterialOptions(exitForm.warehouse_id)"
|
||||
option-label="label"
|
||||
option-value="id"
|
||||
filter
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Cantidad">
|
||||
<InputNumber v-model="exitForm.quantity" :min="0" class="w-full" />
|
||||
<FormField label="Cantidad" required>
|
||||
<InputNumber v-model="exitForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Concepto presupuesto" class="span-2">
|
||||
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
|
||||
<InputText v-else disabled placeholder="Solo aplica en almacén de obra" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Destino físico" class="span-2">
|
||||
<InputText v-model="exitForm.destination_note" class="w-full" />
|
||||
|
|
@ -81,19 +189,29 @@
|
|||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:visible="showTransfer" header="Transferencia central ↔ obra" modal :style="{ width: '640px' }">
|
||||
<Dialog v-model:visible="showTransfer" header="Traspaso entre almacenes" modal :style="{ width: '680px' }">
|
||||
<div class="form-grid form-grid-compact">
|
||||
<FormField label="Desde">
|
||||
<Select v-model="transferForm.from_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
||||
<FormField label="Desde" required>
|
||||
<Select v-model="transferForm.from_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Hacia">
|
||||
<Select v-model="transferForm.to_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
||||
<FormField label="Hacia" required>
|
||||
<Select v-model="transferForm.to_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Material">
|
||||
<Select v-model="transferForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
|
||||
<FormField label="Material" required>
|
||||
<Select
|
||||
v-model="transferForm.material_id"
|
||||
:options="stockMaterialOptions(transferForm.from_warehouse_id)"
|
||||
option-label="label"
|
||||
option-value="id"
|
||||
filter
|
||||
class="w-full"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Cantidad">
|
||||
<InputNumber v-model="transferForm.quantity" :min="0" class="w-full" />
|
||||
<FormField label="Cantidad" required>
|
||||
<InputNumber v-model="transferForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
||||
</FormField>
|
||||
<FormField label="Notas" class="span-2">
|
||||
<InputText v-model="transferForm.notes" class="w-full" />
|
||||
</FormField>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
|
@ -101,89 +219,347 @@
|
|||
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<MaterialCatalogDialog v-model="showCatalog" @saved="onMaterialSaved" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FormField from "~/components/padron/FormField.vue";
|
||||
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
||||
import MaterialCatalogDialog, { type Material } from "~/components/MaterialCatalogDialog.vue";
|
||||
import { displayErrorMessage } from "~/composables/api-response";
|
||||
|
||||
type WarehouseRow = { id: number; kind: string; project_id?: number; project_code?: string; label: string };
|
||||
type StockRow = Record<string, unknown>;
|
||||
type WarehouseRow = {
|
||||
id: number;
|
||||
kind: string;
|
||||
status?: string;
|
||||
project_id?: number;
|
||||
project_code?: string;
|
||||
project_name?: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type StockRow = Record<string, unknown> & {
|
||||
row_key?: string;
|
||||
material_id?: number;
|
||||
material_code?: string;
|
||||
material_name?: string;
|
||||
warehouse_id?: number;
|
||||
warehouse_label?: string;
|
||||
qty_on_hand?: number;
|
||||
stock_value?: number;
|
||||
unit?: string;
|
||||
unit_label?: string;
|
||||
warehouse_count?: number;
|
||||
};
|
||||
|
||||
type MovementRow = Record<string, unknown>;
|
||||
type ProjectOption = { id: number; name: string; code?: string; status?: string; label: string };
|
||||
|
||||
const desk = useDesktop();
|
||||
const { api } = useApi();
|
||||
const toast = useToast();
|
||||
const perms = usePermissions();
|
||||
|
||||
const tableQ = ref("");
|
||||
const warehouses = ref<WarehouseRow[]>([]);
|
||||
const projects = ref<ProjectOption[]>([]);
|
||||
const materials = ref<Material[]>([]);
|
||||
const stockRows = ref<StockRow[]>([]);
|
||||
const consolidated = ref<StockRow[]>([]);
|
||||
const movements = ref<MovementRow[]>([]);
|
||||
const materialOptions = ref<{ id: number; label: string }[]>([]);
|
||||
const showEntry = ref(false);
|
||||
const showExit = ref(false);
|
||||
const showTransfer = ref(false);
|
||||
const showOpen = ref(false);
|
||||
const showClose = ref(false);
|
||||
const showCatalog = ref(false);
|
||||
const exitWarning = ref("");
|
||||
const openProjectId = ref<number | null>(null);
|
||||
const closeNotes = ref("");
|
||||
const transferRemaining = ref(true);
|
||||
|
||||
const entryForm = reactive({ warehouse_id: 0, material_code: "", material_name: "", quantity: 0, unit_cost: 0 });
|
||||
const exitForm = reactive({ warehouse_id: 0, material_id: 0, quantity: 0, budget_item_id: null as number | null, destination_note: "" });
|
||||
const transferForm = reactive({ from_warehouse_id: 0, to_warehouse_id: 0, material_id: 0, quantity: 0 });
|
||||
const entryForm = reactive({
|
||||
warehouse_id: 0,
|
||||
material_id: 0,
|
||||
quantity: 0,
|
||||
unit_cost: 0,
|
||||
movement_date: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
const exitForm = reactive({
|
||||
warehouse_id: 0,
|
||||
material_id: 0,
|
||||
quantity: 0,
|
||||
budget_item_id: null as number | null,
|
||||
destination_note: "",
|
||||
});
|
||||
const transferForm = reactive({
|
||||
from_warehouse_id: 0,
|
||||
to_warehouse_id: 0,
|
||||
material_id: 0,
|
||||
quantity: 0,
|
||||
notes: "",
|
||||
});
|
||||
|
||||
const projectWarehouses = computed(() => warehouses.value.filter((w) => w.kind === "project"));
|
||||
const exitProjectId = computed(() => projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0);
|
||||
const stockValue = computed(() => stockRows.value.reduce((s, r) => s + Number(r.stock_value || 0), 0));
|
||||
const filteredStock = computed(() => {
|
||||
const canUpdate = computed(() => perms.can("warehouse.update"));
|
||||
const canCreate = computed(() => perms.can("warehouse.create"));
|
||||
const openWarehouses = computed(() => warehouses.value.filter((w) => w.status !== "closed"));
|
||||
const projectWarehouses = computed(() => openWarehouses.value.filter((w) => w.kind === "project"));
|
||||
const centralWarehouse = computed(() => warehouses.value.find((w) => w.kind === "central") ?? null);
|
||||
const isGlobalView = computed(() => !desk.treeKey.value || desk.treeKey.value === "global");
|
||||
const selectedWarehouse = computed(() => {
|
||||
if (isGlobalView.value) return centralWarehouse.value;
|
||||
if (desk.treeKey.value.startsWith("w:")) {
|
||||
const id = Number(desk.treeKey.value.slice(2));
|
||||
return warehouses.value.find((w) => w.id === id) ?? null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const currentLabel = computed(() =>
|
||||
isGlobalView.value ? "Almacén global" : (selectedWarehouse.value?.label || "Almacén"),
|
||||
);
|
||||
const stockMode = computed(() => (isGlobalView.value ? "consolidated" : "detail"));
|
||||
const exitProjectId = computed(() =>
|
||||
projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0,
|
||||
);
|
||||
|
||||
const projectsWithoutWarehouse = computed(() => {
|
||||
const openIds = new Set(projectWarehouses.value.map((w) => w.project_id));
|
||||
return projects.value.filter((p) => !openIds.has(p.id));
|
||||
});
|
||||
|
||||
const materialSelectOptions = computed(() =>
|
||||
materials.value.map((m) => ({
|
||||
id: m.id,
|
||||
label: `${m.code} — ${m.name}`,
|
||||
})),
|
||||
);
|
||||
|
||||
const selectedEntryMaterial = computed(() =>
|
||||
materials.value.find((m) => m.id === entryForm.material_id) ?? null,
|
||||
);
|
||||
const selectedEntrySuppliers = computed(() =>
|
||||
(selectedEntryMaterial.value?.suppliers ?? []).map((s) => s.name).join(", "),
|
||||
);
|
||||
|
||||
const visibleStock = computed(() => {
|
||||
const rows = isGlobalView.value ? consolidated.value : stockRows.value;
|
||||
return filterStock(rows);
|
||||
});
|
||||
const filteredDetail = computed(() => filterStock(stockRows.value));
|
||||
const stockValue = computed(() =>
|
||||
visibleStock.value.reduce((s, r) => s + Number(r.stock_value || 0), 0),
|
||||
);
|
||||
const filteredMovements = computed(() => {
|
||||
const q = tableQ.value.toLowerCase();
|
||||
return stockRows.value.filter((r) =>
|
||||
!q || [r.material_name, r.material_code, r.project_code].join(" ").toLowerCase().includes(q),
|
||||
return movements.value.filter((r) =>
|
||||
!q || [r.material_name, r.material_code, r.warehouse_label, r.destination_note].join(" ").toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
function filterStock(rows: StockRow[]) {
|
||||
const q = tableQ.value.toLowerCase();
|
||||
return rows.filter((r) =>
|
||||
!q || [r.material_name, r.material_code, r.warehouse_label, r.project_code].join(" ").toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
function stockMaterialOptions(warehouseId: number) {
|
||||
const seen = new Map<number, string>();
|
||||
for (const row of stockRows.value) {
|
||||
if (warehouseId && Number(row.warehouse_id) !== warehouseId && row.warehouse_id != null) continue;
|
||||
const id = Number(row.material_id);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.set(id, `${row.material_code} — ${row.material_name}`);
|
||||
}
|
||||
if (!seen.size) {
|
||||
for (const m of materials.value) seen.set(m.id, `${m.code} — ${m.name}`);
|
||||
}
|
||||
return [...seen.entries()].map(([id, label]) => ({ id, label }));
|
||||
}
|
||||
|
||||
function money(v: number) {
|
||||
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||
}
|
||||
|
||||
async function loadWarehouses() {
|
||||
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} });
|
||||
const r = await api<{ data?: { warehouses?: Array<Record<string, unknown>> } }>("/v1/warehouses");
|
||||
const rows = (r.data?.warehouses ?? []) as Array<Record<string, unknown>>;
|
||||
warehouses.value = rows.map((w) => ({
|
||||
id: Number(w.id),
|
||||
kind: String(w.kind),
|
||||
project_id: w.project_id ? Number(w.project_id) : undefined,
|
||||
project_code: String(w.project_code ?? (w.kind === "central" ? "CENTRAL" : "")),
|
||||
label: w.kind === "central" ? "Almacén central" : `${w.project_code} — ${w.project_name ?? "Obra"}`,
|
||||
function movementLabel(type: unknown) {
|
||||
switch (String(type || "")) {
|
||||
case "entry": return "Entrada";
|
||||
case "exit": return "Salida";
|
||||
case "transfer_out": return "Traspaso salida";
|
||||
case "transfer_in": return "Traspaso entrada";
|
||||
case "adjustment": return "Ajuste";
|
||||
default: return String(type || "—");
|
||||
}
|
||||
}
|
||||
|
||||
function warehouseLabel(w: Record<string, unknown>) {
|
||||
if (w.kind === "central") return "Almacén global";
|
||||
const code = String(w.project_code || "");
|
||||
const name = String(w.project_name || "Obra");
|
||||
return code ? `${code} — ${name}` : name;
|
||||
}
|
||||
|
||||
function withRowKeys(rows: StockRow[], prefix: string) {
|
||||
return rows.map((r, i) => ({
|
||||
...r,
|
||||
row_key: `${prefix}-${r.warehouse_id ?? "g"}-${r.material_id ?? i}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadStock() {
|
||||
const r = await api<{ data?: { stock?: StockRow[] } }>("/v1/warehouses/stock/global");
|
||||
stockRows.value = r.data?.stock ?? [];
|
||||
const mats = new Map<number, string>();
|
||||
for (const row of stockRows.value) {
|
||||
mats.set(Number(row.material_id), `${row.material_code} — ${row.material_name}`);
|
||||
const entryDate = computed({
|
||||
get: () => {
|
||||
const [y, m, d] = entryForm.movement_date.split("-").map(Number);
|
||||
return y ? new Date(y, (m || 1) - 1, d || 1) : new Date();
|
||||
},
|
||||
set: (value: Date | null) => {
|
||||
if (!value) return;
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(value.getDate()).padStart(2, "0");
|
||||
entryForm.movement_date = `${y}-${m}-${d}`;
|
||||
},
|
||||
});
|
||||
|
||||
async function loadProjects() {
|
||||
const r = await api<{ projects?: Array<{ id: number; name: string; code?: string; status?: string }> }>(
|
||||
"/v1/projects?status=activo,pausado",
|
||||
);
|
||||
projects.value = (r.projects ?? []).map((p) => ({
|
||||
...p,
|
||||
label: p.code ? `${p.code} — ${p.name}` : p.name,
|
||||
}));
|
||||
}
|
||||
materialOptions.value = [...mats.entries()].map(([id, label]) => ({ id, label }));
|
||||
|
||||
async function loadMaterials() {
|
||||
try {
|
||||
const r = await api<{ materials?: Material[] }>("/v1/warehouse-materials");
|
||||
materials.value = r.materials ?? [];
|
||||
} catch {
|
||||
materials.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWarehouses() {
|
||||
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} }).catch(() => undefined);
|
||||
const r = await api<{ warehouses?: Array<Record<string, unknown>> }>("/v1/warehouses");
|
||||
const rows = r.warehouses ?? [];
|
||||
warehouses.value = rows.map((w) => ({
|
||||
id: Number(w.id),
|
||||
kind: String(w.kind),
|
||||
status: String(w.status ?? "open"),
|
||||
project_id: w.project_id ? Number(w.project_id) : undefined,
|
||||
project_code: String(w.project_code ?? ""),
|
||||
project_name: String(w.project_name ?? ""),
|
||||
label: warehouseLabel(w),
|
||||
}));
|
||||
}
|
||||
|
||||
function aggregateClient(rows: StockRow[]): StockRow[] {
|
||||
const map = new Map<number, StockRow>();
|
||||
for (const row of rows) {
|
||||
const id = Number(row.material_id);
|
||||
if (!id) continue;
|
||||
const cur = map.get(id);
|
||||
if (!cur) {
|
||||
map.set(id, { ...row, warehouse_id: undefined, warehouse_count: 1 });
|
||||
continue;
|
||||
}
|
||||
cur.qty_on_hand = Number(cur.qty_on_hand || 0) + Number(row.qty_on_hand || 0);
|
||||
cur.stock_value = Number(cur.stock_value || 0) + Number(row.stock_value || 0);
|
||||
cur.warehouse_count = Number(cur.warehouse_count || 1) + 1;
|
||||
}
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
async function loadStock() {
|
||||
if (isGlobalView.value) {
|
||||
const r = await api<{ stock?: StockRow[]; consolidated?: StockRow[] }>("/v1/warehouses/stock/global");
|
||||
const detail = r.stock ?? [];
|
||||
stockRows.value = withRowKeys(detail, "d");
|
||||
const cons = r.consolidated ?? [];
|
||||
consolidated.value = withRowKeys(cons.length ? cons : aggregateClient(detail), "c");
|
||||
return;
|
||||
}
|
||||
const id = selectedWarehouse.value?.id;
|
||||
if (!id) {
|
||||
stockRows.value = [];
|
||||
consolidated.value = [];
|
||||
return;
|
||||
}
|
||||
const r = await api<{ stock?: StockRow[] }>(`/v1/warehouses/${id}/stock`);
|
||||
stockRows.value = withRowKeys(r.stock ?? [], "w");
|
||||
consolidated.value = [];
|
||||
}
|
||||
|
||||
async function loadMovements() {
|
||||
const wh = warehouses.value[0]?.id;
|
||||
if (!wh) return;
|
||||
const r = await api<{ data?: { movements?: MovementRow[] } }>(`/v1/warehouses/${wh}/movements`);
|
||||
movements.value = r.data?.movements ?? [];
|
||||
if (isGlobalView.value) {
|
||||
const r = await api<{ movements?: MovementRow[] }>("/v1/warehouses/movements/global");
|
||||
movements.value = r.movements ?? [];
|
||||
return;
|
||||
}
|
||||
const id = selectedWarehouse.value?.id;
|
||||
if (!id) {
|
||||
movements.value = [];
|
||||
return;
|
||||
}
|
||||
const r = await api<{ movements?: MovementRow[] }>(`/v1/warehouses/${id}/movements`);
|
||||
movements.value = r.movements ?? [];
|
||||
}
|
||||
|
||||
function defaultWarehouseId() {
|
||||
return selectedWarehouse.value?.id || centralWarehouse.value?.id || 0;
|
||||
}
|
||||
|
||||
function openEntry() {
|
||||
entryForm.warehouse_id = defaultWarehouseId();
|
||||
entryForm.material_id = materials.value[0]?.id ?? 0;
|
||||
entryForm.quantity = 0;
|
||||
entryForm.unit_cost = 0;
|
||||
showEntry.value = true;
|
||||
}
|
||||
|
||||
function openExitDlg() {
|
||||
exitForm.warehouse_id = defaultWarehouseId();
|
||||
exitForm.material_id = 0;
|
||||
exitForm.quantity = 0;
|
||||
exitForm.budget_item_id = null;
|
||||
exitForm.destination_note = "";
|
||||
exitWarning.value = "";
|
||||
showExit.value = true;
|
||||
}
|
||||
|
||||
function openTransferDlg() {
|
||||
transferForm.from_warehouse_id = defaultWarehouseId();
|
||||
transferForm.to_warehouse_id = isGlobalView.value
|
||||
? (projectWarehouses.value[0]?.id ?? 0)
|
||||
: (centralWarehouse.value?.id ?? 0);
|
||||
transferForm.material_id = 0;
|
||||
transferForm.quantity = 0;
|
||||
transferForm.notes = "";
|
||||
showTransfer.value = true;
|
||||
}
|
||||
|
||||
async function saveEntry() {
|
||||
if (!entryForm.warehouse_id || !entryForm.material_id || entryForm.quantity <= 0) {
|
||||
toast.add({ severity: "warn", summary: "Seleccione almacén, material y una cantidad mayor a cero", life: 3000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
|
||||
method: "POST",
|
||||
json: entryForm,
|
||||
json: {
|
||||
warehouse_id: entryForm.warehouse_id,
|
||||
material_id: entryForm.material_id,
|
||||
quantity: entryForm.quantity,
|
||||
unit_cost: entryForm.unit_cost,
|
||||
movement_date: entryForm.movement_date,
|
||||
},
|
||||
});
|
||||
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
|
||||
showEntry.value = false;
|
||||
await loadStock();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||
}
|
||||
|
|
@ -191,14 +567,14 @@ async function saveEntry() {
|
|||
|
||||
async function saveExit() {
|
||||
try {
|
||||
const r = await api<{ data?: { budget_warning?: { message: string } }; message?: string }>(
|
||||
const r = await api<{ budget_warning?: { message: string }; message?: string }>(
|
||||
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
|
||||
{ method: "POST", json: exitForm },
|
||||
);
|
||||
exitWarning.value = r.data?.budget_warning?.message ?? "";
|
||||
exitWarning.value = r.budget_warning?.message ?? "";
|
||||
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
|
||||
if (!exitWarning.value) showExit.value = false;
|
||||
await loadStock();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||
}
|
||||
|
|
@ -207,29 +583,138 @@ async function saveExit() {
|
|||
async function saveTransfer() {
|
||||
try {
|
||||
const r = await api<{ message?: string }>("/v1/warehouse-transfers", { method: "POST", json: transferForm });
|
||||
toast.add({ severity: "success", summary: r.message || "Transferencia completada", life: 2000 });
|
||||
toast.add({ severity: "success", summary: r.message || "Traspaso completado", life: 2000 });
|
||||
showTransfer.value = false;
|
||||
await loadStock();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function openWarehouse() {
|
||||
if (!openProjectId.value) return;
|
||||
try {
|
||||
const r = await api<{ message?: string }>(`/v1/projects/${openProjectId.value}/warehouse/open`, {
|
||||
method: "POST",
|
||||
json: {},
|
||||
});
|
||||
toast.add({ severity: "success", summary: r.message || "Almacén abierto", life: 2200 });
|
||||
showOpen.value = false;
|
||||
const projectId = openProjectId.value;
|
||||
openProjectId.value = null;
|
||||
await loadWarehouses();
|
||||
const opened = warehouses.value.find((w) => w.project_id === projectId);
|
||||
desk.treeKey.value = opened ? `w:${opened.id}` : "global";
|
||||
await refresh();
|
||||
syncChrome();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function closeWarehouse() {
|
||||
const projectId = selectedWarehouse.value?.project_id;
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const r = await api<{ message?: string }>(`/v1/projects/${projectId}/warehouse/close`, {
|
||||
method: "POST",
|
||||
json: { close_notes: closeNotes.value, transfer_remaining: transferRemaining.value },
|
||||
});
|
||||
toast.add({ severity: "success", summary: r.message || "Almacén cerrado", life: 2200 });
|
||||
showClose.value = false;
|
||||
closeNotes.value = "";
|
||||
transferRemaining.value = true;
|
||||
desk.treeKey.value = "global";
|
||||
await loadWarehouses();
|
||||
await refresh();
|
||||
syncChrome();
|
||||
} catch (e) {
|
||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function onMaterialSaved(material: Material) {
|
||||
await loadMaterials();
|
||||
if (material?.id) entryForm.material_id = material.id;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([loadStock(), loadMovements(), loadMaterials()]);
|
||||
syncChrome();
|
||||
}
|
||||
|
||||
function syncChrome() {
|
||||
const projectNodes = projectWarehouses.value.map((w) => ({
|
||||
key: `w:${w.id}`,
|
||||
label: w.project_name || w.label,
|
||||
icon: "pi pi-building",
|
||||
}));
|
||||
desk.configure({
|
||||
tab: "Almacén",
|
||||
ribbon: [{
|
||||
treeTitle: "Almacenes",
|
||||
showTree: true,
|
||||
showDetail: false,
|
||||
treeKey: desk.treeKey.value || "global",
|
||||
countLabel: `${visibleStock.value.length} líneas`,
|
||||
tree: [
|
||||
{ key: "global", label: "Almacén global", icon: "pi pi-box", data: { count: consolidated.value.length || stockRows.value.length } },
|
||||
...projectNodes,
|
||||
],
|
||||
onTreeSelect: () => {
|
||||
void refresh();
|
||||
},
|
||||
ribbon: [
|
||||
{
|
||||
key: "almacenes",
|
||||
label: "Almacenes",
|
||||
items: [
|
||||
{
|
||||
key: "open",
|
||||
label: "Abrir almacén",
|
||||
icon: "pi pi-folder-open",
|
||||
disabled: !canUpdate.value,
|
||||
command: () => {
|
||||
openProjectId.value = projectsWithoutWarehouse.value[0]?.id ?? null;
|
||||
showOpen.value = true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "close",
|
||||
label: "Cerrar almacén",
|
||||
icon: "pi pi-lock",
|
||||
disabled: !canUpdate.value || isGlobalView.value || selectedWarehouse.value?.kind !== "project",
|
||||
title: isGlobalView.value
|
||||
? "El almacén global no se cierra"
|
||||
: "Cerrar el almacén de la obra seleccionada",
|
||||
command: () => { showClose.value = true; },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "wh",
|
||||
label: "Movimientos",
|
||||
items: [
|
||||
{ key: "entry", label: "Entrada", icon: "pi pi-download", command: () => { showEntry.value = true; } },
|
||||
{ key: "exit", label: "Salida", icon: "pi pi-upload", command: () => { showExit.value = true; } },
|
||||
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", command: () => { showTransfer.value = true; } },
|
||||
{ key: "entry", label: "Entrada", icon: "pi pi-download", disabled: !canCreate.value, command: openEntry },
|
||||
{ key: "exit", label: "Salida", icon: "pi pi-upload", disabled: !canUpdate.value, command: openExitDlg },
|
||||
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", disabled: !canUpdate.value, command: openTransferDlg },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cat",
|
||||
label: "Catálogo",
|
||||
items: [
|
||||
{ key: "materials", label: "Materiales", icon: "pi pi-book", command: () => { showCatalog.value = true; } },
|
||||
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: () => { void refresh(); } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
await loadWarehouses();
|
||||
await loadStock();
|
||||
await loadMovements();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
desk.treeKey.value = "global";
|
||||
syncChrome();
|
||||
await Promise.all([loadProjects(), loadWarehouses(), loadMaterials()]);
|
||||
await refresh();
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue