diff --git a/api/cost_control_http.ts b/api/cost_control_http.ts new file mode 100644 index 0000000..fee67f4 --- /dev/null +++ b/api/cost_control_http.ts @@ -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); + }); +} diff --git a/api/expenses_http.ts b/api/expenses_http.ts new file mode 100644 index 0000000..7db15d3 --- /dev/null +++ b/api/expenses_http.ts @@ -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>(); + 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>(); + 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>(); + 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); + }); +} diff --git a/api/expenses_test.ts b/api/expenses_test.ts new file mode 100644 index 0000000..4178d80 --- /dev/null +++ b/api/expenses_test.ts @@ -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); +}); diff --git a/api/iam_http.ts b/api/iam_http.ts new file mode 100644 index 0000000..daad78c --- /dev/null +++ b/api/iam_http.ts @@ -0,0 +1,65 @@ +import type { Hono } from "hono"; +import type { AuthUser } from "./auth.ts"; +import { requireAuth, tenantScope } from "./auth.ts"; +import { callCoreFn } from "./rpc.ts"; +import { respondRpc, routeLabel } from "./http_errors.ts"; +import { requirePermission, callIamFn } from "./permissions.ts"; + +type App = Hono<{ Variables: { user: AuthUser } }>; + +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[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[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[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[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, + }); + }); +} diff --git a/api/main.ts b/api/main.ts index 23619b4..eac39e5 100644 --- a/api/main.ts +++ b/api/main.ts @@ -56,6 +56,10 @@ import { } from "./mx.ts"; import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.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 { buildBudgetTemplate, @@ -1340,6 +1344,10 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) = }); registerPayrollRoutes(app); +registerExpenseRoutes(app); +registerWarehouseRoutes(app); +registerCostControlRoutes(app); +registerIamRoutes(app); const port = config.port; diff --git a/api/permissions.ts b/api/permissions.ts new file mode 100644 index 0000000..deb43f0 --- /dev/null +++ b/api/permissions.ts @@ -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; at: number }>(); +const CACHE_TTL_MS = 60_000; + +export async function userPermissions( + tenantId: number | null, + roleCode: string, +): Promise> { + 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 { + 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( + tenantId: number | null, + fn: string, + payload: Record = {}, +): Promise { + 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; +} diff --git a/api/storage.ts b/api/storage.ts index 79aedb1..4ed3c3a 100644 --- a/api/storage.ts +++ b/api/storage.ts @@ -175,6 +175,9 @@ export function badgeJobPdfKey(jobId: number): string { export function loanReceiptPdfKey(loanId: number, weekId: number): string { 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 { return `logos/${projectId}-${side}.${ext}`; } diff --git a/api/warehouse_http.ts b/api/warehouse_http.ts new file mode 100644 index 0000000..89ed384 --- /dev/null +++ b/api/warehouse_http.ts @@ -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>().catch(() => ({})); + 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>(); + 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>(); + 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>(); + 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>(); + 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); + }); +} diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index f1eee80..45c7c9d 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -24,5 +24,14 @@ + + + + + + + + + diff --git a/db/core/changesets/018-schema-expenses-cost.sql b/db/core/changesets/018-schema-expenses-cost.sql new file mode 100644 index 0000000..f5d30d4 --- /dev/null +++ b/db/core/changesets/018-schema-expenses-cost.sql @@ -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) +); diff --git a/db/core/changesets/019-schema-warehouse.sql b/db/core/changesets/019-schema-warehouse.sql new file mode 100644 index 0000000..23b16d2 --- /dev/null +++ b/db/core/changesets/019-schema-warehouse.sql @@ -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; diff --git a/db/core/changesets/020-rls-expenses-warehouse.sql b/db/core/changesets/020-rls-expenses-warehouse.sql new file mode 100644 index 0000000..1cab97b --- /dev/null +++ b/db/core/changesets/020-rls-expenses-warehouse.sql @@ -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); diff --git a/db/core/changesets/021-cost-control-helpers.sql b/db/core/changesets/021-cost-control-helpers.sql new file mode 100644 index 0000000..2327762 --- /dev/null +++ b/db/core/changesets/021-cost-control-helpers.sql @@ -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; diff --git a/db/core/changesets/022-rpc-expenses.sql b/db/core/changesets/022-rpc-expenses.sql new file mode 100644 index 0000000..f7aa5af --- /dev/null +++ b/db/core/changesets/022-rpc-expenses.sql @@ -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; diff --git a/db/core/changesets/023-rpc-warehouse.sql b/db/core/changesets/023-rpc-warehouse.sql new file mode 100644 index 0000000..a20c4aa --- /dev/null +++ b/db/core/changesets/023-rpc-warehouse.sql @@ -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; diff --git a/db/core/changesets/024-rpc-cost-control.sql b/db/core/changesets/024-rpc-cost-control.sql new file mode 100644 index 0000000..c439306 --- /dev/null +++ b/db/core/changesets/024-rpc-cost-control.sql @@ -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; diff --git a/db/core/changesets/025-rpc-payroll-cost-sync.sql b/db/core/changesets/025-rpc-payroll-cost-sync.sql new file mode 100644 index 0000000..52ff880 --- /dev/null +++ b/db/core/changesets/025-rpc-payroll-cost-sync.sql @@ -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; diff --git a/db/core/changesets/026-rpc-project-update-warehouse.sql b/db/core/changesets/026-rpc-project-update-warehouse.sql new file mode 100644 index 0000000..f8dd4be --- /dev/null +++ b/db/core/changesets/026-rpc-project-update-warehouse.sql @@ -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; +$$; diff --git a/db/iam/changelog-master.xml b/db/iam/changelog-master.xml index 15ba648..ed397c7 100644 --- a/db/iam/changelog-master.xml +++ b/db/iam/changelog-master.xml @@ -8,5 +8,7 @@ + + diff --git a/db/iam/changesets/003-expenses-warehouse-permissions.sql b/db/iam/changesets/003-expenses-warehouse-permissions.sql new file mode 100644 index 0000000..7e6cfc4 --- /dev/null +++ b/db/iam/changesets/003-expenses-warehouse-permissions.sql @@ -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; diff --git a/db/iam/changesets/004-rpc-users-permissions.sql b/db/iam/changesets/004-rpc-users-permissions.sql new file mode 100644 index 0000000..9e10e51 --- /dev/null +++ b/db/iam/changesets/004-rpc-users-permissions.sql @@ -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; diff --git a/scripts/crud-smoke-test.sh b/scripts/crud-smoke-test.sh index 7d940a4..3652872 100755 --- a/scripts/crud-smoke-test.sh +++ b/scripts/crud-smoke-test.sh @@ -164,5 +164,21 @@ BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1) check_status "catalogs" "200" "$CODE" "$BODY" 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 "All CRUD smoke tests passed." diff --git a/web-panel/assets/css/desktop-v2.css b/web-panel/assets/css/desktop-v2.css index 6f8b8e6..a905613 100644 --- a/web-panel/assets/css/desktop-v2.css +++ b/web-panel/assets/css/desktop-v2.css @@ -2231,3 +2231,8 @@ html.dark .p-dialog .p-dialog-header-icon { .detail-tabs .nomina-loan-fieldset { 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; } diff --git a/web-panel/components/BudgetItemPicker.vue b/web-panel/components/BudgetItemPicker.vue new file mode 100644 index 0000000..2c0f777 --- /dev/null +++ b/web-panel/components/BudgetItemPicker.vue @@ -0,0 +1,73 @@ + + + diff --git a/web-panel/components/CostSemaphore.vue b/web-panel/components/CostSemaphore.vue new file mode 100644 index 0000000..faf04ed --- /dev/null +++ b/web-panel/components/CostSemaphore.vue @@ -0,0 +1,18 @@ + + + diff --git a/web-panel/layouts/default.vue b/web-panel/layouts/default.vue index c5b7413..4ebef2a 100644 --- a/web-panel/layouts/default.vue +++ b/web-panel/layouts/default.vue @@ -144,10 +144,14 @@ const menu = [ { label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") }, { label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") }, { 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: "Kanban", icon: "pi pi-th-large", to: "/kanban", command: () => navigateTo("/kanban") }, { 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: "Usuarios", icon: "pi pi-shield", to: "/usuarios", command: () => navigateTo("/usuarios") }, { label: "Ayuda", icon: "pi pi-question-circle", command: showHelp }, ]; diff --git a/web-panel/pages/almacen.vue b/web-panel/pages/almacen.vue new file mode 100644 index 0000000..6f30a90 --- /dev/null +++ b/web-panel/pages/almacen.vue @@ -0,0 +1,235 @@ + + + diff --git a/web-panel/pages/control-presupuesto.vue b/web-panel/pages/control-presupuesto.vue new file mode 100644 index 0000000..99f4a67 --- /dev/null +++ b/web-panel/pages/control-presupuesto.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/web-panel/pages/gastos.vue b/web-panel/pages/gastos.vue new file mode 100644 index 0000000..df10f9b --- /dev/null +++ b/web-panel/pages/gastos.vue @@ -0,0 +1,235 @@ +