Pre-computed merge for change #3

This commit is contained in:
Cursor Origin 1970-01-01 00:00:00 +00:00
commit 23720997fc
30 changed files with 3322 additions and 0 deletions

53
api/cost_control_http.ts Normal file
View file

@ -0,0 +1,53 @@
import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission } from "./permissions.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantScope(c.get("user")) ?? 0;
}
export function registerCostControlRoutes(app: App) {
app.get("/v1/projects/:id/cost-control/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_project_summary", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/items", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_by_item", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/chapters", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_by_chapter", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/projects/:id/cost-control/deviations", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_control_top_deviations", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
limit: c.req.query("limit"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
}

146
api/expenses_http.ts Normal file
View file

@ -0,0 +1,146 @@
import type { Context, Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts";
import { encryptBytes, decryptBytes } from "./docs_crypto.ts";
import { expenseDocKey, getObject, putObject, s3Configured } from "./storage.ts";
import { randomBytes } from "node:crypto";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantScope(c.get("user")) ?? 0;
}
function actor(c: Context): { id: number; name: string } {
const u = c.get("user");
return { id: u.id, name: u.display_name };
}
export function registerExpenseRoutes(app: App) {
app.get("/v1/expenses", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_list", {
tenant_id: tid(c),
project_id: c.req.query("project_id"),
company_id: c.req.query("company_id"),
date_from: c.req.query("date_from"),
date_to: c.req.query("date_to"),
budget_item_id: c.req.query("budget_item_id"),
source: c.req.query("source"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/expenses/:id", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/expenses", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_expense_create", {
...body,
tenant_id: tid(c),
created_by_id: a.id,
created_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_update", {
...body,
id: Number(c.req.param("id")),
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_expense_void", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
const expenseId = Number(c.req.param("id"));
const form = await c.req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
const env = await callCoreFn(c.get("db"), "core.fn_expense_attachment_add", {
expense_id: expenseId,
...JSON.parse(String(form.get("meta") ?? "{}")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
}
const bytes = new Uint8Array(await file.arrayBuffer());
const { iv, cipher } = await encryptBytes(bytes);
const storageName = `${Date.now()}-${randomBytes(8).toString("hex")}`;
if (s3Configured()) {
await putObject(expenseDocKey(expenseId, storageName), cipher);
}
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_expense_attachment_add", {
expense_id: expenseId,
original_name: file.name,
mime: file.type || "application/pdf",
size_bytes: bytes.length,
sha256: "",
iv,
storage_name: storageName,
uploaded_by_id: a.id,
uploaded_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const expenseId = Number(c.req.param("id"));
const attId = Number(c.req.param("attId"));
const db = c.get("db");
const row = await db.prepare(
"SELECT storage_name, iv, mime, original_name FROM expense_attachments WHERE id = ? AND expense_id = ?",
).get(attId, expenseId) as { storage_name: string; iv: string; mime: string; original_name: string } | undefined;
if (!row) return respondRpc(c, { ok: false, code: "NOT_FOUND", layer: "api", message: "Comprobante no encontrado", data: null, errors: null });
const enc = await getObject(expenseDocKey(expenseId, row.storage_name));
const plain = await decryptBytes(row.iv, enc);
c.header("Content-Type", row.mime);
c.header("Content-Disposition", `inline; filename="${row.original_name}"`);
return c.body(plain.buffer as ArrayBuffer);
});
app.get("/v1/iva/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_iva_period_summary", {
tenant_id: tid(c),
company_id: c.req.query("company_id"),
year: c.req.query("year"),
month: c.req.query("month"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/cost-settings", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_cost_settings"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_settings_get", { tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("manage_cost_settings"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_cost_settings_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
}

10
api/expenses_test.ts Normal file
View file

@ -0,0 +1,10 @@
import { assertEquals } from "jsr:@std/assert";
import { mapRpcToStatus } from "./http_errors.ts";
Deno.test("mapRpcToStatus CREATED", () => {
assertEquals(mapRpcToStatus("CREATED"), 201);
});
Deno.test("mapRpcToStatus FORBIDDEN", () => {
assertEquals(mapRpcToStatus("FORBIDDEN"), 403);
});

65
api/iam_http.ts Normal file
View file

@ -0,0 +1,65 @@
import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { requireAuth, tenantScope } from "./auth.ts";
import type { Db } from "./db.ts";
import { respondRpc } from "./http_errors.ts";
import { requirePermission, callIamFn } from "./permissions.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
export function registerIamRoutes(app: App) {
app.get("/v1/iam/users", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_user_list", { tenant_id: tid });
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar usuarios IAM", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/iam/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_permission_list", {});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos IAM", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_role_permissions_get", { role_code: c.req.param("role") });
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos del rol", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.put("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
const body = await c.req.json<{ permissions?: string[] }>();
const tid = tenantScope(c.get("user"));
const row = await callIamFn(tid, "iam.fn_role_permissions_save", {
role_code: c.req.param("role"),
permissions: body.permissions ?? [],
});
if (!row) {
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo guardar permisos del rol", data: null, errors: null });
}
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
});
app.get("/v1/me/permissions", requireAuth, async (c) => {
const user = c.get("user");
const { userPermissions } = await import("./permissions.ts");
const role = user.role === "tenant_admin" ? "tenant_admin" : "user";
const perms = await userPermissions(user.tenant_id, role);
return respondRpc(c, {
ok: true,
code: "OK",
layer: "api",
message: `Permisos del usuario ${user.username}`,
data: { permissions: [...perms], role: user.role },
errors: null,
});
});
}

View file

@ -56,6 +56,10 @@ import {
} from "./mx.ts"; } from "./mx.ts";
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts"; import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
import { registerPayrollRoutes } from "./payroll_http.ts"; import { registerPayrollRoutes } from "./payroll_http.ts";
import { registerExpenseRoutes } from "./expenses_http.ts";
import { registerWarehouseRoutes } from "./warehouse_http.ts";
import { registerCostControlRoutes } from "./cost_control_http.ts";
import { registerIamRoutes } from "./iam_http.ts";
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts"; import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
import { import {
buildBudgetTemplate, buildBudgetTemplate,
@ -1340,6 +1344,10 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) =
}); });
registerPayrollRoutes(app); registerPayrollRoutes(app);
registerExpenseRoutes(app);
registerWarehouseRoutes(app);
registerCostControlRoutes(app);
registerIamRoutes(app);
const port = config.port; const port = config.port;

101
api/permissions.ts Normal file
View file

@ -0,0 +1,101 @@
import type { Context, Next } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { withIamTenant } from "./iam_db.ts";
import { respondApiError, routeLabel } from "./http_errors.ts";
const permissionCache = new Map<string, { perms: Set<string>; at: number }>();
const CACHE_TTL_MS = 60_000;
export async function userPermissions(
tenantId: number | null,
roleCode: string,
): Promise<Set<string>> {
const key = `${tenantId ?? 0}:${roleCode}`;
const hit = permissionCache.get(key);
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.perms;
if (roleCode === "tenant_admin") {
const all = new Set([
"view_expenses", "manage_expenses", "view_warehouse", "manage_warehouse",
"close_warehouse", "manage_cost_settings", "manage_users", "manage_budget",
"manage_payroll", "manage_workers", "manage_projects", "manage_companies",
"manage_documents", "manage_settings", "view_reports",
]);
permissionCache.set(key, { perms: all, at: Date.now() });
return all;
}
const rows = await withIamTenant(tenantId, async (db) =>
await db.prepare(
"SELECT permission_code FROM role_permissions WHERE role_code = ?",
).all(roleCode) as { permission_code: string }[],
);
const perms = new Set(rows.map((r) => r.permission_code));
permissionCache.set(key, { perms, at: Date.now() });
return perms;
}
export async function hasPermission(
user: AuthUser,
code: string,
): Promise<boolean> {
if (user.role === "tenant_admin") return true;
const role = user.role === "user" ? "user" : user.role;
const perms = await userPermissions(user.tenant_id, role);
return perms.has(code);
}
export function requirePermission(code: string) {
return async (c: Context, next: Next) => {
const user = c.get("user") as AuthUser;
if (user.realm === "platform") {
await next();
return;
}
if (!await hasPermission(user, code)) {
return respondApiError(
c,
"FORBIDDEN",
`Permiso requerido: ${code}`,
{ route: routeLabel(c), permission: code, role: user.role },
);
}
await next();
};
}
export function requireAnyPermission(...codes: string[]) {
return async (c: Context, next: Next) => {
const user = c.get("user") as AuthUser;
if (user.realm === "platform") {
await next();
return;
}
for (const code of codes) {
if (await hasPermission(user, code)) {
await next();
return;
}
}
return respondApiError(
c,
"FORBIDDEN",
`Se requiere alguno de: ${codes.join(", ")}`,
{ route: routeLabel(c), permissions: codes },
);
};
}
export async function callIamFn<T = unknown>(
tenantId: number | null,
fn: string,
payload: Record<string, unknown> = {},
): Promise<T | null> {
const row = await withIamTenant(tenantId, async (db) =>
await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get(payload) as { result: unknown },
);
const raw = row?.result;
if (raw && typeof raw === "object" && "ok" in (raw as object)) {
return raw as T;
}
return null;
}

View file

@ -175,6 +175,9 @@ export function badgeJobPdfKey(jobId: number): string {
export function loanReceiptPdfKey(loanId: number, weekId: number): string { export function loanReceiptPdfKey(loanId: number, weekId: number): string {
return `pdfs/prestamo-${loanId}-semana-${weekId}.pdf`; return `pdfs/prestamo-${loanId}-semana-${weekId}.pdf`;
} }
export function expenseDocKey(expenseId: number, storageName: string): string {
return `expenses/${expenseId}/${storageName}`;
}
export function projectLogoKey(projectId: number, side: "left" | "right", ext: string): string { export function projectLogoKey(projectId: number, side: "left" | "right", ext: string): string {
return `logos/${projectId}-${side}.${ext}`; return `logos/${projectId}-${side}.${ext}`;
} }

133
api/warehouse_http.ts Normal file
View file

@ -0,0 +1,133 @@
import type { Context, Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import type { Db } from "./db.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
import { requireAnyPermission, requirePermission } from "./permissions.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantScope(c.get("user")) ?? 0;
}
function actor(c: Context): { id: number; name: string } {
const u = c.get("user");
return { id: u.id, name: u.display_name };
}
export function registerWarehouseRoutes(app: App) {
app.get("/v1/warehouses", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_list", {
tenant_id: tid(c),
include_closed: c.req.query("include_closed") === "true",
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_ensure_central", { tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/projects/:id/warehouse/open", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_open_project", {
tenant_id: tid(c),
project_id: Number(c.req.param("id")),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("close_warehouse"), async (c) => {
const body = await c.req.json<{ force?: boolean; close_notes?: string }>().catch(() => ({} as { force?: boolean; close_notes?: string }));
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_close_project", {
project_id: Number(c.req.param("id")),
force: body.force,
closed_by_name: a.name,
close_notes: body.close_notes,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/warehouses/:id/stock", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")),
global: false,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/warehouses/stock/global", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_stock_list", {
tenant_id: tid(c),
global: true,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/warehouses/:id/movements", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_warehouse_movement_list", {
warehouse_id: Number(c.req.param("id")),
material_id: c.req.query("material_id"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("manage_warehouse"), 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, tenant_id: tid(c) }, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_entry_create", {
...body,
tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")),
created_by_id: a.id,
created_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_exit_create", {
...body,
tenant_id: tid(c),
warehouse_id: Number(c.req.param("id")),
created_by_id: a.id,
created_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const a = actor(c);
const env = await callCoreFn(db, "core.fn_warehouse_transfer_create", {
...body,
tenant_id: tid(c),
created_by_name: a.name,
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
}

View file

@ -24,5 +24,14 @@
<include file="changesets/015-rpc-documents.sql" relativeToChangelogFile="true"/> <include file="changesets/015-rpc-documents.sql" relativeToChangelogFile="true"/>
<include file="changesets/016-rpc-worker-needs-badge.sql" relativeToChangelogFile="true"/> <include file="changesets/016-rpc-worker-needs-badge.sql" relativeToChangelogFile="true"/>
<include file="changesets/017-rpc-worker-normalize-badge.sql" relativeToChangelogFile="true"/> <include file="changesets/017-rpc-worker-normalize-badge.sql" relativeToChangelogFile="true"/>
<include file="changesets/018-schema-expenses-cost.sql" relativeToChangelogFile="true"/>
<include file="changesets/019-schema-warehouse.sql" relativeToChangelogFile="true"/>
<include file="changesets/020-rls-expenses-warehouse.sql" relativeToChangelogFile="true"/>
<include file="changesets/021-cost-control-helpers.sql" relativeToChangelogFile="true"/>
<include file="changesets/022-rpc-expenses.sql" relativeToChangelogFile="true"/>
<include file="changesets/023-rpc-warehouse.sql" relativeToChangelogFile="true"/>
<include file="changesets/024-rpc-cost-control.sql" relativeToChangelogFile="true"/>
<include file="changesets/025-rpc-payroll-cost-sync.sql" relativeToChangelogFile="true"/>
<include file="changesets/026-rpc-project-update-warehouse.sql" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -0,0 +1,103 @@
--liquibase formatted sql
-- PANELS · core · gastos, IVA y configuración de control de costos
--changeset panel:core-018a-tenant-cost-settings 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='tenant_cost_settings'
CREATE TABLE core.tenant_cost_settings (
tenant_id INTEGER PRIMARY KEY,
budget_warn_pct NUMERIC(5,2) NOT NULL DEFAULT 85,
budget_critical_pct NUMERIC(5,2) NOT NULL DEFAULT 100,
default_iva_rate NUMERIC(5,4) NOT NULL DEFAULT 0.16,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-018b-expense-entries 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='expense_entries'
CREATE TABLE core.expense_entries (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
company_id BIGINT NOT NULL REFERENCES core.companies(id),
project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
expense_date DATE NOT NULL,
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'payroll', 'destajo')),
source_ref_id BIGINT,
description TEXT NOT NULL DEFAULT '',
supplier_name TEXT NOT NULL DEFAULT '',
supplier_rfc TEXT NOT NULL DEFAULT '',
budget_chapter_id BIGINT REFERENCES core.budget_chapters(id) ON DELETE SET NULL,
budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL,
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT '',
unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
subtotal NUMERIC(14,2) NOT NULL DEFAULT 0,
iva_rate NUMERIC(5,4) NOT NULL DEFAULT 0.16,
iva_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
total NUMERIC(14,2) NOT NULL DEFAULT 0,
tax_included BOOLEAN NOT NULL DEFAULT false,
is_deductible BOOLEAN NOT NULL DEFAULT true,
counts_toward_budget BOOLEAN NOT NULL DEFAULT true,
is_locked BOOLEAN NOT NULL DEFAULT false,
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('draft', 'confirmed', 'void')),
created_by_id INTEGER,
created_by_name TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_core_expense_entries_tenant ON core.expense_entries(tenant_id);
CREATE INDEX idx_core_expense_entries_project_date ON core.expense_entries(project_id, expense_date);
CREATE INDEX idx_core_expense_entries_budget_item ON core.expense_entries(budget_item_id);
CREATE UNIQUE INDEX idx_core_expense_entries_source_ref
ON core.expense_entries(source, source_ref_id)
WHERE source_ref_id IS NOT NULL AND status <> 'void';
--changeset panel:core-018c-expense-attachments 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='expense_attachments'
CREATE TABLE core.expense_attachments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
expense_id BIGINT NOT NULL REFERENCES core.expense_entries(id) ON DELETE CASCADE,
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
uploaded_by_id INTEGER,
uploaded_by_name TEXT NOT NULL DEFAULT '',
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
--changeset panel:core-018d-iva-period-manual 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='iva_period_entries'
CREATE TABLE core.iva_period_entries (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
company_id BIGINT NOT NULL REFERENCES core.companies(id),
period_year INTEGER NOT NULL,
period_month INTEGER NOT NULL CHECK (period_month BETWEEN 1 AND 12),
iva_trasladado NUMERIC(14,2) NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
updated_by_name TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, company_id, period_year, period_month)
);
CREATE INDEX idx_core_iva_period_tenant ON core.iva_period_entries(tenant_id);
--changeset panel:core-018e-destajo-budget-item endDelimiter:; splitStatements:true
ALTER TABLE core.destajo_jobs
ADD COLUMN IF NOT EXISTS budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL;
--changeset panel:core-018f-warehouse-material-budget-links 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_budget_links'
CREATE TABLE core.warehouse_material_budget_links (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
material_id BIGINT NOT NULL,
budget_item_id BIGINT NOT NULL REFERENCES core.budget_items(id) ON DELETE CASCADE,
conversion_factor NUMERIC(14,6) NOT NULL DEFAULT 1,
UNIQUE (material_id, budget_item_id)
);

View file

@ -0,0 +1,94 @@
--liquibase formatted sql
-- PANELS · core · almacén central y por obra
--changeset panel:core-019a-warehouse-materials 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_materials'
CREATE TABLE core.warehouse_materials (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
code TEXT NOT NULL,
name TEXT NOT NULL,
unit TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, code)
);
CREATE INDEX idx_core_warehouse_materials_tenant ON core.warehouse_materials(tenant_id);
--changeset panel:core-019b-warehouses 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='warehouses'
CREATE TABLE core.warehouses (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('central', 'project')),
project_id BIGINT REFERENCES core.projects(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closing', 'closed')),
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
closed_at TIMESTAMPTZ,
closed_by_name TEXT NOT NULL DEFAULT '',
close_notes TEXT NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX idx_core_warehouses_central ON core.warehouses(tenant_id) WHERE kind = 'central';
CREATE UNIQUE INDEX idx_core_warehouses_project ON core.warehouses(project_id) WHERE kind = 'project' AND project_id IS NOT NULL;
CREATE INDEX idx_core_warehouses_tenant ON core.warehouses(tenant_id);
--changeset panel:core-019c-warehouse-stock 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_stock'
CREATE TABLE core.warehouse_stock (
warehouse_id BIGINT NOT NULL REFERENCES core.warehouses(id) ON DELETE CASCADE,
material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id) ON DELETE CASCADE,
qty_on_hand NUMERIC(14,4) NOT NULL DEFAULT 0,
avg_unit_cost NUMERIC(14,4) NOT NULL DEFAULT 0,
PRIMARY KEY (warehouse_id, material_id)
);
--changeset panel:core-019d-warehouse-transfers 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_transfers'
CREATE TABLE core.warehouse_transfers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
from_warehouse_id BIGINT NOT NULL REFERENCES core.warehouses(id),
to_warehouse_id BIGINT NOT NULL REFERENCES core.warehouses(id),
material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id),
quantity NUMERIC(14,4) NOT NULL,
unit_cost NUMERIC(14,4) NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'completed' CHECK (status IN ('pending', 'completed', 'cancelled')),
transfer_date DATE NOT NULL DEFAULT CURRENT_DATE,
notes TEXT NOT NULL DEFAULT '',
created_by_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_core_warehouse_transfers_tenant ON core.warehouse_transfers(tenant_id);
--changeset panel:core-019e-warehouse-movements 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_movements'
CREATE TABLE core.warehouse_movements (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
warehouse_id BIGINT NOT NULL REFERENCES core.warehouses(id),
material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id),
movement_type TEXT NOT NULL CHECK (movement_type IN ('entry', 'exit', 'transfer_out', 'transfer_in', 'adjustment')),
transfer_id BIGINT REFERENCES core.warehouse_transfers(id) ON DELETE SET NULL,
movement_date DATE NOT NULL DEFAULT CURRENT_DATE,
quantity NUMERIC(14,4) NOT NULL,
unit_cost NUMERIC(14,4) NOT NULL DEFAULT 0,
total_cost NUMERIC(14,2) NOT NULL DEFAULT 0,
budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL,
destination_note TEXT NOT NULL DEFAULT '',
expense_id BIGINT REFERENCES core.expense_entries(id) ON DELETE SET NULL,
adjustment_reason TEXT NOT NULL DEFAULT '',
created_by_id INTEGER,
created_by_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_core_warehouse_movements_wh ON core.warehouse_movements(warehouse_id, movement_date);
CREATE INDEX idx_core_warehouse_movements_budget ON core.warehouse_movements(budget_item_id);
--changeset panel:core-019f-warehouse-material-links-fk endDelimiter:; splitStatements:true
ALTER TABLE core.warehouse_material_budget_links
ADD CONSTRAINT warehouse_material_budget_links_material_fk
FOREIGN KEY (material_id) REFERENCES core.warehouse_materials(id) ON DELETE CASCADE;

View file

@ -0,0 +1,50 @@
--liquibase formatted sql
-- PANELS · core · RLS gastos y almacén
--changeset panel:core-020a-rls-expense-entries endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='expense_entries' AND policyname='tenant_isolation'
ALTER TABLE core.expense_entries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.expense_entries
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-020b-rls-iva-period endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='iva_period_entries' AND policyname='tenant_isolation'
ALTER TABLE core.iva_period_entries ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.iva_period_entries
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-020c-rls-warehouse-materials endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_materials' AND policyname='tenant_isolation'
ALTER TABLE core.warehouse_materials ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.warehouse_materials
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-020d-rls-warehouses endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouses' AND policyname='tenant_isolation'
ALTER TABLE core.warehouses ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.warehouses
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-020e-rls-warehouse-transfers endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_transfers' AND policyname='tenant_isolation'
ALTER TABLE core.warehouse_transfers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.warehouse_transfers
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-020f-rls-warehouse-movements endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_movements' AND policyname='tenant_isolation'
ALTER TABLE core.warehouse_movements ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.warehouse_movements
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);

View file

@ -0,0 +1,180 @@
--liquibase formatted sql
-- PANELS · core · helpers de control de costos (compartidos por gastos/almacén)
--changeset panel:core-021a-cost-tenant-id splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_tenant_id(payload jsonb)
RETURNS integer
LANGUAGE sql
STABLE
AS $$
SELECT COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer,
0
);
$$;
--changeset panel:core-021b-cost-settings splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_settings(p_tenant_id integer)
RETURNS TABLE (
budget_warn_pct numeric,
budget_critical_pct numeric,
default_iva_rate numeric
)
LANGUAGE plpgsql
STABLE
SET search_path = core
AS $$
BEGIN
RETURN QUERY
SELECT
COALESCE(s.budget_warn_pct, 85::numeric),
COALESCE(s.budget_critical_pct, 100::numeric),
COALESCE(s.default_iva_rate, 0.16::numeric)
FROM (
SELECT budget_warn_pct, budget_critical_pct, default_iva_rate
FROM tenant_cost_settings
WHERE tenant_id = p_tenant_id
) s
UNION ALL
SELECT 85::numeric, 100::numeric, 0.16::numeric
WHERE NOT EXISTS (SELECT 1 FROM tenant_cost_settings WHERE tenant_id = p_tenant_id)
LIMIT 1;
END;
$$;
--changeset panel:core-021c-cost-executed-amount splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_executed_amount(p_budget_item_id bigint)
RETURNS numeric
LANGUAGE sql
STABLE
SET search_path = core
AS $$
SELECT round(COALESCE(
(SELECT SUM(e.subtotal) FROM expense_entries e
WHERE e.budget_item_id = p_budget_item_id
AND e.status = 'confirmed'
AND e.counts_toward_budget = true),
0
) + COALESCE(
(SELECT SUM(m.total_cost) FROM warehouse_movements m
WHERE m.budget_item_id = p_budget_item_id
AND m.movement_type = 'exit'),
0
), 2);
$$;
--changeset panel:core-021d-cost-executed-qty splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_executed_qty(p_budget_item_id bigint)
RETURNS numeric
LANGUAGE sql
STABLE
SET search_path = core
AS $$
SELECT COALESCE(
(SELECT SUM(e.quantity) FROM expense_entries e
WHERE e.budget_item_id = p_budget_item_id
AND e.status = 'confirmed'
AND e.counts_toward_budget = true),
0
) + COALESCE(
(SELECT SUM(m.quantity) FROM warehouse_movements m
WHERE m.budget_item_id = p_budget_item_id
AND m.movement_type = 'exit'),
0
);
$$;
--changeset panel:core-021e-cost-budget-warning splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_budget_warning(
p_tenant_id integer,
p_budget_item_id bigint,
p_add_subtotal numeric DEFAULT 0,
p_add_qty numeric DEFAULT 0
)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SET search_path = core
AS $$
DECLARE
v_item record;
v_warn numeric;
v_critical numeric;
v_exec_amt numeric;
v_exec_qty numeric;
v_pct numeric;
v_level text;
BEGIN
IF p_budget_item_id IS NULL THEN
RETURN NULL;
END IF;
SELECT id, description, amount, quantity INTO v_item
FROM budget_items WHERE id = p_budget_item_id;
IF NOT FOUND OR COALESCE(v_item.amount, 0) <= 0 THEN
RETURN NULL;
END IF;
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical
FROM core._cost_settings(p_tenant_id);
v_exec_amt := core._cost_executed_amount(p_budget_item_id) + COALESCE(p_add_subtotal, 0);
v_exec_qty := core._cost_executed_qty(p_budget_item_id) + COALESCE(p_add_qty, 0);
v_pct := round(v_exec_amt / v_item.amount * 100, 1);
IF v_pct > v_critical THEN
v_level := 'critical';
ELSIF v_pct > v_warn THEN
v_level := 'warn';
ELSE
RETURN NULL;
END IF;
RETURN jsonb_build_object(
'level', v_level,
'budget_item_id', p_budget_item_id,
'pct_after', v_pct,
'executed_amount', v_exec_amt,
'budget_amount', v_item.amount,
'executed_qty', v_exec_qty,
'budget_qty', v_item.quantity,
'message', format(
'Esta operación dejará el concepto "%s" al %s%% del presupuesto (importe)',
left(v_item.description, 80), v_pct
)
);
END;
$$;
--changeset panel:core-021f-cost-calc-iva splitStatements:false
CREATE OR REPLACE FUNCTION core._cost_calc_amounts(
p_subtotal numeric,
p_iva_rate numeric,
p_tax_included boolean,
p_total numeric DEFAULT NULL
)
RETURNS TABLE (subtotal numeric, iva_amount numeric, total numeric)
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_sub numeric;
v_iva numeric;
v_tot numeric;
BEGIN
IF p_tax_included AND p_total IS NOT NULL AND p_total > 0 THEN
v_tot := round(p_total, 2);
v_sub := round(v_tot / (1 + COALESCE(p_iva_rate, 0.16)), 2);
v_iva := round(v_tot - v_sub, 2);
ELSE
v_sub := round(COALESCE(p_subtotal, 0), 2);
v_iva := round(v_sub * COALESCE(p_iva_rate, 0.16), 2);
v_tot := round(v_sub + v_iva, 2);
END IF;
RETURN QUERY SELECT v_sub, v_iva, v_tot;
END;
$$;
--changeset panel:core-021g-cost-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core._cost_tenant_id(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_settings(integer) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_executed_amount(bigint) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_executed_qty(bigint) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_budget_warning(integer, bigint, numeric, numeric) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core._cost_calc_amounts(numeric, numeric, boolean, numeric) TO panels_core_app;

View file

@ -0,0 +1,409 @@
--liquibase formatted sql
-- PANELS · core · RPC gastos e IVA
--changeset panel:core-022a-fn-expense-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
v_date_from date := NULLIF(payload->>'date_from', '')::date;
v_date_to date := NULLIF(payload->>'date_to', '')::date;
v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
v_source text := nullif(btrim(payload->>'source'), '');
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.expense_date DESC, x.id DESC), '[]'::jsonb)
INTO v_rows
FROM (
SELECT e.*,
bi.description AS budget_item_description,
bi.code AS budget_item_code
FROM expense_entries e
LEFT JOIN budget_items bi ON bi.id = e.budget_item_id
WHERE e.tenant_id = v_tid
AND e.status <> 'void'
AND (v_project_id IS NULL OR e.project_id = v_project_id)
AND (v_company_id IS NULL OR e.company_id = v_company_id)
AND (v_date_from IS NULL OR e.expense_date >= v_date_from)
AND (v_date_to IS NULL OR e.expense_date <= v_date_to)
AND (v_budget_item_id IS NULL OR e.budget_item_id = v_budget_item_id)
AND (v_source IS NULL OR e.source = v_source)
) x;
RETURN core.rpc_ok(
jsonb_build_object('expenses', v_rows, 'count', jsonb_array_length(v_rows)),
format('Listado de gastos: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid),
jsonb_build_object('fn', 'fn_expense_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022b-fn-expense-get splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_row jsonb;
v_attachments jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_get: id es obligatorio',
jsonb_build_object('fn', 'fn_expense_get', 'field', 'id'));
END IF;
SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id;
IF v_row IS NULL THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_get: gasto id=%s no encontrado', v_id),
jsonb_build_object('fn', 'fn_expense_get', 'id', v_id));
END IF;
SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.uploaded_at DESC), '[]'::jsonb)
INTO v_attachments FROM expense_attachments a WHERE a.expense_id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('expense', v_row, 'attachments', v_attachments),
format('Gasto id=%s recuperado con %s comprobante(s)', v_id, jsonb_array_length(v_attachments)),
jsonb_build_object('fn', 'fn_expense_get', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022c-fn-expense-create splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_expense_date date := COALESCE(NULLIF(payload->>'expense_date', '')::date, CURRENT_DATE);
v_description text := coalesce(nullif(btrim(payload->>'description'), ''), '');
v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
v_budget_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint;
v_quantity numeric := COALESCE((payload->>'quantity')::numeric, 0);
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0);
v_subtotal_in numeric := COALESCE((payload->>'subtotal')::numeric, 0);
v_iva_rate numeric;
v_tax_included boolean := COALESCE((payload->>'tax_included')::boolean, false);
v_total_in numeric := (payload->>'total')::numeric;
v_subtotal numeric; v_iva_amount numeric; v_total numeric;
v_is_deductible boolean := COALESCE((payload->>'is_deductible')::boolean, true);
v_counts_toward_budget boolean := COALESCE((payload->>'counts_toward_budget')::boolean, true);
v_supplier_name text := coalesce(nullif(btrim(payload->>'supplier_name'), ''), '');
v_supplier_rfc text := upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), ''));
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer;
v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
v_id bigint;
v_warning jsonb;
v_row jsonb;
BEGIN
IF v_company_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_create: company_id es obligatorio',
jsonb_build_object('fn', 'fn_expense_create', 'field', 'company_id'));
END IF;
IF v_description = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_create: description es obligatoria',
jsonb_build_object('fn', 'fn_expense_create', 'field', 'description'));
END IF;
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
RETURN core.rpc_err('VALIDATION', format('fn_expense_create: empresa id=%s no encontrada', v_company_id),
jsonb_build_object('fn', 'fn_expense_create', 'company_id', v_company_id));
END IF;
IF v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proyecto id=%s no encontrado', v_project_id),
jsonb_build_object('fn', 'fn_expense_create', 'project_id', v_project_id));
END IF;
IF v_subtotal_in <= 0 AND v_quantity > 0 AND v_unit_price > 0 THEN
v_subtotal_in := round(v_quantity * v_unit_price, 2);
END IF;
SELECT default_iva_rate INTO v_iva_rate FROM core._cost_settings(v_tid);
v_iva_rate := COALESCE((payload->>'iva_rate')::numeric, v_iva_rate);
SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total
FROM core._cost_calc_amounts(v_subtotal_in, v_iva_rate, v_tax_included, v_total_in) c;
IF v_budget_item_id IS NOT NULL THEN
SELECT chapter_id INTO v_budget_chapter_id FROM budget_items WHERE id = v_budget_item_id;
END IF;
INSERT INTO expense_entries (
tenant_id, company_id, project_id, expense_date, source, description,
supplier_name, supplier_rfc, budget_chapter_id, budget_item_id,
quantity, unit, unit_price, subtotal, iva_rate, iva_amount, total,
tax_included, is_deductible, counts_toward_budget, status,
created_by_id, created_by_name, notes
) VALUES (
v_tid, v_company_id, v_project_id, v_expense_date, 'manual', v_description,
v_supplier_name, v_supplier_rfc, v_budget_chapter_id, v_budget_item_id,
v_quantity, v_unit, v_unit_price, v_subtotal, v_iva_rate, v_iva_amount, v_total,
v_tax_included, v_is_deductible, v_counts_toward_budget, 'confirmed',
v_created_by_id, v_created_by_name, v_notes
) RETURNING id INTO v_id;
IF v_counts_toward_budget AND v_budget_item_id IS NOT NULL THEN
v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0);
END IF;
SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id;
RETURN core.rpc_created(
jsonb_build_object('expense', v_row, 'budget_warning', v_warning),
format('Gasto registrado por %s (subtotal %s, IVA %s)', v_description, v_subtotal, v_iva_amount),
jsonb_build_object('fn', 'fn_expense_create', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022d-fn-expense-update splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_tid integer := core._cost_tenant_id(payload);
v_cur expense_entries%ROWTYPE;
v_warning jsonb;
v_row jsonb;
v_subtotal numeric; v_iva_amount numeric; v_total numeric;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_update: id es obligatorio',
jsonb_build_object('fn', 'fn_expense_update', 'field', 'id'));
END IF;
SELECT * INTO v_cur FROM expense_entries WHERE id = v_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_update: gasto id=%s no encontrado', v_id),
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id));
END IF;
IF v_cur.is_locked OR v_cur.source <> 'manual' THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_update: solo se editan gastos manuales no bloqueados',
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id, 'source', v_cur.source));
END IF;
UPDATE expense_entries SET
expense_date = COALESCE(NULLIF(payload->>'expense_date', '')::date, expense_date),
description = COALESCE(nullif(btrim(payload->>'description'), ''), description),
supplier_name = CASE WHEN payload ? 'supplier_name' THEN coalesce(nullif(btrim(payload->>'supplier_name'), ''), '') ELSE supplier_name END,
budget_item_id = CASE WHEN payload ? 'budget_item_id' THEN NULLIF(payload->>'budget_item_id', '')::bigint ELSE budget_item_id END,
quantity = CASE WHEN payload ? 'quantity' THEN COALESCE((payload->>'quantity')::numeric, 0) ELSE quantity END,
unit = CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE unit END,
unit_price = CASE WHEN payload ? 'unit_price' THEN COALESCE((payload->>'unit_price')::numeric, 0) ELSE unit_price END,
notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END
WHERE id = v_id;
SELECT * INTO v_cur FROM expense_entries WHERE id = v_id;
SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total
FROM core._cost_calc_amounts(
CASE WHEN v_cur.quantity > 0 AND v_cur.unit_price > 0 THEN round(v_cur.quantity * v_cur.unit_price, 2) ELSE v_cur.subtotal END,
v_cur.iva_rate, v_cur.tax_included, NULL
) c;
UPDATE expense_entries SET subtotal = v_subtotal, iva_amount = v_iva_amount, total = v_total WHERE id = v_id;
IF v_cur.counts_toward_budget AND v_cur.budget_item_id IS NOT NULL THEN
v_warning := core._cost_budget_warning(v_tid, v_cur.budget_item_id, 0, 0);
END IF;
SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('expense', v_row, 'budget_warning', v_warning),
format('Gasto id=%s actualizado', v_id),
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022e-fn-expense-void splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_void(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_cur expense_entries%ROWTYPE;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_void: id es obligatorio',
jsonb_build_object('fn', 'fn_expense_void', 'field', 'id'));
END IF;
SELECT * INTO v_cur FROM expense_entries WHERE id = v_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_void: gasto id=%s no encontrado', v_id),
jsonb_build_object('fn', 'fn_expense_void', 'id', v_id));
END IF;
IF v_cur.is_locked THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_void: no se puede anular un gasto derivado de nómina',
jsonb_build_object('fn', 'fn_expense_void', 'id', v_id));
END IF;
UPDATE expense_entries SET status = 'void' WHERE id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('id', v_id, 'status', 'void'),
format('Gasto id=%s anulado', v_id),
jsonb_build_object('fn', 'fn_expense_void', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_void', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022f-fn-expense-attachment-add splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_attachment_add(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_expense_id bigint := NULLIF(payload->>'expense_id', '')::bigint;
v_id bigint;
BEGIN
IF v_expense_id IS NULL OR NOT EXISTS (SELECT 1 FROM expense_entries WHERE id = v_expense_id) THEN
RETURN core.rpc_err('NOT_FOUND', 'fn_expense_attachment_add: gasto no encontrado',
jsonb_build_object('fn', 'fn_expense_attachment_add', 'expense_id', v_expense_id));
END IF;
INSERT INTO expense_attachments (
expense_id, original_name, mime, size_bytes, sha256, iv, storage_name,
uploaded_by_id, uploaded_by_name
) VALUES (
v_expense_id,
coalesce(nullif(btrim(payload->>'original_name'), ''), 'comprobante.pdf'),
coalesce(nullif(btrim(payload->>'mime'), ''), 'application/pdf'),
COALESCE((payload->>'size_bytes')::bigint, 0),
coalesce(nullif(btrim(payload->>'sha256'), ''), ''),
coalesce(nullif(btrim(payload->>'iv'), ''), ''),
coalesce(nullif(btrim(payload->>'storage_name'), ''), ''),
NULLIF(payload->>'uploaded_by_id', '')::integer,
coalesce(nullif(btrim(payload->>'uploaded_by_name'), ''), '')
) RETURNING id INTO v_id;
RETURN core.rpc_created(
jsonb_build_object('id', v_id, 'expense_id', v_expense_id),
format('Comprobante adjunto al gasto id=%s', v_expense_id),
jsonb_build_object('fn', 'fn_expense_attachment_add', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_attachment_add', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022g-fn-iva-period-summary splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_iva_period_summary(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
v_year integer := COALESCE((payload->>'year')::integer, EXTRACT(YEAR FROM CURRENT_DATE)::integer);
v_month integer := COALESCE((payload->>'month')::integer, EXTRACT(MONTH FROM CURRENT_DATE)::integer);
v_acreditable numeric;
v_trasladado numeric;
BEGIN
SELECT COALESCE(SUM(e.iva_amount), 0) INTO v_acreditable
FROM expense_entries e
WHERE e.tenant_id = v_tid
AND e.status = 'confirmed'
AND e.is_deductible = true
AND EXTRACT(YEAR FROM e.expense_date) = v_year
AND EXTRACT(MONTH FROM e.expense_date) = v_month
AND (v_company_id IS NULL OR e.company_id = v_company_id);
SELECT COALESCE(iva_trasladado, 0) INTO v_trasladado
FROM iva_period_entries
WHERE tenant_id = v_tid
AND (v_company_id IS NULL OR company_id = v_company_id)
AND period_year = v_year AND period_month = v_month
LIMIT 1;
RETURN core.rpc_ok(
jsonb_build_object(
'year', v_year, 'month', v_month,
'iva_acreditable', round(COALESCE(v_acreditable, 0), 2),
'iva_trasladado', round(COALESCE(v_trasladado, 0), 2),
'iva_a_pagar', round(COALESCE(v_trasladado, 0) - COALESCE(v_acreditable, 0), 2)
),
format('Resumen IVA %s/%s: acreditable %s, trasladado %s', v_month, v_year, v_acreditable, v_trasladado),
jsonb_build_object('fn', 'fn_iva_period_summary', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_iva_period_summary', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022h-fn-cost-settings-get splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_cost_settings_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_row jsonb;
BEGIN
SELECT to_jsonb(s) INTO v_row FROM tenant_cost_settings s WHERE tenant_id = v_tid;
IF v_row IS NULL THEN
v_row := jsonb_build_object('tenant_id', v_tid, 'budget_warn_pct', 85, 'budget_critical_pct', 100, 'default_iva_rate', 0.16);
END IF;
RETURN core.rpc_ok(v_row, format('Configuración de costos del tenant %s', v_tid),
jsonb_build_object('fn', 'fn_cost_settings_get', 'tenant_id', v_tid));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_cost_settings_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022i-fn-cost-settings-save splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_cost_settings_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
BEGIN
INSERT INTO tenant_cost_settings (tenant_id, budget_warn_pct, budget_critical_pct, default_iva_rate, updated_at)
VALUES (
v_tid,
COALESCE((payload->>'budget_warn_pct')::numeric, 85),
COALESCE((payload->>'budget_critical_pct')::numeric, 100),
COALESCE((payload->>'default_iva_rate')::numeric, 0.16),
now()
)
ON CONFLICT (tenant_id) DO UPDATE SET
budget_warn_pct = EXCLUDED.budget_warn_pct,
budget_critical_pct = EXCLUDED.budget_critical_pct,
default_iva_rate = EXCLUDED.default_iva_rate,
updated_at = now();
RETURN core.rpc_ok(
(SELECT to_jsonb(s) FROM tenant_cost_settings s WHERE tenant_id = v_tid),
format('Configuración de costos guardada para tenant %s', v_tid),
jsonb_build_object('fn', 'fn_cost_settings_save', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_cost_settings_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-022j-expense-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_expense_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_expense_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_expense_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_expense_update(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_expense_void(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_expense_attachment_add(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_iva_period_summary(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_cost_settings_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_cost_settings_save(jsonb) TO panels_core_app;

View file

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

View file

@ -0,0 +1,186 @@
--liquibase formatted sql
-- PANELS · core · RPC control presupuestal
--changeset panel:core-024a-fn-cost-control-by-item splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_cost_control_by_item(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_tid integer := core._cost_tenant_id(payload);
v_warn numeric; v_critical numeric;
v_rows jsonb;
BEGIN
IF v_project_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_by_item: project_id es obligatorio',
jsonb_build_object('fn', 'fn_cost_control_by_item', 'field', 'project_id'));
END IF;
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical FROM core._cost_settings(v_tid);
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.code, x.id), '[]'::jsonb) INTO v_rows
FROM (
SELECT
bi.id, bi.code, bi.description, bi.unit, bi.chapter_id,
bi.quantity AS qty_budget, bi.amount AS amount_budget, bi.unit_price,
core._cost_executed_qty(bi.id) AS qty_executed,
core._cost_executed_amount(bi.id) AS amount_executed,
round(CASE WHEN bi.amount > 0 THEN core._cost_executed_amount(bi.id) / bi.amount * 100 ELSE 0 END, 1) AS pct_amount,
round(core._cost_executed_amount(bi.id) - bi.amount, 2) AS delta_amount,
CASE
WHEN bi.amount <= 0 AND core._cost_executed_amount(bi.id) > 0 THEN 'critical'
WHEN bi.amount > 0 AND core._cost_executed_amount(bi.id) / bi.amount * 100 > v_critical THEN 'critical'
WHEN bi.amount > 0 AND core._cost_executed_amount(bi.id) / bi.amount * 100 > v_warn THEN 'warn'
ELSE 'ok'
END AS semaphore
FROM budget_items bi
WHERE bi.project_id = v_project_id
) x;
RETURN core.rpc_ok(
jsonb_build_object('items', v_rows, 'count', jsonb_array_length(v_rows)),
format('Control por concepto del proyecto %s: %s renglón(es)', v_project_id, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_cost_control_by_item', 'project_id', v_project_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_cost_control_by_item', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-024b-fn-cost-control-by-chapter splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_cost_control_by_chapter(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_tid integer := core._cost_tenant_id(payload);
v_warn numeric; v_critical numeric;
v_rows jsonb;
BEGIN
IF v_project_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_by_chapter: project_id es obligatorio',
jsonb_build_object('fn', 'fn_cost_control_by_chapter'));
END IF;
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical FROM core._cost_settings(v_tid);
WITH chapter_base AS (
SELECT bc.id, bc.parent_id, bc.code, bc.name, bc.wbs, bc.sort_order
FROM budget_chapters bc
WHERE bc.project_id = v_project_id
)
SELECT COALESCE(jsonb_agg(to_jsonb(cs) ORDER BY cs.sort_order, cs.id), '[]'::jsonb) INTO v_rows
FROM (
SELECT cb.id, cb.parent_id, cb.code, cb.name, cb.wbs, cb.sort_order,
COALESCE(SUM(bi.amount), 0) AS amount_budget,
COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) AS amount_executed,
round(CASE WHEN COALESCE(SUM(bi.amount), 0) > 0
THEN COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 ELSE 0 END, 1) AS pct_amount,
round(COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) - COALESCE(SUM(bi.amount), 0), 2) AS delta_amount,
CASE
WHEN COALESCE(SUM(bi.amount), 0) <= 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) > 0 THEN 'critical'
WHEN COALESCE(SUM(bi.amount), 0) > 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 > v_critical THEN 'critical'
WHEN COALESCE(SUM(bi.amount), 0) > 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 > v_warn THEN 'warn'
ELSE 'ok'
END AS semaphore
FROM chapter_base cb
LEFT JOIN budget_items bi ON bi.chapter_id = cb.id
GROUP BY cb.id, cb.parent_id, cb.code, cb.name, cb.wbs, cb.sort_order
) cs;
RETURN core.rpc_ok(jsonb_build_object('chapters', v_rows),
format('Control por partida del proyecto %s', v_project_id),
jsonb_build_object('fn', 'fn_cost_control_by_chapter', 'project_id', v_project_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_cost_control_by_chapter', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-024c-fn-cost-control-project-summary splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_cost_control_project_summary(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_budget numeric;
v_executed numeric;
v_unclassified numeric;
v_in_warehouse numeric;
v_red_items integer;
v_red_chapters integer;
BEGIN
IF v_project_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_project_summary: project_id es obligatorio',
jsonb_build_object('fn', 'fn_cost_control_project_summary'));
END IF;
SELECT COALESCE(SUM(amount), 0) INTO v_budget FROM budget_items WHERE project_id = v_project_id;
SELECT COALESCE(SUM(core._cost_executed_amount(id)), 0) INTO v_executed FROM budget_items WHERE project_id = v_project_id;
SELECT COALESCE(SUM(subtotal), 0) INTO v_unclassified
FROM expense_entries
WHERE project_id = v_project_id AND status = 'confirmed'
AND budget_item_id IS NULL AND counts_toward_budget = true;
SELECT COALESCE(SUM(s.qty_on_hand * s.avg_unit_cost), 0) INTO v_in_warehouse
FROM warehouse_stock s
JOIN warehouses w ON w.id = s.warehouse_id
WHERE w.project_id = v_project_id AND w.status = 'open';
SELECT COUNT(*)::integer INTO v_red_items FROM budget_items bi
WHERE bi.project_id = v_project_id AND bi.amount > 0
AND core._cost_executed_amount(bi.id) > bi.amount;
RETURN core.rpc_ok(
jsonb_build_object(
'project_id', v_project_id,
'budget_subtotal', round(v_budget, 2),
'executed_subtotal', round(v_executed, 2),
'pct_executed', round(CASE WHEN v_budget > 0 THEN v_executed / v_budget * 100 ELSE 0 END, 1),
'delta_subtotal', round(v_executed - v_budget, 2),
'unclassified_amount', round(v_unclassified, 2),
'warehouse_stock_value', round(v_in_warehouse, 2),
'concepts_over_budget', v_red_items
),
format('Resumen de control del proyecto %s: %s%% ejecutado', v_project_id,
round(CASE WHEN v_budget > 0 THEN v_executed / v_budget * 100 ELSE 0 END, 1)),
jsonb_build_object('fn', 'fn_cost_control_project_summary', 'project_id', v_project_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_cost_control_project_summary', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-024d-fn-cost-control-top-deviations splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_cost_control_top_deviations(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_limit integer := COALESCE((payload->>'limit')::integer, 10);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
SELECT bi.id, bi.code, bi.description, bi.amount AS amount_budget,
core._cost_executed_amount(bi.id) AS amount_executed,
round(core._cost_executed_amount(bi.id) - bi.amount, 2) AS delta_amount
FROM budget_items bi
WHERE bi.project_id = v_project_id AND bi.amount > 0
AND core._cost_executed_amount(bi.id) > bi.amount
ORDER BY core._cost_executed_amount(bi.id) - bi.amount DESC
LIMIT v_limit
) x;
RETURN core.rpc_ok(jsonb_build_object('deviations', v_rows),
format('Top %s desviaciones del proyecto %s', v_limit, v_project_id),
jsonb_build_object('fn', 'fn_cost_control_top_deviations', 'project_id', v_project_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_cost_control_top_deviations', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-024e-cost-control-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_cost_control_by_item(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_cost_control_by_chapter(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_cost_control_project_summary(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_cost_control_top_deviations(jsonb) TO panels_core_app;

View file

@ -0,0 +1,197 @@
--liquibase formatted sql
-- PANELS · core · sincronización nómina/destajo → gastos
--changeset panel:core-025a-fn-payroll-sync-cost-entries splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_payroll_sync_cost_entries(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_tid integer := core._cost_tenant_id(payload);
v_line record;
v_company_id bigint;
v_expense_date date;
v_desc text;
v_budget_item_id bigint;
v_inserted integer := 0;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_payroll_sync_cost_entries: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_sync_cost_entries'));
END IF;
SELECT week_end INTO v_expense_date FROM payroll_weeks WHERE id = v_week_id;
FOR v_line IN
SELECT l.id AS line_id, l.payable_net, l.concepto, l.project_id, l.worker_id,
s.kind AS sheet_kind, s.project_id AS sheet_project_id,
w.company_id AS worker_company_id, p.company_id AS project_company_id,
dj.budget_item_id AS destajo_budget_item_id
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
JOIN workers w ON w.id = l.worker_id
LEFT JOIN projects p ON p.id = COALESCE(l.project_id, s.project_id)
LEFT JOIN destajo_cut_lines dcl ON dcl.id = l.destajo_cut_line_id
LEFT JOIN destajo_jobs dj ON dj.id = dcl.job_id
WHERE s.week_id = v_week_id
AND l.payable_net > 0
LOOP
v_company_id := COALESCE(v_line.project_company_id, v_line.worker_company_id);
IF v_company_id IS NULL THEN
SELECT id INTO v_company_id FROM companies WHERE tenant_id = v_tid AND kind = 'principal' LIMIT 1;
END IF;
v_budget_item_id := v_line.destajo_budget_item_id;
v_desc := coalesce(nullif(btrim(v_line.concepto), ''),
CASE v_line.sheet_kind
WHEN 'destajo' THEN 'Destajo semana ' || v_week_id
WHEN 'obra' THEN 'Nómina obra semana ' || v_week_id
ELSE 'Nómina admin semana ' || v_week_id
END);
IF EXISTS (
SELECT 1 FROM expense_entries
WHERE source = 'payroll' AND source_ref_id = v_line.line_id AND status <> 'void'
) THEN
UPDATE expense_entries SET
subtotal = v_line.payable_net,
total = v_line.payable_net,
project_id = COALESCE(v_line.project_id, v_line.sheet_project_id),
budget_item_id = v_budget_item_id,
description = v_desc,
expense_date = v_expense_date
WHERE source = 'payroll' AND source_ref_id = v_line.line_id AND status <> 'void';
ELSE
INSERT INTO expense_entries (
tenant_id, company_id, project_id, expense_date, source, source_ref_id,
description, subtotal, iva_rate, iva_amount, total, tax_included,
is_deductible, counts_toward_budget, is_locked, status,
budget_item_id, created_by_name
) VALUES (
v_tid, v_company_id, COALESCE(v_line.project_id, v_line.sheet_project_id),
v_expense_date, 'payroll', v_line.line_id,
v_desc, v_line.payable_net, 0, 0, v_line.payable_net, false,
false, true, true, 'confirmed',
v_budget_item_id, 'nómina'
);
END IF;
v_inserted := v_inserted + 1;
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('week_id', v_week_id, 'lines_synced', v_inserted),
format('Sincronizados %s gastos de nómina de la semana %s', v_inserted, v_week_id),
jsonb_build_object('fn', 'fn_payroll_sync_cost_entries', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_sync_cost_entries', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-025b-fn-payroll-week-pay-with-costs splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_payroll_week_pay(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
v_now_iso date := COALESCE(
NULLIF(btrim(payload->>'now_iso'), '')::date,
core._payroll_tenant_today(v_tid)
);
v_week record;
v_line record;
v_loan record;
v_take numeric;
v_n integer;
v_period_id bigint;
v_job record;
v_paid numeric;
v_env jsonb;
v_sync jsonb;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_pay: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_week_pay', 'field', 'week_id')
);
END IF;
SELECT * INTO v_week FROM payroll_weeks WHERE id = v_week_id;
IF NOT FOUND THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_payroll_week_pay: semana id=%s no encontrada', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
END IF;
IF v_week.status = 'paid' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_week_pay: la semana ya está pagada',
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
END IF;
IF v_week.status <> 'assembled' THEN
v_env := core.fn_payroll_week_assemble(jsonb_build_object(
'week_id', v_week_id,
'tenant_id', v_tid,
'now_iso', v_now_iso::text
));
IF NOT COALESCE((v_env->>'ok')::boolean, false) THEN
RETURN v_env;
END IF;
END IF;
PERFORM core._payroll_recalc_week(v_week_id);
FOR v_line IN
SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
WHERE s.week_id = v_week_id
AND l.loan_id IS NOT NULL
AND l.loan_discount > 0
LOOP
SELECT * INTO v_loan FROM loans WHERE id = v_line.loan_id;
IF NOT FOUND OR v_loan.balance <= 0 THEN
CONTINUE;
END IF;
v_take := core.round_money(LEAST(v_line.loan_discount, v_loan.balance));
IF v_take <= 0 THEN
CONTINUE;
END IF;
SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id;
INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label)
VALUES (v_loan.id, v_week_id, v_take, v_n, v_line.loan_label);
UPDATE loans
SET balance = core.round_money(balance - v_take)
WHERE id = v_loan.id;
END LOOP;
SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
IF v_period_id IS NOT NULL THEN
FOR v_job IN SELECT id, qty_total_estimated FROM destajo_jobs LOOP
SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) INTO v_paid
FROM destajo_cut_lines c
JOIN destajo_periods p ON p.id = c.period_id
JOIN payroll_weeks w ON w.id = p.week_id
WHERE c.job_id = v_job.id
AND (w.status = 'paid' OR w.id = v_week_id);
IF v_paid >= v_job.qty_total_estimated THEN
UPDATE destajo_jobs SET status = 'done' WHERE id = v_job.id;
END IF;
END LOOP;
END IF;
UPDATE payroll_weeks SET status = 'paid', paid_at = now() WHERE id = v_week_id;
v_sync := core.fn_payroll_sync_cost_entries(jsonb_build_object('week_id', v_week_id, 'tenant_id', v_tid));
RETURN core.rpc_ok(
jsonb_build_object('week_id', v_week_id, 'status', 'paid', 'cost_sync', v_sync->'data'),
format('Semana de nómina %s marcada como pagada y costos sincronizados', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_pay', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-025c-payroll-cost-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_payroll_sync_cost_entries(jsonb) TO panels_core_app;

View file

@ -0,0 +1,125 @@
--liquibase formatted sql
-- PANELS · core · cierre de almacén al concluir proyecto
--changeset panel:core-026a-fn-project-update-warehouse splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_project_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := (payload->>'id')::bigint;
v_cur projects%ROWTYPE;
v_name text;
v_address text;
v_theme_id text;
v_status text;
v_company_id integer;
v_contract_amount numeric(14,2);
v_start_date date;
v_end_date date;
v_resident_name text;
v_siroc text;
v_payroll_tax_pct numeric(5,2);
v_close_env jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_project_update: id es obligatorio',
jsonb_build_object('fn', 'fn_project_update', 'field', 'id')
);
END IF;
SELECT * INTO v_cur FROM projects WHERE id = v_id;
IF NOT FOUND THEN
RETURN core.rpc_err(
'NOT_FOUND',
format('fn_project_update: proyecto id=%s no encontrado', v_id),
jsonb_build_object('fn', 'fn_project_update', 'id', v_id)
);
END IF;
v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name);
v_address := CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE v_cur.address END;
v_theme_id := coalesce(nullif(btrim(payload->>'theme_id'), ''), v_cur.theme_id);
v_status := CASE
WHEN payload ? 'status' AND btrim(payload->>'status') IN ('activo', 'pausado', 'concluido', 'cancelado')
THEN btrim(payload->>'status')
ELSE v_cur.status
END;
v_company_id := coalesce((payload->>'company_id')::integer, v_cur.company_id);
v_contract_amount := CASE
WHEN payload ? 'contract_amount' THEN (payload->>'contract_amount')::numeric(14,2)
ELSE v_cur.contract_amount
END;
v_start_date := CASE
WHEN payload ? 'start_date' THEN nullif(btrim(payload->>'start_date'), '')::date
ELSE v_cur.start_date
END;
v_end_date := CASE
WHEN payload ? 'end_date' THEN nullif(btrim(payload->>'end_date'), '')::date
ELSE v_cur.end_date
END;
v_resident_name := CASE
WHEN payload ? 'resident_name' THEN coalesce(nullif(btrim(payload->>'resident_name'), ''), '')
ELSE v_cur.resident_name
END;
v_siroc := CASE
WHEN payload ? 'siroc' THEN upper(coalesce(nullif(btrim(payload->>'siroc'), ''), ''))
ELSE v_cur.siroc
END;
v_payroll_tax_pct := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), v_cur.payroll_tax_pct);
IF v_company_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_project_update: company_id es obligatorio',
jsonb_build_object('fn', 'fn_project_update', 'field', 'company_id', 'id', v_id)
);
END IF;
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
RETURN core.rpc_err(
'VALIDATION',
format('fn_project_update: empresa id=%s no encontrada', v_company_id),
jsonb_build_object('fn', 'fn_project_update', 'company_id', v_company_id, 'id', v_id)
);
END IF;
IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_project_update: la fecha de término no puede ser anterior al inicio',
jsonb_build_object('fn', 'fn_project_update', 'field', 'end_date', 'id', v_id)
);
END IF;
IF v_status IN ('concluido', 'cancelado') AND v_cur.status NOT IN ('concluido', 'cancelado') THEN
v_close_env := core.fn_warehouse_close_project(jsonb_build_object(
'project_id', v_id,
'force', COALESCE((payload->>'force_warehouse_close')::boolean, false),
'closed_by_name', coalesce(nullif(btrim(payload->>'closed_by_name'), ''), 'sistema'),
'close_notes', coalesce(nullif(btrim(payload->>'warehouse_close_notes'), ''), 'Cierre automático al concluir obra')
));
IF NOT COALESCE((v_close_env->>'ok')::boolean, false) THEN
RETURN v_close_env;
END IF;
END IF;
UPDATE projects SET
name = v_name,
address = v_address,
theme_id = v_theme_id,
status = v_status,
company_id = v_company_id,
contract_amount = v_contract_amount,
start_date = v_start_date,
end_date = v_end_date,
resident_name = v_resident_name,
siroc = v_siroc,
payroll_tax_pct = v_payroll_tax_pct
WHERE id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('id', v_id, 'code', v_cur.code, 'status', v_status),
format('Proyecto %s actualizado', v_cur.code),
jsonb_build_object('fn', 'fn_project_update', 'id', v_id, 'code', v_cur.code)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_project_update', SQLSTATE, SQLERRM);
END;
$$;

View file

@ -8,5 +8,7 @@
<!-- panels_product, esquema iam: identidad/roles/permisos del tenant. --> <!-- panels_product, esquema iam: identidad/roles/permisos del tenant. -->
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/> <include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
<include file="changesets/002-rls.sql" relativeToChangelogFile="true"/> <include file="changesets/002-rls.sql" relativeToChangelogFile="true"/>
<include file="changesets/003-expenses-warehouse-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -0,0 +1,27 @@
--liquibase formatted sql
-- PANELS · iam · permisos gastos, almacén y control de costos
--changeset panel:iam-003a-permissions endDelimiter:; splitStatements:true
INSERT INTO iam.permissions (code, label) VALUES
('view_expenses', 'Ver gastos y reportes de costos'),
('manage_expenses', 'Capturar y editar gastos'),
('view_warehouse', 'Ver almacén global y de obra'),
('manage_warehouse', 'Entradas, salidas y transferencias de almacén'),
('close_warehouse', 'Cerrar almacén de obra'),
('manage_cost_settings', 'Configurar umbrales de alerta presupuestal')
ON CONFLICT (code) DO NOTHING;
--changeset panel:iam-003b-tenant-admin-permissions endDelimiter:; splitStatements:true
INSERT INTO iam.role_permissions (role_code, permission_code)
SELECT 'tenant_admin', code FROM iam.permissions
WHERE code IN (
'view_expenses', 'manage_expenses', 'view_warehouse', 'manage_warehouse',
'close_warehouse', 'manage_cost_settings'
)
ON CONFLICT DO NOTHING;
--changeset panel:iam-003c-user-view-permissions endDelimiter:; splitStatements:true
INSERT INTO iam.role_permissions (role_code, permission_code) VALUES
('user', 'view_expenses'),
('user', 'view_warehouse')
ON CONFLICT DO NOTHING;

View file

@ -0,0 +1,118 @@
--liquibase formatted sql
-- PANELS · iam · RPC usuarios y permisos del tenant
--changeset panel:iam-004a-fn-iam-user-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_user_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_tid integer := COALESCE(
NULLIF(payload->>'tenant_id', '')::integer,
NULLIF(current_setting('app.tenant_id', true), '')::integer
);
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.display_name), '[]'::jsonb) INTO v_rows
FROM (
SELECT id, username, display_name, email, role_code, company_id, tenant_id, created_at
FROM users WHERE tenant_id = v_tid
) u;
RETURN core.rpc_ok(
jsonb_build_object('users', v_rows),
format('Usuarios del tenant %s: %s cuenta(s)', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_user_list', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_user_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-004b-fn-iam-permission-list splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_permission_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.code), '[]'::jsonb) INTO v_rows
FROM (SELECT code, label FROM permissions) p;
RETURN core.rpc_ok(jsonb_build_object('permissions', v_rows),
format('Catálogo de permisos: %s', jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_permission_list'));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_permission_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-004c-fn-iam-role-permissions-get splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_role text := nullif(btrim(payload->>'role_code'), '');
v_rows jsonb;
BEGIN
IF v_role IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_get: role_code es obligatorio',
jsonb_build_object('fn', 'fn_role_permissions_get'));
END IF;
SELECT COALESCE(jsonb_agg(rp.permission_code), '[]'::jsonb) INTO v_rows
FROM role_permissions rp WHERE rp.role_code = v_role;
RETURN core.rpc_ok(jsonb_build_object('role_code', v_role, 'permissions', v_rows),
format('Permisos del rol %s: %s', v_role, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_role_permissions_get', 'role_code', v_role));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_permissions_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-004d-fn-iam-role-permissions-save splitStatements:false
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = iam
AS $$
DECLARE
v_role text := nullif(btrim(payload->>'role_code'), '');
v_perms jsonb := COALESCE(payload->'permissions', '[]'::jsonb);
v_perm text;
BEGIN
IF v_role IS NULL OR v_role = 'tenant_admin' THEN
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar tenant_admin',
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_code', v_role));
END IF;
DELETE FROM role_permissions WHERE role_code = v_role;
FOR v_perm IN SELECT jsonb_array_elements_text(v_perms)
LOOP
INSERT INTO role_permissions (role_code, permission_code)
VALUES (v_role, v_perm)
ON CONFLICT DO NOTHING;
END LOOP;
RETURN core.rpc_ok(
jsonb_build_object('role_code', v_role, 'permissions', v_perms),
format('Permisos del rol %s actualizados (%s permiso(s))', v_role, jsonb_array_length(v_perms)),
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_code', v_role));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_role_permissions_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:iam-004e-iam-rpc-grants endDelimiter:; splitStatements:true
GRANT USAGE ON SCHEMA core TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_user_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_permission_list(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app;
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_save(jsonb) TO panels_iam_app;

View file

@ -164,5 +164,21 @@ BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "catalogs" "200" "$CODE" "$BODY" check_status "catalogs" "200" "$CODE" "$BODY"
echo "$BODY" | grep -q '"risks"' || fail "catalogs missing risks" echo "$BODY" | grep -q '"risks"' || fail "catalogs missing risks"
echo "=== Expenses LIST ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/expenses")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "expenses list" "200" "$CODE" "$BODY"
echo "$BODY" | grep -q '"ok":true' || fail "expenses list envelope"
echo "=== Warehouses LIST ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/warehouses")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "warehouses list" "200" "$CODE" "$BODY"
echo "=== Cost control (project 1) ==="
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/1/cost-control/summary")
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
check_status "cost control summary" "200" "$CODE" "$BODY"
echo "" echo ""
echo "All CRUD smoke tests passed." echo "All CRUD smoke tests passed."

View file

@ -2231,3 +2231,8 @@ html.dark .p-dialog .p-dialog-header-icon {
.detail-tabs .nomina-loan-fieldset { .detail-tabs .nomina-loan-fieldset {
margin-top: 28px; margin-top: 28px;
} }
.cost-ok { background: #e8f5e9 !important; color: #1b5e20 !important; }
.cost-warn { background: #fff8e1 !important; color: #e65100 !important; }
.cost-critical { background: #ffebee !important; color: #b42318 !important; }
.cost-critical-text { color: #b42318; font-weight: 600; }

View file

@ -0,0 +1,73 @@
<template>
<AutoComplete
v-model="selected"
:suggestions="filtered"
option-label="label"
placeholder="Buscar concepto del presupuesto..."
class="w-full"
force-selection
@complete="onSearch"
@item-select="onSelect"
/>
</template>
<script setup lang="ts">
const props = defineProps<{
projectId: number;
modelValue?: number | null;
}>();
const emit = defineEmits<{ "update:modelValue": [number | null]; select: [BudgetOption | null] }>();
type BudgetOption = {
id: number;
code: string;
description: string;
unit: string;
amount: number;
label: string;
};
const { api } = useApi();
const items = ref<BudgetOption[]>([]);
const selected = ref<BudgetOption | null>(null);
const filtered = ref<BudgetOption[]>([]);
async function load() {
if (!props.projectId) {
items.value = [];
return;
}
const r = await api<{ items?: Array<Record<string, unknown>> }>(
`/v1/projects/${props.projectId}/budget`,
);
const raw = r.items ?? [];
items.value = raw.map((row) => ({
id: Number(row.id),
code: String(row.code ?? ""),
description: String(row.description ?? ""),
unit: String(row.unit ?? ""),
amount: Number(row.amount ?? 0),
label: [row.code, row.description].filter(Boolean).join(" — "),
}));
filtered.value = items.value.slice(0, 30);
}
function onSearch(event: { query: string }) {
const q = event.query.toLowerCase();
filtered.value = items.value.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 30);
}
function onSelect(event: { value: BudgetOption }) {
emit("update:modelValue", event.value?.id ?? null);
emit("select", event.value ?? null);
}
watch(() => props.projectId, load, { immediate: true });
watch(
() => props.modelValue,
(id) => {
if (!id) selected.value = null;
else selected.value = items.value.find((i) => i.id === id) ?? null;
},
);
</script>

View file

@ -0,0 +1,18 @@
<template>
<Chip :class="chipClass" :label="label" />
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
pct: number;
compact?: boolean;
}>(), { compact: false });
const label = computed(() => props.compact ? `${props.pct}%` : `${props.pct}% ejecutado`);
const chipClass = computed(() => {
if (props.pct > 100) return "cost-critical";
if (props.pct > 85) return "cost-warn";
return "cost-ok";
});
</script>

View file

@ -144,10 +144,14 @@ const menu = [
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") }, { label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") },
{ label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") }, { label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") }, { label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") },
{ label: "Control costos", icon: "pi pi-chart-bar", to: "/control-presupuesto", command: () => navigateTo("/control-presupuesto") },
{ label: "Gastos", icon: "pi pi-money-bill", to: "/gastos", command: () => navigateTo("/gastos") },
{ label: "Almacén", icon: "pi pi-box", to: "/almacen", command: () => navigateTo("/almacen") },
{ label: "Padrón", icon: "pi pi-users", to: "/padron", command: () => navigateTo("/padron") }, { label: "Padrón", icon: "pi pi-users", to: "/padron", command: () => navigateTo("/padron") },
{ label: "Kanban", icon: "pi pi-th-large", to: "/kanban", command: () => navigateTo("/kanban") }, { label: "Kanban", icon: "pi pi-th-large", to: "/kanban", command: () => navigateTo("/kanban") },
{ label: "Gafetes", icon: "pi pi-id-card", to: "/gafetes", command: () => navigateTo("/gafetes") }, { label: "Gafetes", icon: "pi pi-id-card", to: "/gafetes", command: () => navigateTo("/gafetes") },
{ label: "Nómina", icon: "pi pi-wallet", to: "/nomina", command: () => navigateTo("/nomina") }, { label: "Nómina", icon: "pi pi-wallet", to: "/nomina", command: () => navigateTo("/nomina") },
{ label: "Usuarios", icon: "pi pi-shield", to: "/usuarios", command: () => navigateTo("/usuarios") },
{ label: "Ayuda", icon: "pi pi-question-circle", command: showHelp }, { label: "Ayuda", icon: "pi pi-question-circle", command: showHelp },
]; ];

235
web-panel/pages/almacen.vue Normal file
View file

@ -0,0 +1,235 @@
<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" />
<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" />
<Column field="material_name" header="Material" />
<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>
</Column>
</DataTable>
</TabPanel>
<TabPanel header="Kardex">
<DataTable :value="movements" 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 field="material_name" header="Material" />
<Column field="quantity" header="Cant." style="width: 7rem" />
<Column field="total_cost" header="Importe" style="width: 9rem">
<template #body="{ data }">{{ money(data.total_cost) }}</template>
</Column>
<Column field="destination_note" header="Destino / nota" />
</DataTable>
</TabPanel>
</TabView>
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '640px' }">
<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>
<FormField label="Código material">
<InputText v-model="entryForm.material_code" class="w-full identifier-input" />
</FormField>
<FormField label="Nombre material">
<InputText v-model="entryForm.material_name" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="entryForm.quantity" :min="0" 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>
</div>
<template #footer>
<Button label="Cancelar" text @click="showEntry = false" />
<Button label="Registrar entrada" icon="pi pi-check" @click="saveEntry" />
</template>
</Dialog>
<Dialog v-model:visible="showExit" header="Salida a obra / concepto" 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>
<FormField label="Material">
<Select v-model="exitForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="exitForm.quantity" :min="0" class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
</FormField>
<FormField label="Destino físico" class="span-2">
<InputText v-model="exitForm.destination_note" class="w-full" />
</FormField>
</div>
<Message v-if="exitWarning" severity="warn" :closable="false">{{ exitWarning }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showExit = false" />
<Button label="Registrar salida" icon="pi pi-check" @click="saveExit" />
</template>
</Dialog>
<Dialog v-model:visible="showTransfer" header="Transferencia central ↔ obra" modal :style="{ width: '640px' }">
<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>
<FormField label="Hacia">
<Select v-model="transferForm.to_warehouse_id" :options="warehouses" 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>
<FormField label="Cantidad">
<InputNumber v-model="transferForm.quantity" :min="0" class="w-full" />
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showTransfer = false" />
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetItemPicker from "~/components/BudgetItemPicker.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 MovementRow = Record<string, unknown>;
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const tableQ = ref("");
const warehouses = ref<WarehouseRow[]>([]);
const stockRows = 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 exitWarning = ref("");
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 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 q = tableQ.value.toLowerCase();
return stockRows.value.filter((r) =>
!q || [r.material_name, r.material_code, r.project_code].join(" ").toLowerCase().includes(q),
);
});
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"}`,
}));
}
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}`);
}
materialOptions.value = [...mats.entries()].map(([id, label]) => ({ id, label }));
}
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 ?? [];
}
async function saveEntry() {
try {
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
method: "POST",
json: entryForm,
});
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
showEntry.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function saveExit() {
try {
const r = await api<{ data?: { budget_warning?: { message: string } }; message?: string }>(
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
{ method: "POST", json: exitForm },
);
exitWarning.value = r.data?.budget_warning?.message ?? "";
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
if (!exitWarning.value) showExit.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
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 });
showTransfer.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
onMounted(async () => {
desk.configure({
tab: "Almacén",
ribbon: [{
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; } },
],
}],
});
await loadWarehouses();
await loadStock();
await loadMovements();
});
</script>

View file

@ -0,0 +1,153 @@
<template>
<div class="control-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar partida o concepto">
<Chip :label="projectName" icon="pi pi-building" />
<Chip v-if="summary" :label="`${summary.pct_executed}% ejecutado`" icon="pi pi-chart-line" />
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
</DeskTableBar>
<div v-if="summary" class="kpi-row mb-3">
<div class="kpi-card">
<span class="kpi-label">Presupuesto</span>
<strong>{{ money(summary.budget_subtotal) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Ejecutado</span>
<strong>{{ money(summary.executed_subtotal) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">En bodega</span>
<strong>{{ money(summary.warehouse_stock_value) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Sin clasificar</span>
<strong>{{ money(summary.unclassified_amount) }}</strong>
</div>
<div class="kpi-card">
<span class="kpi-label">Conceptos en rojo</span>
<strong>{{ summary.concepts_over_budget }}</strong>
</div>
</div>
<DataTable
:value="filteredItems"
data-key="id"
paginator
:rows="50"
scrollable
scroll-height="flex"
table-style="min-width: 72rem"
empty-message="Seleccione una obra con presupuesto cargado."
>
<Column field="code" header="Clave" style="width: 7rem">
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
</Column>
<Column field="description" header="Concepto" />
<Column field="amount_budget" header="Ppto $" style="width: 9rem">
<template #body="{ data }">{{ money(data.amount_budget) }}</template>
</Column>
<Column field="amount_executed" header="Ejecutado $" style="width: 9rem">
<template #body="{ data }">{{ money(data.amount_executed) }}</template>
</Column>
<Column field="pct_amount" header="%" style="width: 6rem">
<template #body="{ data }"><CostSemaphore :pct="Number(data.pct_amount)" compact /></template>
</Column>
<Column field="delta_amount" header="Δ $" style="width: 9rem">
<template #body="{ data }">
<span :class="Number(data.delta_amount) > 0 ? 'cost-critical-text' : ''">{{ money(data.delta_amount) }}</span>
</template>
</Column>
<Column field="qty_budget" header="Ppto cant." style="width: 8rem" />
<Column field="qty_executed" header="Ejec. cant." style="width: 8rem" />
</DataTable>
</div>
</template>
<script setup lang="ts">
import CostSemaphore from "~/components/CostSemaphore.vue";
type ControlItem = {
id: number;
code: string;
description: string;
amount_budget: number;
amount_executed: number;
pct_amount: number;
delta_amount: number;
qty_budget: number;
qty_executed: number;
semaphore: string;
};
type Summary = {
budget_subtotal: number;
executed_subtotal: number;
pct_executed: number;
warehouse_stock_value: number;
unclassified_amount: number;
concepts_over_budget: number;
};
const desk = useDesktop();
const { api } = useApi();
const route = useRoute();
const tableQ = ref("");
const projects = ref<{ id: number; name: string }[]>([]);
const items = ref<ControlItem[]>([]);
const summary = ref<Summary | null>(null);
const projectId = computed(() => Number(route.query.project_id || 0) || projects.value[0]?.id || 0);
const projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra");
const filteredItems = computed(() => {
const q = tableQ.value.toLowerCase();
return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q));
});
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
async function loadProjects() {
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
projects.value = r.projects ?? [];
}
async function loadControl() {
if (!projectId.value) return;
const [s, i] = await Promise.all([
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
]);
summary.value = s.data ?? null;
items.value = i.data?.items ?? [];
}
onMounted(async () => {
await loadProjects();
desk.configure({
tab: "Control presupuesto",
treeTitle: "Obras",
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
onTreeSelect: (key) => {
if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`);
},
});
await loadControl();
});
watch(projectId, loadControl);
</script>
<style scoped>
.kpi-row { display: flex; flex-wrap: wrap; gap: 12px; }
.kpi-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 14px;
min-width: 140px;
}
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
.cost-critical-text { color: #b42318; font-weight: 600; }
</style>

235
web-panel/pages/gastos.vue Normal file
View file

@ -0,0 +1,235 @@
<template>
<div class="gastos-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
<Chip :label="projectLabel" icon="pi pi-building" />
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" />
<Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
</DeskTableBar>
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
{{ budgetWarning.message }}
</Message>
<DataTable
:value="listed"
data-key="id"
paginator
:rows="25"
empty-message="No hay gastos en el periodo seleccionado."
scrollable
scroll-height="flex"
table-style="min-width: 56rem"
>
<Column field="expense_date" header="Fecha" sortable style="width: 7rem" />
<Column field="description" header="Descripción" sortable />
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
<Column field="budget_item_code" header="Concepto" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.budget_item_code || "—" }}</span></template>
</Column>
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
<template #body="{ data }">{{ money(data.subtotal) }}</template>
</Column>
<Column field="iva_amount" header="IVA" sortable style="width: 8rem">
<template #body="{ data }">{{ money(data.iva_amount) }}</template>
</Column>
<Column field="total" header="Total" sortable style="width: 9rem">
<template #body="{ data }"><strong>{{ money(data.total) }}</strong></template>
</Column>
<Column field="source" header="Origen" style="width: 7rem" />
</DataTable>
<Dialog v-model:visible="showForm" header="Nuevo gasto" modal :style="{ width: '720px' }">
<div class="form-grid form-grid-compact">
<FormField label="Empresa" required>
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
</FormField>
<FormField label="Obra (opcional)">
<Select v-model="form.project_id" :options="projects" option-label="name" option-value="id" show-clear placeholder="Gasto de empresa" class="w-full" />
</FormField>
<FormField label="Fecha" required>
<InputText v-model="form.expense_date" type="date" class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker v-if="form.project_id" v-model="form.budget_item_id" :project-id="form.project_id" />
<InputText v-else disabled placeholder="Seleccione una obra para ligar concepto" class="w-full" />
</FormField>
<FormField label="Descripción" class="span-2" required>
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
</FormField>
<FormField label="Proveedor">
<InputText v-model="form.supplier_name" class="w-full" />
</FormField>
<FormField label="RFC proveedor">
<InputText v-model="form.supplier_rfc" class="w-full identifier-input" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Unidad">
<InputText v-model="form.unit" class="w-full" />
</FormField>
<FormField label="Precio unitario">
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<FormField label="Subtotal">
<InputNumber v-model="form.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<FormField label="IVA incluido en total">
<Checkbox v-model="form.tax_included" binary />
</FormField>
<FormField label="Total con IVA">
<InputNumber v-model="form.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<FormField label="Comprobante PDF" class="span-2">
<input type="file" accept="application/pdf" @change="onFile" />
</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" @click="save" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
import { displayErrorMessage } from "~/composables/api-response";
type Expense = {
id: number;
expense_date: string;
description: string;
supplier_name: string;
budget_item_code?: string;
subtotal: number;
iva_amount: number;
total: number;
source: string;
project_id?: number | null;
};
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const route = useRoute();
const tableQ = ref("");
const expenses = ref<Expense[]>([]);
const companies = ref<{ id: number; name: string }[]>([]);
const projects = ref<{ id: number; name: string }[]>([]);
const showForm = ref(false);
const formError = ref("");
const budgetWarning = ref<{ level: string; message: string } | null>(null);
const receiptFile = ref<File | null>(null);
const form = reactive({
company_id: null as number | null,
project_id: null as number | null,
expense_date: new Date().toISOString().slice(0, 10),
budget_item_id: null as number | null,
description: "",
supplier_name: "",
supplier_rfc: "",
quantity: 0,
unit: "",
unit_price: 0,
subtotal: 0,
total: 0,
tax_included: false,
});
const projectId = computed(() => Number(route.query.project_id || 0) || null);
const projectLabel = computed(() => {
const p = projects.value.find((x) => x.id === projectId.value);
return p?.name || "Todas las obras";
});
const listed = computed(() => {
const q = tableQ.value.toLowerCase();
return expenses.value.filter((e) =>
!q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q),
);
});
const totalSubtotal = computed(() => listed.value.reduce((s, e) => s + Number(e.subtotal || 0), 0));
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
function onFile(e: Event) {
const input = e.target as HTMLInputElement;
receiptFile.value = input.files?.[0] ?? null;
}
async function loadCatalogs() {
const [cat, pr] = await Promise.all([
api<{ companies?: { id: number; name: string }[] }>("/v1/catalogs"),
api<{ projects?: { id: number; name: string }[] }>("/v1/projects"),
]);
companies.value = cat.companies ?? [];
projects.value = pr.projects ?? [];
if (!form.company_id && companies.value[0]) form.company_id = companies.value[0].id;
}
async function loadExpenses() {
const qs = new URLSearchParams();
if (projectId.value) qs.set("project_id", String(projectId.value));
const r = await api<{ data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
expenses.value = (r.data?.expenses ?? []) as Expense[];
}
async function save() {
formError.value = "";
if (!form.company_id || !form.description.trim()) {
formError.value = "Empresa y descripción son obligatorios";
return;
}
try {
const r = await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
"/v1/expenses",
{
method: "POST",
json: { ...form, project_id: form.project_id || null },
},
);
const expenseId = r.data?.expense?.id;
if (expenseId && receiptFile.value) {
const fd = new FormData();
fd.append("file", receiptFile.value);
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
}
budgetWarning.value = r.data?.budget_warning ?? null;
toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 });
showForm.value = false;
await loadExpenses();
} catch (e) {
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
}
onMounted(async () => {
await loadCatalogs();
desk.configure({
tab: "Gastos",
treeTitle: "Obras",
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
onTreeSelect: (key) => {
if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`);
},
ribbon: [
{
key: "gastos",
label: "Captura",
items: [
{ key: "new", label: "Nuevo gasto", icon: "pi pi-plus", command: () => { showForm.value = true; } },
],
},
],
});
await loadExpenses();
});
watch(projectId, loadExpenses);
</script>

View file

@ -0,0 +1,78 @@
<template>
<div class="usuarios-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar usuario">
<Chip :label="`${users.length} usuarios`" icon="pi pi-users" />
</DeskTableBar>
<DataTable :value="listed" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="username" header="Usuario" style="width: 10rem" />
<Column field="display_name" header="Nombre" />
<Column field="role_code" header="Rol" style="width: 9rem" />
<Column field="email" header="Correo" style="width: 14rem" />
</DataTable>
<Panel header="Permisos del rol operativo (user)" class="mt-4">
<div class="perm-grid">
<div v-for="perm in permissions" :key="perm.code" class="perm-item">
<Checkbox v-model="rolePerms" :input-id="perm.code" :value="perm.code" />
<label :for="perm.code">{{ perm.label }}</label>
</div>
</div>
<Button label="Guardar permisos" icon="pi pi-save" class="mt-3" @click="saveRolePerms" />
</Panel>
</div>
</template>
<script setup lang="ts">
import { displayErrorMessage } from "~/composables/api-response";
type UserRow = { id: number; username: string; display_name: string; role_code: string; email: string };
type PermRow = { code: string; label: string };
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const tableQ = ref("");
const users = ref<UserRow[]>([]);
const permissions = ref<PermRow[]>([]);
const rolePerms = ref<string[]>([]);
const listed = computed(() => {
const q = tableQ.value.toLowerCase();
return users.value.filter((u) => !q || [u.username, u.display_name, u.email].join(" ").toLowerCase().includes(q));
});
async function load() {
const [u, p, rp] = await Promise.all([
api<{ data?: { users?: UserRow[] } }>("/v1/iam/users"),
api<{ data?: { permissions?: PermRow[] } }>("/v1/iam/permissions"),
api<{ data?: { permissions?: string[] } }>("/v1/iam/roles/user/permissions"),
]);
users.value = u.data?.users ?? [];
permissions.value = p.data?.permissions ?? [];
rolePerms.value = rp.data?.permissions ?? [];
}
async function saveRolePerms() {
try {
const r = await api<{ message?: string }>("/v1/iam/roles/user/permissions", {
method: "PUT",
json: { permissions: rolePerms.value },
});
toast.add({ severity: "success", summary: r.message || "Permisos guardados", life: 2500 });
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
onMounted(async () => {
desk.configure({ tab: "Usuarios IAM", ribbon: [] });
await load();
});
</script>
<style scoped>
.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; }
.perm-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
</style>