mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:13:18 +00:00
feat: gastos, almacén central/obra y control presupuestal
Implementa el módulo integral de costos según el plan acordado: - Esquema Liquibase: expense_entries, almacén (central + obra), IVA, tenant_cost_settings, permisos IAM y RLS - Funciones RPC core: gastos, almacén, control presupuestal, sync nómina, cierre de almacén al concluir proyecto - API HTTP: expenses_http, warehouse_http, cost_control_http, iam_http - Middleware requirePermission con matriz IAM - UI web-panel: /gastos, /almacen, /control-presupuesto, /usuarios - Componentes BudgetItemPicker, CostSemaphore y semáforos CSS - Smoke tests extendidos para gastos/almacén/control Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
parent
b9bf7044b2
commit
ab89727522
30 changed files with 3322 additions and 0 deletions
53
api/cost_control_http.ts
Normal file
53
api/cost_control_http.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import type { AuthUser } from "./auth.ts";
|
||||||
|
import { tenantScope } from "./auth.ts";
|
||||||
|
import { requireCoreAuth } from "./scope.ts";
|
||||||
|
import type { Db } from "./db.ts";
|
||||||
|
import { callCoreFn } from "./rpc.ts";
|
||||||
|
import { respondRpc, routeLabel } from "./http_errors.ts";
|
||||||
|
import { requireAnyPermission } from "./permissions.ts";
|
||||||
|
|
||||||
|
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||||
|
|
||||||
|
function tid(c: { get: (k: "user") => AuthUser }): number {
|
||||||
|
return tenantScope(c.get("user")) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCostControlRoutes(app: App) {
|
||||||
|
app.get("/v1/projects/:id/cost-control/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_cost_control_project_summary", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
project_id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/projects/:id/cost-control/items", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_cost_control_by_item", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
project_id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/projects/:id/cost-control/chapters", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_cost_control_by_chapter", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
project_id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/projects/:id/cost-control/deviations", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_cost_control_top_deviations", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
project_id: Number(c.req.param("id")),
|
||||||
|
limit: c.req.query("limit"),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
}
|
||||||
146
api/expenses_http.ts
Normal file
146
api/expenses_http.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import type { AuthUser } from "./auth.ts";
|
||||||
|
import { tenantScope } from "./auth.ts";
|
||||||
|
import { requireCoreAuth } from "./scope.ts";
|
||||||
|
import type { Db } from "./db.ts";
|
||||||
|
import { callCoreFn } from "./rpc.ts";
|
||||||
|
import { respondRpc, routeLabel } from "./http_errors.ts";
|
||||||
|
import { requireAnyPermission, requirePermission } from "./permissions.ts";
|
||||||
|
import { encryptBytes, decryptBytes } from "./docs_crypto.ts";
|
||||||
|
import { expenseDocKey, getObject, putObject, s3Configured } from "./storage.ts";
|
||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||||
|
|
||||||
|
function tid(c: { get: (k: "user") => AuthUser }): number {
|
||||||
|
return tenantScope(c.get("user")) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actor(c: Context): { id: number; name: string } {
|
||||||
|
const u = c.get("user");
|
||||||
|
return { id: u.id, name: u.display_name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerExpenseRoutes(app: App) {
|
||||||
|
app.get("/v1/expenses", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_expense_list", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
project_id: c.req.query("project_id"),
|
||||||
|
company_id: c.req.query("company_id"),
|
||||||
|
date_from: c.req.query("date_from"),
|
||||||
|
date_to: c.req.query("date_to"),
|
||||||
|
budget_item_id: c.req.query("budget_item_id"),
|
||||||
|
source: c.req.query("source"),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/expenses/:id", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_expense_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/expenses", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const a = actor(c);
|
||||||
|
const env = await callCoreFn(db, "core.fn_expense_create", {
|
||||||
|
...body,
|
||||||
|
tenant_id: tid(c),
|
||||||
|
created_by_id: a.id,
|
||||||
|
created_by_name: a.name,
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_expense_update", {
|
||||||
|
...body,
|
||||||
|
id: Number(c.req.param("id")),
|
||||||
|
tenant_id: tid(c),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/v1/expenses/:id", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_expense_void", { id: Number(c.req.param("id")) }, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/expenses/:id/attachments", ...requireCoreAuth, requirePermission("manage_expenses"), async (c) => {
|
||||||
|
const expenseId = Number(c.req.param("id"));
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
const env = await callCoreFn(c.get("db"), "core.fn_expense_attachment_add", {
|
||||||
|
expense_id: expenseId,
|
||||||
|
...JSON.parse(String(form.get("meta") ?? "{}")),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const { iv, cipher } = await encryptBytes(bytes);
|
||||||
|
const storageName = `${Date.now()}-${randomBytes(8).toString("hex")}`;
|
||||||
|
if (s3Configured()) {
|
||||||
|
await putObject(expenseDocKey(expenseId, storageName), cipher);
|
||||||
|
}
|
||||||
|
const db = c.get("db");
|
||||||
|
const a = actor(c);
|
||||||
|
const env = await callCoreFn(db, "core.fn_expense_attachment_add", {
|
||||||
|
expense_id: expenseId,
|
||||||
|
original_name: file.name,
|
||||||
|
mime: file.type || "application/pdf",
|
||||||
|
size_bytes: bytes.length,
|
||||||
|
sha256: "",
|
||||||
|
iv,
|
||||||
|
storage_name: storageName,
|
||||||
|
uploaded_by_id: a.id,
|
||||||
|
uploaded_by_name: a.name,
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/expenses/:id/attachments/:attId", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||||
|
const expenseId = Number(c.req.param("id"));
|
||||||
|
const attId = Number(c.req.param("attId"));
|
||||||
|
const db = c.get("db");
|
||||||
|
const row = await db.prepare(
|
||||||
|
"SELECT storage_name, iv, mime, original_name FROM expense_attachments WHERE id = ? AND expense_id = ?",
|
||||||
|
).get(attId, expenseId) as { storage_name: string; iv: string; mime: string; original_name: string } | undefined;
|
||||||
|
if (!row) return respondRpc(c, { ok: false, code: "NOT_FOUND", layer: "api", message: "Comprobante no encontrado", data: null, errors: null });
|
||||||
|
const enc = await getObject(expenseDocKey(expenseId, row.storage_name));
|
||||||
|
const plain = await decryptBytes(row.iv, enc);
|
||||||
|
c.header("Content-Type", row.mime);
|
||||||
|
c.header("Content-Disposition", `inline; filename="${row.original_name}"`);
|
||||||
|
return c.body(plain.buffer as ArrayBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/iva/summary", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_expenses"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_iva_period_summary", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
company_id: c.req.query("company_id"),
|
||||||
|
year: c.req.query("year"),
|
||||||
|
month: c.req.query("month"),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/cost-settings", ...requireCoreAuth, requireAnyPermission("view_expenses", "manage_cost_settings"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_cost_settings_get", { tenant_id: tid(c) }, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/v1/cost-settings", ...requireCoreAuth, requirePermission("manage_cost_settings"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_cost_settings_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
}
|
||||||
10
api/expenses_test.ts
Normal file
10
api/expenses_test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { assertEquals } from "jsr:@std/assert";
|
||||||
|
import { mapRpcToStatus } from "./http_errors.ts";
|
||||||
|
|
||||||
|
Deno.test("mapRpcToStatus CREATED", () => {
|
||||||
|
assertEquals(mapRpcToStatus("CREATED"), 201);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("mapRpcToStatus FORBIDDEN", () => {
|
||||||
|
assertEquals(mapRpcToStatus("FORBIDDEN"), 403);
|
||||||
|
});
|
||||||
65
api/iam_http.ts
Normal file
65
api/iam_http.ts
Normal file
|
|
@ -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<typeof respondRpc>[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/iam/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
|
||||||
|
const tid = tenantScope(c.get("user"));
|
||||||
|
const row = await callIamFn(tid, "iam.fn_permission_list", {});
|
||||||
|
if (!row) {
|
||||||
|
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos IAM", data: null, errors: null });
|
||||||
|
}
|
||||||
|
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
|
||||||
|
const tid = tenantScope(c.get("user"));
|
||||||
|
const row = await callIamFn(tid, "iam.fn_role_permissions_get", { role_code: c.req.param("role") });
|
||||||
|
if (!row) {
|
||||||
|
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo consultar permisos del rol", data: null, errors: null });
|
||||||
|
}
|
||||||
|
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/v1/iam/roles/:role/permissions", requireAuth, requirePermission("manage_users"), async (c) => {
|
||||||
|
const body = await c.req.json<{ permissions?: string[] }>();
|
||||||
|
const tid = tenantScope(c.get("user"));
|
||||||
|
const row = await callIamFn(tid, "iam.fn_role_permissions_save", {
|
||||||
|
role_code: c.req.param("role"),
|
||||||
|
permissions: body.permissions ?? [],
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
return respondRpc(c, { ok: false, code: "INTERNAL", layer: "api", message: "No se pudo guardar permisos del rol", data: null, errors: null });
|
||||||
|
}
|
||||||
|
return respondRpc(c, row as Parameters<typeof respondRpc>[1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/me/permissions", requireAuth, async (c) => {
|
||||||
|
const user = c.get("user");
|
||||||
|
const { userPermissions } = await import("./permissions.ts");
|
||||||
|
const role = user.role === "tenant_admin" ? "tenant_admin" : "user";
|
||||||
|
const perms = await userPermissions(user.tenant_id, role);
|
||||||
|
return respondRpc(c, {
|
||||||
|
ok: true,
|
||||||
|
code: "OK",
|
||||||
|
layer: "api",
|
||||||
|
message: `Permisos del usuario ${user.username}`,
|
||||||
|
data: { permissions: [...perms], role: user.role },
|
||||||
|
errors: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -56,6 +56,10 @@ import {
|
||||||
} from "./mx.ts";
|
} from "./mx.ts";
|
||||||
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
|
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
|
||||||
import { registerPayrollRoutes } from "./payroll_http.ts";
|
import { registerPayrollRoutes } from "./payroll_http.ts";
|
||||||
|
import { registerExpenseRoutes } from "./expenses_http.ts";
|
||||||
|
import { registerWarehouseRoutes } from "./warehouse_http.ts";
|
||||||
|
import { registerCostControlRoutes } from "./cost_control_http.ts";
|
||||||
|
import { registerIamRoutes } from "./iam_http.ts";
|
||||||
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
|
import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
|
||||||
import {
|
import {
|
||||||
buildBudgetTemplate,
|
buildBudgetTemplate,
|
||||||
|
|
@ -1340,6 +1344,10 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) =
|
||||||
});
|
});
|
||||||
|
|
||||||
registerPayrollRoutes(app);
|
registerPayrollRoutes(app);
|
||||||
|
registerExpenseRoutes(app);
|
||||||
|
registerWarehouseRoutes(app);
|
||||||
|
registerCostControlRoutes(app);
|
||||||
|
registerIamRoutes(app);
|
||||||
|
|
||||||
const port = config.port;
|
const port = config.port;
|
||||||
|
|
||||||
|
|
|
||||||
101
api/permissions.ts
Normal file
101
api/permissions.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import type { Context, Next } from "hono";
|
||||||
|
import type { AuthUser } from "./auth.ts";
|
||||||
|
import { tenantScope } from "./auth.ts";
|
||||||
|
import { withIamTenant } from "./iam_db.ts";
|
||||||
|
import { respondApiError, routeLabel } from "./http_errors.ts";
|
||||||
|
|
||||||
|
const permissionCache = new Map<string, { perms: Set<string>; at: number }>();
|
||||||
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
export async function userPermissions(
|
||||||
|
tenantId: number | null,
|
||||||
|
roleCode: string,
|
||||||
|
): Promise<Set<string>> {
|
||||||
|
const key = `${tenantId ?? 0}:${roleCode}`;
|
||||||
|
const hit = permissionCache.get(key);
|
||||||
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.perms;
|
||||||
|
if (roleCode === "tenant_admin") {
|
||||||
|
const all = new Set([
|
||||||
|
"view_expenses", "manage_expenses", "view_warehouse", "manage_warehouse",
|
||||||
|
"close_warehouse", "manage_cost_settings", "manage_users", "manage_budget",
|
||||||
|
"manage_payroll", "manage_workers", "manage_projects", "manage_companies",
|
||||||
|
"manage_documents", "manage_settings", "view_reports",
|
||||||
|
]);
|
||||||
|
permissionCache.set(key, { perms: all, at: Date.now() });
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
const rows = await withIamTenant(tenantId, async (db) =>
|
||||||
|
await db.prepare(
|
||||||
|
"SELECT permission_code FROM role_permissions WHERE role_code = ?",
|
||||||
|
).all(roleCode) as { permission_code: string }[],
|
||||||
|
);
|
||||||
|
const perms = new Set(rows.map((r) => r.permission_code));
|
||||||
|
permissionCache.set(key, { perms, at: Date.now() });
|
||||||
|
return perms;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasPermission(
|
||||||
|
user: AuthUser,
|
||||||
|
code: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (user.role === "tenant_admin") return true;
|
||||||
|
const role = user.role === "user" ? "user" : user.role;
|
||||||
|
const perms = await userPermissions(user.tenant_id, role);
|
||||||
|
return perms.has(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requirePermission(code: string) {
|
||||||
|
return async (c: Context, next: Next) => {
|
||||||
|
const user = c.get("user") as AuthUser;
|
||||||
|
if (user.realm === "platform") {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!await hasPermission(user, code)) {
|
||||||
|
return respondApiError(
|
||||||
|
c,
|
||||||
|
"FORBIDDEN",
|
||||||
|
`Permiso requerido: ${code}`,
|
||||||
|
{ route: routeLabel(c), permission: code, role: user.role },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireAnyPermission(...codes: string[]) {
|
||||||
|
return async (c: Context, next: Next) => {
|
||||||
|
const user = c.get("user") as AuthUser;
|
||||||
|
if (user.realm === "platform") {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const code of codes) {
|
||||||
|
if (await hasPermission(user, code)) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return respondApiError(
|
||||||
|
c,
|
||||||
|
"FORBIDDEN",
|
||||||
|
`Se requiere alguno de: ${codes.join(", ")}`,
|
||||||
|
{ route: routeLabel(c), permissions: codes },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callIamFn<T = unknown>(
|
||||||
|
tenantId: number | null,
|
||||||
|
fn: string,
|
||||||
|
payload: Record<string, unknown> = {},
|
||||||
|
): Promise<T | null> {
|
||||||
|
const row = await withIamTenant(tenantId, async (db) =>
|
||||||
|
await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get(payload) as { result: unknown },
|
||||||
|
);
|
||||||
|
const raw = row?.result;
|
||||||
|
if (raw && typeof raw === "object" && "ok" in (raw as object)) {
|
||||||
|
return raw as T;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -175,6 +175,9 @@ export function badgeJobPdfKey(jobId: number): string {
|
||||||
export function loanReceiptPdfKey(loanId: number, weekId: number): string {
|
export function loanReceiptPdfKey(loanId: number, weekId: number): string {
|
||||||
return `pdfs/prestamo-${loanId}-semana-${weekId}.pdf`;
|
return `pdfs/prestamo-${loanId}-semana-${weekId}.pdf`;
|
||||||
}
|
}
|
||||||
|
export function expenseDocKey(expenseId: number, storageName: string): string {
|
||||||
|
return `expenses/${expenseId}/${storageName}`;
|
||||||
|
}
|
||||||
export function projectLogoKey(projectId: number, side: "left" | "right", ext: string): string {
|
export function projectLogoKey(projectId: number, side: "left" | "right", ext: string): string {
|
||||||
return `logos/${projectId}-${side}.${ext}`;
|
return `logos/${projectId}-${side}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
api/warehouse_http.ts
Normal file
133
api/warehouse_http.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import type { Context, Hono } from "hono";
|
||||||
|
import type { AuthUser } from "./auth.ts";
|
||||||
|
import { tenantScope } from "./auth.ts";
|
||||||
|
import { requireCoreAuth } from "./scope.ts";
|
||||||
|
import type { Db } from "./db.ts";
|
||||||
|
import { callCoreFn } from "./rpc.ts";
|
||||||
|
import { respondRpc, routeLabel } from "./http_errors.ts";
|
||||||
|
import { requireAnyPermission, requirePermission } from "./permissions.ts";
|
||||||
|
|
||||||
|
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||||
|
|
||||||
|
function tid(c: { get: (k: "user") => AuthUser }): number {
|
||||||
|
return tenantScope(c.get("user")) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actor(c: Context): { id: number; name: string } {
|
||||||
|
const u = c.get("user");
|
||||||
|
return { id: u.id, name: u.display_name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerWarehouseRoutes(app: App) {
|
||||||
|
app.get("/v1/warehouses", ...requireCoreAuth, requireAnyPermission("view_warehouse", "manage_warehouse"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_warehouse_list", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
include_closed: c.req.query("include_closed") === "true",
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/warehouses/central/ensure", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_warehouse_ensure_central", { tenant_id: tid(c) }, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/projects/:id/warehouse/open", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_warehouse_open_project", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
project_id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("close_warehouse"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>().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<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const a = actor(c);
|
||||||
|
const env = await callCoreFn(db, "core.fn_warehouse_entry_create", {
|
||||||
|
...body,
|
||||||
|
tenant_id: tid(c),
|
||||||
|
warehouse_id: Number(c.req.param("id")),
|
||||||
|
created_by_id: a.id,
|
||||||
|
created_by_name: a.name,
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/warehouses/:id/exits", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const a = actor(c);
|
||||||
|
const env = await callCoreFn(db, "core.fn_warehouse_exit_create", {
|
||||||
|
...body,
|
||||||
|
tenant_id: tid(c),
|
||||||
|
warehouse_id: Number(c.req.param("id")),
|
||||||
|
created_by_id: a.id,
|
||||||
|
created_by_name: a.name,
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/warehouse-transfers", ...requireCoreAuth, requirePermission("manage_warehouse"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
const a = actor(c);
|
||||||
|
const env = await callCoreFn(db, "core.fn_warehouse_transfer_create", {
|
||||||
|
...body,
|
||||||
|
tenant_id: tid(c),
|
||||||
|
created_by_name: a.name,
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -24,5 +24,14 @@
|
||||||
<include file="changesets/015-rpc-documents.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/015-rpc-documents.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/016-rpc-worker-needs-badge.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/016-rpc-worker-needs-badge.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/017-rpc-worker-normalize-badge.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/017-rpc-worker-normalize-badge.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/018-schema-expenses-cost.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/019-schema-warehouse.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/020-rls-expenses-warehouse.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/021-cost-control-helpers.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/022-rpc-expenses.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/023-rpc-warehouse.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/024-rpc-cost-control.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/025-rpc-payroll-cost-sync.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/026-rpc-project-update-warehouse.sql" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
103
db/core/changesets/018-schema-expenses-cost.sql
Normal file
103
db/core/changesets/018-schema-expenses-cost.sql
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · gastos, IVA y configuración de control de costos
|
||||||
|
|
||||||
|
--changeset panel:core-018a-tenant-cost-settings endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='tenant_cost_settings'
|
||||||
|
CREATE TABLE core.tenant_cost_settings (
|
||||||
|
tenant_id INTEGER PRIMARY KEY,
|
||||||
|
budget_warn_pct NUMERIC(5,2) NOT NULL DEFAULT 85,
|
||||||
|
budget_critical_pct NUMERIC(5,2) NOT NULL DEFAULT 100,
|
||||||
|
default_iva_rate NUMERIC(5,4) NOT NULL DEFAULT 0.16,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
--changeset panel:core-018b-expense-entries endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='expense_entries'
|
||||||
|
CREATE TABLE core.expense_entries (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
company_id BIGINT NOT NULL REFERENCES core.companies(id),
|
||||||
|
project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
|
||||||
|
expense_date DATE NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'payroll', 'destajo')),
|
||||||
|
source_ref_id BIGINT,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
supplier_name TEXT NOT NULL DEFAULT '',
|
||||||
|
supplier_rfc TEXT NOT NULL DEFAULT '',
|
||||||
|
budget_chapter_id BIGINT REFERENCES core.budget_chapters(id) ON DELETE SET NULL,
|
||||||
|
budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL,
|
||||||
|
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||||
|
unit TEXT NOT NULL DEFAULT '',
|
||||||
|
unit_price NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||||
|
subtotal NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
iva_rate NUMERIC(5,4) NOT NULL DEFAULT 0.16,
|
||||||
|
iva_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_included BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_deductible BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
counts_toward_budget BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
is_locked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('draft', 'confirmed', 'void')),
|
||||||
|
created_by_id INTEGER,
|
||||||
|
created_by_name TEXT NOT NULL DEFAULT '',
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_core_expense_entries_tenant ON core.expense_entries(tenant_id);
|
||||||
|
CREATE INDEX idx_core_expense_entries_project_date ON core.expense_entries(project_id, expense_date);
|
||||||
|
CREATE INDEX idx_core_expense_entries_budget_item ON core.expense_entries(budget_item_id);
|
||||||
|
CREATE UNIQUE INDEX idx_core_expense_entries_source_ref
|
||||||
|
ON core.expense_entries(source, source_ref_id)
|
||||||
|
WHERE source_ref_id IS NOT NULL AND status <> 'void';
|
||||||
|
|
||||||
|
--changeset panel:core-018c-expense-attachments endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='expense_attachments'
|
||||||
|
CREATE TABLE core.expense_attachments (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
expense_id BIGINT NOT NULL REFERENCES core.expense_entries(id) ON DELETE CASCADE,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
iv TEXT NOT NULL,
|
||||||
|
storage_name TEXT NOT NULL,
|
||||||
|
uploaded_by_id INTEGER,
|
||||||
|
uploaded_by_name TEXT NOT NULL DEFAULT '',
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
--changeset panel:core-018d-iva-period-manual endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='iva_period_entries'
|
||||||
|
CREATE TABLE core.iva_period_entries (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
company_id BIGINT NOT NULL REFERENCES core.companies(id),
|
||||||
|
period_year INTEGER NOT NULL,
|
||||||
|
period_month INTEGER NOT NULL CHECK (period_month BETWEEN 1 AND 12),
|
||||||
|
iva_trasladado NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_by_name TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, company_id, period_year, period_month)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_core_iva_period_tenant ON core.iva_period_entries(tenant_id);
|
||||||
|
|
||||||
|
--changeset panel:core-018e-destajo-budget-item endDelimiter:; splitStatements:true
|
||||||
|
ALTER TABLE core.destajo_jobs
|
||||||
|
ADD COLUMN IF NOT EXISTS budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
--changeset panel:core-018f-warehouse-material-budget-links endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='warehouse_material_budget_links'
|
||||||
|
CREATE TABLE core.warehouse_material_budget_links (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
material_id BIGINT NOT NULL,
|
||||||
|
budget_item_id BIGINT NOT NULL REFERENCES core.budget_items(id) ON DELETE CASCADE,
|
||||||
|
conversion_factor NUMERIC(14,6) NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE (material_id, budget_item_id)
|
||||||
|
);
|
||||||
94
db/core/changesets/019-schema-warehouse.sql
Normal file
94
db/core/changesets/019-schema-warehouse.sql
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · almacén central y por obra
|
||||||
|
|
||||||
|
--changeset panel:core-019a-warehouse-materials endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='warehouse_materials'
|
||||||
|
CREATE TABLE core.warehouse_materials (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
unit TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_id, code)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_core_warehouse_materials_tenant ON core.warehouse_materials(tenant_id);
|
||||||
|
|
||||||
|
--changeset panel:core-019b-warehouses endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='warehouses'
|
||||||
|
CREATE TABLE core.warehouses (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('central', 'project')),
|
||||||
|
project_id BIGINT REFERENCES core.projects(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'closing', 'closed')),
|
||||||
|
opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
closed_at TIMESTAMPTZ,
|
||||||
|
closed_by_name TEXT NOT NULL DEFAULT '',
|
||||||
|
close_notes TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_core_warehouses_central ON core.warehouses(tenant_id) WHERE kind = 'central';
|
||||||
|
CREATE UNIQUE INDEX idx_core_warehouses_project ON core.warehouses(project_id) WHERE kind = 'project' AND project_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_core_warehouses_tenant ON core.warehouses(tenant_id);
|
||||||
|
|
||||||
|
--changeset panel:core-019c-warehouse-stock endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='warehouse_stock'
|
||||||
|
CREATE TABLE core.warehouse_stock (
|
||||||
|
warehouse_id BIGINT NOT NULL REFERENCES core.warehouses(id) ON DELETE CASCADE,
|
||||||
|
material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id) ON DELETE CASCADE,
|
||||||
|
qty_on_hand NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||||
|
avg_unit_cost NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (warehouse_id, material_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
--changeset panel:core-019d-warehouse-transfers endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='warehouse_transfers'
|
||||||
|
CREATE TABLE core.warehouse_transfers (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
from_warehouse_id BIGINT NOT NULL REFERENCES core.warehouses(id),
|
||||||
|
to_warehouse_id BIGINT NOT NULL REFERENCES core.warehouses(id),
|
||||||
|
material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id),
|
||||||
|
quantity NUMERIC(14,4) NOT NULL,
|
||||||
|
unit_cost NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'completed' CHECK (status IN ('pending', 'completed', 'cancelled')),
|
||||||
|
transfer_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by_name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_core_warehouse_transfers_tenant ON core.warehouse_transfers(tenant_id);
|
||||||
|
|
||||||
|
--changeset panel:core-019e-warehouse-movements endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='warehouse_movements'
|
||||||
|
CREATE TABLE core.warehouse_movements (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
warehouse_id BIGINT NOT NULL REFERENCES core.warehouses(id),
|
||||||
|
material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id),
|
||||||
|
movement_type TEXT NOT NULL CHECK (movement_type IN ('entry', 'exit', 'transfer_out', 'transfer_in', 'adjustment')),
|
||||||
|
transfer_id BIGINT REFERENCES core.warehouse_transfers(id) ON DELETE SET NULL,
|
||||||
|
movement_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
quantity NUMERIC(14,4) NOT NULL,
|
||||||
|
unit_cost NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||||
|
total_cost NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL,
|
||||||
|
destination_note TEXT NOT NULL DEFAULT '',
|
||||||
|
expense_id BIGINT REFERENCES core.expense_entries(id) ON DELETE SET NULL,
|
||||||
|
adjustment_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by_id INTEGER,
|
||||||
|
created_by_name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_core_warehouse_movements_wh ON core.warehouse_movements(warehouse_id, movement_date);
|
||||||
|
CREATE INDEX idx_core_warehouse_movements_budget ON core.warehouse_movements(budget_item_id);
|
||||||
|
|
||||||
|
--changeset panel:core-019f-warehouse-material-links-fk endDelimiter:; splitStatements:true
|
||||||
|
ALTER TABLE core.warehouse_material_budget_links
|
||||||
|
ADD CONSTRAINT warehouse_material_budget_links_material_fk
|
||||||
|
FOREIGN KEY (material_id) REFERENCES core.warehouse_materials(id) ON DELETE CASCADE;
|
||||||
50
db/core/changesets/020-rls-expenses-warehouse.sql
Normal file
50
db/core/changesets/020-rls-expenses-warehouse.sql
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · RLS gastos y almacén
|
||||||
|
|
||||||
|
--changeset panel:core-020a-rls-expense-entries endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='expense_entries' AND policyname='tenant_isolation'
|
||||||
|
ALTER TABLE core.expense_entries ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON core.expense_entries
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
|
||||||
|
--changeset panel:core-020b-rls-iva-period endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='iva_period_entries' AND policyname='tenant_isolation'
|
||||||
|
ALTER TABLE core.iva_period_entries ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON core.iva_period_entries
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
|
||||||
|
--changeset panel:core-020c-rls-warehouse-materials endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_materials' AND policyname='tenant_isolation'
|
||||||
|
ALTER TABLE core.warehouse_materials ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON core.warehouse_materials
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
|
||||||
|
--changeset panel:core-020d-rls-warehouses endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouses' AND policyname='tenant_isolation'
|
||||||
|
ALTER TABLE core.warehouses ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON core.warehouses
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
|
||||||
|
--changeset panel:core-020e-rls-warehouse-transfers endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_transfers' AND policyname='tenant_isolation'
|
||||||
|
ALTER TABLE core.warehouse_transfers ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON core.warehouse_transfers
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
|
||||||
|
--changeset panel:core-020f-rls-warehouse-movements endDelimiter:; splitStatements:true
|
||||||
|
--preconditions onFail:MARK_RAN
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_movements' AND policyname='tenant_isolation'
|
||||||
|
ALTER TABLE core.warehouse_movements ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON core.warehouse_movements
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
180
db/core/changesets/021-cost-control-helpers.sql
Normal file
180
db/core/changesets/021-cost-control-helpers.sql
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · helpers de control de costos (compartidos por gastos/almacén)
|
||||||
|
|
||||||
|
--changeset panel:core-021a-cost-tenant-id splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._cost_tenant_id(payload jsonb)
|
||||||
|
RETURNS integer
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE(
|
||||||
|
NULLIF(payload->>'tenant_id', '')::integer,
|
||||||
|
NULLIF(current_setting('app.tenant_id', true), '')::integer,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-021b-cost-settings splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._cost_settings(p_tenant_id integer)
|
||||||
|
RETURNS TABLE (
|
||||||
|
budget_warn_pct numeric,
|
||||||
|
budget_critical_pct numeric,
|
||||||
|
default_iva_rate numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(s.budget_warn_pct, 85::numeric),
|
||||||
|
COALESCE(s.budget_critical_pct, 100::numeric),
|
||||||
|
COALESCE(s.default_iva_rate, 0.16::numeric)
|
||||||
|
FROM (
|
||||||
|
SELECT budget_warn_pct, budget_critical_pct, default_iva_rate
|
||||||
|
FROM tenant_cost_settings
|
||||||
|
WHERE tenant_id = p_tenant_id
|
||||||
|
) s
|
||||||
|
UNION ALL
|
||||||
|
SELECT 85::numeric, 100::numeric, 0.16::numeric
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM tenant_cost_settings WHERE tenant_id = p_tenant_id)
|
||||||
|
LIMIT 1;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-021c-cost-executed-amount splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._cost_executed_amount(p_budget_item_id bigint)
|
||||||
|
RETURNS numeric
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
SELECT round(COALESCE(
|
||||||
|
(SELECT SUM(e.subtotal) FROM expense_entries e
|
||||||
|
WHERE e.budget_item_id = p_budget_item_id
|
||||||
|
AND e.status = 'confirmed'
|
||||||
|
AND e.counts_toward_budget = true),
|
||||||
|
0
|
||||||
|
) + COALESCE(
|
||||||
|
(SELECT SUM(m.total_cost) FROM warehouse_movements m
|
||||||
|
WHERE m.budget_item_id = p_budget_item_id
|
||||||
|
AND m.movement_type = 'exit'),
|
||||||
|
0
|
||||||
|
), 2);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-021d-cost-executed-qty splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._cost_executed_qty(p_budget_item_id bigint)
|
||||||
|
RETURNS numeric
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
SELECT COALESCE(
|
||||||
|
(SELECT SUM(e.quantity) FROM expense_entries e
|
||||||
|
WHERE e.budget_item_id = p_budget_item_id
|
||||||
|
AND e.status = 'confirmed'
|
||||||
|
AND e.counts_toward_budget = true),
|
||||||
|
0
|
||||||
|
) + COALESCE(
|
||||||
|
(SELECT SUM(m.quantity) FROM warehouse_movements m
|
||||||
|
WHERE m.budget_item_id = p_budget_item_id
|
||||||
|
AND m.movement_type = 'exit'),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-021e-cost-budget-warning splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._cost_budget_warning(
|
||||||
|
p_tenant_id integer,
|
||||||
|
p_budget_item_id bigint,
|
||||||
|
p_add_subtotal numeric DEFAULT 0,
|
||||||
|
p_add_qty numeric DEFAULT 0
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_item record;
|
||||||
|
v_warn numeric;
|
||||||
|
v_critical numeric;
|
||||||
|
v_exec_amt numeric;
|
||||||
|
v_exec_qty numeric;
|
||||||
|
v_pct numeric;
|
||||||
|
v_level text;
|
||||||
|
BEGIN
|
||||||
|
IF p_budget_item_id IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
SELECT id, description, amount, quantity INTO v_item
|
||||||
|
FROM budget_items WHERE id = p_budget_item_id;
|
||||||
|
IF NOT FOUND OR COALESCE(v_item.amount, 0) <= 0 THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical
|
||||||
|
FROM core._cost_settings(p_tenant_id);
|
||||||
|
v_exec_amt := core._cost_executed_amount(p_budget_item_id) + COALESCE(p_add_subtotal, 0);
|
||||||
|
v_exec_qty := core._cost_executed_qty(p_budget_item_id) + COALESCE(p_add_qty, 0);
|
||||||
|
v_pct := round(v_exec_amt / v_item.amount * 100, 1);
|
||||||
|
IF v_pct > v_critical THEN
|
||||||
|
v_level := 'critical';
|
||||||
|
ELSIF v_pct > v_warn THEN
|
||||||
|
v_level := 'warn';
|
||||||
|
ELSE
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'level', v_level,
|
||||||
|
'budget_item_id', p_budget_item_id,
|
||||||
|
'pct_after', v_pct,
|
||||||
|
'executed_amount', v_exec_amt,
|
||||||
|
'budget_amount', v_item.amount,
|
||||||
|
'executed_qty', v_exec_qty,
|
||||||
|
'budget_qty', v_item.quantity,
|
||||||
|
'message', format(
|
||||||
|
'Esta operación dejará el concepto "%s" al %s%% del presupuesto (importe)',
|
||||||
|
left(v_item.description, 80), v_pct
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-021f-cost-calc-iva splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._cost_calc_amounts(
|
||||||
|
p_subtotal numeric,
|
||||||
|
p_iva_rate numeric,
|
||||||
|
p_tax_included boolean,
|
||||||
|
p_total numeric DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (subtotal numeric, iva_amount numeric, total numeric)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_sub numeric;
|
||||||
|
v_iva numeric;
|
||||||
|
v_tot numeric;
|
||||||
|
BEGIN
|
||||||
|
IF p_tax_included AND p_total IS NOT NULL AND p_total > 0 THEN
|
||||||
|
v_tot := round(p_total, 2);
|
||||||
|
v_sub := round(v_tot / (1 + COALESCE(p_iva_rate, 0.16)), 2);
|
||||||
|
v_iva := round(v_tot - v_sub, 2);
|
||||||
|
ELSE
|
||||||
|
v_sub := round(COALESCE(p_subtotal, 0), 2);
|
||||||
|
v_iva := round(v_sub * COALESCE(p_iva_rate, 0.16), 2);
|
||||||
|
v_tot := round(v_sub + v_iva, 2);
|
||||||
|
END IF;
|
||||||
|
RETURN QUERY SELECT v_sub, v_iva, v_tot;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-021g-cost-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core._cost_tenant_id(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core._cost_settings(integer) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core._cost_executed_amount(bigint) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core._cost_executed_qty(bigint) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core._cost_budget_warning(integer, bigint, numeric, numeric) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core._cost_calc_amounts(numeric, numeric, boolean, numeric) TO panels_core_app;
|
||||||
409
db/core/changesets/022-rpc-expenses.sql
Normal file
409
db/core/changesets/022-rpc-expenses.sql
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · RPC gastos e IVA
|
||||||
|
|
||||||
|
--changeset panel:core-022a-fn-expense-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_expense_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
|
||||||
|
v_date_from date := NULLIF(payload->>'date_from', '')::date;
|
||||||
|
v_date_to date := NULLIF(payload->>'date_to', '')::date;
|
||||||
|
v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
|
||||||
|
v_source text := nullif(btrim(payload->>'source'), '');
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.expense_date DESC, x.id DESC), '[]'::jsonb)
|
||||||
|
INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT e.*,
|
||||||
|
bi.description AS budget_item_description,
|
||||||
|
bi.code AS budget_item_code
|
||||||
|
FROM expense_entries e
|
||||||
|
LEFT JOIN budget_items bi ON bi.id = e.budget_item_id
|
||||||
|
WHERE e.tenant_id = v_tid
|
||||||
|
AND e.status <> 'void'
|
||||||
|
AND (v_project_id IS NULL OR e.project_id = v_project_id)
|
||||||
|
AND (v_company_id IS NULL OR e.company_id = v_company_id)
|
||||||
|
AND (v_date_from IS NULL OR e.expense_date >= v_date_from)
|
||||||
|
AND (v_date_to IS NULL OR e.expense_date <= v_date_to)
|
||||||
|
AND (v_budget_item_id IS NULL OR e.budget_item_id = v_budget_item_id)
|
||||||
|
AND (v_source IS NULL OR e.source = v_source)
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('expenses', v_rows, 'count', jsonb_array_length(v_rows)),
|
||||||
|
format('Listado de gastos: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_expense_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022b-fn-expense-get splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_expense_get(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_row jsonb;
|
||||||
|
v_attachments jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_expense_get: id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_expense_get', 'field', 'id'));
|
||||||
|
END IF;
|
||||||
|
SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id;
|
||||||
|
IF v_row IS NULL THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_get: gasto id=%s no encontrado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_get', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.uploaded_at DESC), '[]'::jsonb)
|
||||||
|
INTO v_attachments FROM expense_attachments a WHERE a.expense_id = v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('expense', v_row, 'attachments', v_attachments),
|
||||||
|
format('Gasto id=%s recuperado con %s comprobante(s)', v_id, jsonb_array_length(v_attachments)),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_get', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_expense_get', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022c-fn-expense-create splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_expense_create(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_expense_date date := COALESCE(NULLIF(payload->>'expense_date', '')::date, CURRENT_DATE);
|
||||||
|
v_description text := coalesce(nullif(btrim(payload->>'description'), ''), '');
|
||||||
|
v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
|
||||||
|
v_budget_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint;
|
||||||
|
v_quantity numeric := COALESCE((payload->>'quantity')::numeric, 0);
|
||||||
|
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
|
||||||
|
v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0);
|
||||||
|
v_subtotal_in numeric := COALESCE((payload->>'subtotal')::numeric, 0);
|
||||||
|
v_iva_rate numeric;
|
||||||
|
v_tax_included boolean := COALESCE((payload->>'tax_included')::boolean, false);
|
||||||
|
v_total_in numeric := (payload->>'total')::numeric;
|
||||||
|
v_subtotal numeric; v_iva_amount numeric; v_total numeric;
|
||||||
|
v_is_deductible boolean := COALESCE((payload->>'is_deductible')::boolean, true);
|
||||||
|
v_counts_toward_budget boolean := COALESCE((payload->>'counts_toward_budget')::boolean, true);
|
||||||
|
v_supplier_name text := coalesce(nullif(btrim(payload->>'supplier_name'), ''), '');
|
||||||
|
v_supplier_rfc text := upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), ''));
|
||||||
|
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
|
||||||
|
v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer;
|
||||||
|
v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
|
||||||
|
v_id bigint;
|
||||||
|
v_warning jsonb;
|
||||||
|
v_row jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_company_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_expense_create: company_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_expense_create', 'field', 'company_id'));
|
||||||
|
END IF;
|
||||||
|
IF v_description = '' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_expense_create: description es obligatoria',
|
||||||
|
jsonb_build_object('fn', 'fn_expense_create', 'field', 'description'));
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', format('fn_expense_create: empresa id=%s no encontrada', v_company_id),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_create', 'company_id', v_company_id));
|
||||||
|
END IF;
|
||||||
|
IF v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proyecto id=%s no encontrado', v_project_id),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_create', 'project_id', v_project_id));
|
||||||
|
END IF;
|
||||||
|
IF v_subtotal_in <= 0 AND v_quantity > 0 AND v_unit_price > 0 THEN
|
||||||
|
v_subtotal_in := round(v_quantity * v_unit_price, 2);
|
||||||
|
END IF;
|
||||||
|
SELECT default_iva_rate INTO v_iva_rate FROM core._cost_settings(v_tid);
|
||||||
|
v_iva_rate := COALESCE((payload->>'iva_rate')::numeric, v_iva_rate);
|
||||||
|
SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total
|
||||||
|
FROM core._cost_calc_amounts(v_subtotal_in, v_iva_rate, v_tax_included, v_total_in) c;
|
||||||
|
IF v_budget_item_id IS NOT NULL THEN
|
||||||
|
SELECT chapter_id INTO v_budget_chapter_id FROM budget_items WHERE id = v_budget_item_id;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO expense_entries (
|
||||||
|
tenant_id, company_id, project_id, expense_date, source, description,
|
||||||
|
supplier_name, supplier_rfc, budget_chapter_id, budget_item_id,
|
||||||
|
quantity, unit, unit_price, subtotal, iva_rate, iva_amount, total,
|
||||||
|
tax_included, is_deductible, counts_toward_budget, status,
|
||||||
|
created_by_id, created_by_name, notes
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_company_id, v_project_id, v_expense_date, 'manual', v_description,
|
||||||
|
v_supplier_name, v_supplier_rfc, v_budget_chapter_id, v_budget_item_id,
|
||||||
|
v_quantity, v_unit, v_unit_price, v_subtotal, v_iva_rate, v_iva_amount, v_total,
|
||||||
|
v_tax_included, v_is_deductible, v_counts_toward_budget, 'confirmed',
|
||||||
|
v_created_by_id, v_created_by_name, v_notes
|
||||||
|
) RETURNING id INTO v_id;
|
||||||
|
IF v_counts_toward_budget AND v_budget_item_id IS NOT NULL THEN
|
||||||
|
v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0);
|
||||||
|
END IF;
|
||||||
|
SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id;
|
||||||
|
RETURN core.rpc_created(
|
||||||
|
jsonb_build_object('expense', v_row, 'budget_warning', v_warning),
|
||||||
|
format('Gasto registrado por %s (subtotal %s, IVA %s)', v_description, v_subtotal, v_iva_amount),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_create', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_expense_create', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022d-fn-expense-update splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_expense_update(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_cur expense_entries%ROWTYPE;
|
||||||
|
v_warning jsonb;
|
||||||
|
v_row jsonb;
|
||||||
|
v_subtotal numeric; v_iva_amount numeric; v_total numeric;
|
||||||
|
BEGIN
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_expense_update: id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_expense_update', 'field', 'id'));
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_cur FROM expense_entries WHERE id = v_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_update: gasto id=%s no encontrado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
IF v_cur.is_locked OR v_cur.source <> 'manual' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_expense_update: solo se editan gastos manuales no bloqueados',
|
||||||
|
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id, 'source', v_cur.source));
|
||||||
|
END IF;
|
||||||
|
UPDATE expense_entries SET
|
||||||
|
expense_date = COALESCE(NULLIF(payload->>'expense_date', '')::date, expense_date),
|
||||||
|
description = COALESCE(nullif(btrim(payload->>'description'), ''), description),
|
||||||
|
supplier_name = CASE WHEN payload ? 'supplier_name' THEN coalesce(nullif(btrim(payload->>'supplier_name'), ''), '') ELSE supplier_name END,
|
||||||
|
budget_item_id = CASE WHEN payload ? 'budget_item_id' THEN NULLIF(payload->>'budget_item_id', '')::bigint ELSE budget_item_id END,
|
||||||
|
quantity = CASE WHEN payload ? 'quantity' THEN COALESCE((payload->>'quantity')::numeric, 0) ELSE quantity END,
|
||||||
|
unit = CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE unit END,
|
||||||
|
unit_price = CASE WHEN payload ? 'unit_price' THEN COALESCE((payload->>'unit_price')::numeric, 0) ELSE unit_price END,
|
||||||
|
notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END
|
||||||
|
WHERE id = v_id;
|
||||||
|
SELECT * INTO v_cur FROM expense_entries WHERE id = v_id;
|
||||||
|
SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total
|
||||||
|
FROM core._cost_calc_amounts(
|
||||||
|
CASE WHEN v_cur.quantity > 0 AND v_cur.unit_price > 0 THEN round(v_cur.quantity * v_cur.unit_price, 2) ELSE v_cur.subtotal END,
|
||||||
|
v_cur.iva_rate, v_cur.tax_included, NULL
|
||||||
|
) c;
|
||||||
|
UPDATE expense_entries SET subtotal = v_subtotal, iva_amount = v_iva_amount, total = v_total WHERE id = v_id;
|
||||||
|
IF v_cur.counts_toward_budget AND v_cur.budget_item_id IS NOT NULL THEN
|
||||||
|
v_warning := core._cost_budget_warning(v_tid, v_cur.budget_item_id, 0, 0);
|
||||||
|
END IF;
|
||||||
|
SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('expense', v_row, 'budget_warning', v_warning),
|
||||||
|
format('Gasto id=%s actualizado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_update', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_expense_update', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022e-fn-expense-void splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_expense_void(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_cur expense_entries%ROWTYPE;
|
||||||
|
BEGIN
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_expense_void: id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_expense_void', 'field', 'id'));
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_cur FROM expense_entries WHERE id = v_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_void: gasto id=%s no encontrado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_void', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
IF v_cur.is_locked THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_expense_void: no se puede anular un gasto derivado de nómina',
|
||||||
|
jsonb_build_object('fn', 'fn_expense_void', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
UPDATE expense_entries SET status = 'void' WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('id', v_id, 'status', 'void'),
|
||||||
|
format('Gasto id=%s anulado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_void', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_expense_void', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022f-fn-expense-attachment-add splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_expense_attachment_add(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_expense_id bigint := NULLIF(payload->>'expense_id', '')::bigint;
|
||||||
|
v_id bigint;
|
||||||
|
BEGIN
|
||||||
|
IF v_expense_id IS NULL OR NOT EXISTS (SELECT 1 FROM expense_entries WHERE id = v_expense_id) THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'fn_expense_attachment_add: gasto no encontrado',
|
||||||
|
jsonb_build_object('fn', 'fn_expense_attachment_add', 'expense_id', v_expense_id));
|
||||||
|
END IF;
|
||||||
|
INSERT INTO expense_attachments (
|
||||||
|
expense_id, original_name, mime, size_bytes, sha256, iv, storage_name,
|
||||||
|
uploaded_by_id, uploaded_by_name
|
||||||
|
) VALUES (
|
||||||
|
v_expense_id,
|
||||||
|
coalesce(nullif(btrim(payload->>'original_name'), ''), 'comprobante.pdf'),
|
||||||
|
coalesce(nullif(btrim(payload->>'mime'), ''), 'application/pdf'),
|
||||||
|
COALESCE((payload->>'size_bytes')::bigint, 0),
|
||||||
|
coalesce(nullif(btrim(payload->>'sha256'), ''), ''),
|
||||||
|
coalesce(nullif(btrim(payload->>'iv'), ''), ''),
|
||||||
|
coalesce(nullif(btrim(payload->>'storage_name'), ''), ''),
|
||||||
|
NULLIF(payload->>'uploaded_by_id', '')::integer,
|
||||||
|
coalesce(nullif(btrim(payload->>'uploaded_by_name'), ''), '')
|
||||||
|
) RETURNING id INTO v_id;
|
||||||
|
RETURN core.rpc_created(
|
||||||
|
jsonb_build_object('id', v_id, 'expense_id', v_expense_id),
|
||||||
|
format('Comprobante adjunto al gasto id=%s', v_expense_id),
|
||||||
|
jsonb_build_object('fn', 'fn_expense_attachment_add', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_expense_attachment_add', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022g-fn-iva-period-summary splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_iva_period_summary(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
|
||||||
|
v_year integer := COALESCE((payload->>'year')::integer, EXTRACT(YEAR FROM CURRENT_DATE)::integer);
|
||||||
|
v_month integer := COALESCE((payload->>'month')::integer, EXTRACT(MONTH FROM CURRENT_DATE)::integer);
|
||||||
|
v_acreditable numeric;
|
||||||
|
v_trasladado numeric;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(SUM(e.iva_amount), 0) INTO v_acreditable
|
||||||
|
FROM expense_entries e
|
||||||
|
WHERE e.tenant_id = v_tid
|
||||||
|
AND e.status = 'confirmed'
|
||||||
|
AND e.is_deductible = true
|
||||||
|
AND EXTRACT(YEAR FROM e.expense_date) = v_year
|
||||||
|
AND EXTRACT(MONTH FROM e.expense_date) = v_month
|
||||||
|
AND (v_company_id IS NULL OR e.company_id = v_company_id);
|
||||||
|
SELECT COALESCE(iva_trasladado, 0) INTO v_trasladado
|
||||||
|
FROM iva_period_entries
|
||||||
|
WHERE tenant_id = v_tid
|
||||||
|
AND (v_company_id IS NULL OR company_id = v_company_id)
|
||||||
|
AND period_year = v_year AND period_month = v_month
|
||||||
|
LIMIT 1;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object(
|
||||||
|
'year', v_year, 'month', v_month,
|
||||||
|
'iva_acreditable', round(COALESCE(v_acreditable, 0), 2),
|
||||||
|
'iva_trasladado', round(COALESCE(v_trasladado, 0), 2),
|
||||||
|
'iva_a_pagar', round(COALESCE(v_trasladado, 0) - COALESCE(v_acreditable, 0), 2)
|
||||||
|
),
|
||||||
|
format('Resumen IVA %s/%s: acreditable %s, trasladado %s', v_month, v_year, v_acreditable, v_trasladado),
|
||||||
|
jsonb_build_object('fn', 'fn_iva_period_summary', 'tenant_id', v_tid)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_iva_period_summary', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022h-fn-cost-settings-get splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_cost_settings_get(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_row jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT to_jsonb(s) INTO v_row FROM tenant_cost_settings s WHERE tenant_id = v_tid;
|
||||||
|
IF v_row IS NULL THEN
|
||||||
|
v_row := jsonb_build_object('tenant_id', v_tid, 'budget_warn_pct', 85, 'budget_critical_pct', 100, 'default_iva_rate', 0.16);
|
||||||
|
END IF;
|
||||||
|
RETURN core.rpc_ok(v_row, format('Configuración de costos del tenant %s', v_tid),
|
||||||
|
jsonb_build_object('fn', 'fn_cost_settings_get', 'tenant_id', v_tid));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_cost_settings_get', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022i-fn-cost-settings-save splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_cost_settings_save(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO tenant_cost_settings (tenant_id, budget_warn_pct, budget_critical_pct, default_iva_rate, updated_at)
|
||||||
|
VALUES (
|
||||||
|
v_tid,
|
||||||
|
COALESCE((payload->>'budget_warn_pct')::numeric, 85),
|
||||||
|
COALESCE((payload->>'budget_critical_pct')::numeric, 100),
|
||||||
|
COALESCE((payload->>'default_iva_rate')::numeric, 0.16),
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
|
budget_warn_pct = EXCLUDED.budget_warn_pct,
|
||||||
|
budget_critical_pct = EXCLUDED.budget_critical_pct,
|
||||||
|
default_iva_rate = EXCLUDED.default_iva_rate,
|
||||||
|
updated_at = now();
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
(SELECT to_jsonb(s) FROM tenant_cost_settings s WHERE tenant_id = v_tid),
|
||||||
|
format('Configuración de costos guardada para tenant %s', v_tid),
|
||||||
|
jsonb_build_object('fn', 'fn_cost_settings_save', 'tenant_id', v_tid)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_cost_settings_save', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-022j-expense-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_expense_list(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_expense_get(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_expense_create(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_expense_update(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_expense_void(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_expense_attachment_add(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_iva_period_summary(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_cost_settings_get(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_cost_settings_save(jsonb) TO panels_core_app;
|
||||||
486
db/core/changesets/023-rpc-warehouse.sql
Normal file
486
db/core/changesets/023-rpc-warehouse.sql
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · RPC almacén
|
||||||
|
|
||||||
|
--changeset panel:core-023a-fn-warehouse-ensure-central splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_ensure_central(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_id bigint;
|
||||||
|
v_row jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO v_id FROM warehouses WHERE tenant_id = v_tid AND kind = 'central' LIMIT 1;
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
INSERT INTO warehouses (tenant_id, kind, status) VALUES (v_tid, 'central', 'open')
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
END IF;
|
||||||
|
SELECT to_jsonb(w) INTO v_row FROM warehouses w WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(v_row, format('Almacén central del tenant %s listo (id=%s)', v_tid, v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_ensure_central', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_ensure_central', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023b-fn-warehouse-open-project splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_open_project(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_proj record;
|
||||||
|
v_id bigint;
|
||||||
|
v_row jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_project_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_open_project: project_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_open_project', 'field', 'project_id'));
|
||||||
|
END IF;
|
||||||
|
SELECT id, status, code, name INTO v_proj FROM projects WHERE id = v_project_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_warehouse_open_project: proyecto id=%s no encontrado', v_project_id),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_open_project', 'project_id', v_project_id));
|
||||||
|
END IF;
|
||||||
|
IF v_proj.status NOT IN ('activo', 'pausado') THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION',
|
||||||
|
format('fn_warehouse_open_project: proyecto %s está %s, no se abre almacén', v_proj.code, v_proj.status),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_open_project', 'project_id', v_project_id));
|
||||||
|
END IF;
|
||||||
|
SELECT id INTO v_id FROM warehouses WHERE project_id = v_project_id AND kind = 'project';
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
INSERT INTO warehouses (tenant_id, kind, project_id, status) VALUES (v_tid, 'project', v_project_id, 'open')
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE warehouses SET status = 'open', closed_at = NULL, closed_by_name = '' WHERE id = v_id;
|
||||||
|
END IF;
|
||||||
|
PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_tid));
|
||||||
|
SELECT to_jsonb(w) INTO v_row FROM warehouses w WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(v_row,
|
||||||
|
format('Almacén de obra abierto para proyecto %s (%s)', v_proj.code, v_proj.name),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_open_project', 'id', v_id, 'project_id', v_project_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_open_project', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023c-fn-warehouse-stock-upsert splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._warehouse_stock_apply(
|
||||||
|
p_warehouse_id bigint,
|
||||||
|
p_material_id bigint,
|
||||||
|
p_qty_delta numeric,
|
||||||
|
p_unit_cost numeric
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_cur record;
|
||||||
|
v_new_qty numeric;
|
||||||
|
v_new_avg numeric;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_cur FROM warehouse_stock
|
||||||
|
WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
IF p_qty_delta < 0 THEN
|
||||||
|
RAISE EXCEPTION 'Stock insuficiente en almacén % material %', p_warehouse_id, p_material_id;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO warehouse_stock (warehouse_id, material_id, qty_on_hand, avg_unit_cost)
|
||||||
|
VALUES (p_warehouse_id, p_material_id, p_qty_delta, COALESCE(p_unit_cost, 0));
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
v_new_qty := v_cur.qty_on_hand + p_qty_delta;
|
||||||
|
IF v_new_qty < 0 THEN
|
||||||
|
RAISE EXCEPTION 'Stock insuficiente: disponible % solicitado %', v_cur.qty_on_hand, -p_qty_delta;
|
||||||
|
END IF;
|
||||||
|
IF p_qty_delta > 0 AND p_unit_cost > 0 THEN
|
||||||
|
v_new_avg := CASE WHEN v_new_qty > 0 THEN
|
||||||
|
round((v_cur.qty_on_hand * v_cur.avg_unit_cost + p_qty_delta * p_unit_cost) / v_new_qty, 4)
|
||||||
|
ELSE 0 END;
|
||||||
|
ELSE
|
||||||
|
v_new_avg := v_cur.avg_unit_cost;
|
||||||
|
END IF;
|
||||||
|
UPDATE warehouse_stock SET qty_on_hand = v_new_qty, avg_unit_cost = v_new_avg
|
||||||
|
WHERE warehouse_id = p_warehouse_id AND material_id = p_material_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023d-fn-warehouse-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_include_closed boolean := COALESCE((payload->>'include_closed')::boolean, false);
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_tid));
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.kind, x.id), '[]'::jsonb) INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT w.*, p.code AS project_code, p.name AS project_name
|
||||||
|
FROM warehouses w
|
||||||
|
LEFT JOIN projects p ON p.id = w.project_id
|
||||||
|
WHERE w.tenant_id = v_tid
|
||||||
|
AND (v_include_closed OR w.status = 'open' OR w.kind = 'central')
|
||||||
|
AND (w.kind = 'central' OR w.status <> 'closed' OR v_include_closed)
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('warehouses', v_rows),
|
||||||
|
format('Almacenes del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_list', 'tenant_id', v_tid)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023e-fn-warehouse-stock-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_stock_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
|
||||||
|
v_global boolean := COALESCE((payload->>'global')::boolean, false);
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_global THEN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
|
||||||
|
SELECT w.id AS warehouse_id, w.kind, w.project_id, p.code AS project_code,
|
||||||
|
m.id AS material_id, m.code AS material_code, m.name AS material_name, m.unit,
|
||||||
|
s.qty_on_hand, s.avg_unit_cost, round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value
|
||||||
|
FROM warehouse_stock s
|
||||||
|
JOIN warehouses w ON w.id = s.warehouse_id
|
||||||
|
JOIN warehouse_materials m ON m.id = s.material_id
|
||||||
|
LEFT JOIN projects p ON p.id = w.project_id
|
||||||
|
WHERE w.tenant_id = v_tid AND w.status = 'open' AND s.qty_on_hand > 0
|
||||||
|
) x;
|
||||||
|
ELSE
|
||||||
|
IF v_warehouse_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_stock_list: warehouse_id es obligatorio si global=false',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_stock_list'));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
|
||||||
|
SELECT s.*, m.code AS material_code, m.name AS material_name, m.unit,
|
||||||
|
round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value
|
||||||
|
FROM warehouse_stock s
|
||||||
|
JOIN warehouse_materials m ON m.id = s.material_id
|
||||||
|
WHERE s.warehouse_id = v_warehouse_id AND s.qty_on_hand > 0
|
||||||
|
) x;
|
||||||
|
END IF;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('stock', v_rows),
|
||||||
|
format('Stock consultado: %s línea(s)', jsonb_array_length(COALESCE(v_rows, '[]'::jsonb))),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_stock_list', 'warehouse_id', v_warehouse_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_stock_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023f-fn-material-upsert splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_material_upsert(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), ''));
|
||||||
|
v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
|
||||||
|
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
|
||||||
|
v_id bigint;
|
||||||
|
BEGIN
|
||||||
|
IF v_code = '' OR v_name = '' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_material_upsert: code y name son obligatorios',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_material_upsert'));
|
||||||
|
END IF;
|
||||||
|
INSERT INTO warehouse_materials (tenant_id, code, name, unit)
|
||||||
|
VALUES (v_tid, v_code, v_name, v_unit)
|
||||||
|
ON CONFLICT (tenant_id, code) DO UPDATE SET name = EXCLUDED.name, unit = EXCLUDED.unit
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
(SELECT to_jsonb(m) FROM warehouse_materials m WHERE id = v_id),
|
||||||
|
format('Material %s (%s) registrado', v_code, v_name),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_material_upsert', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023g-fn-warehouse-entry-create splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_entry_create(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
|
||||||
|
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
|
||||||
|
v_material_code text := nullif(btrim(payload->>'material_code'), '');
|
||||||
|
v_material_name text := nullif(btrim(payload->>'material_name'), '');
|
||||||
|
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
|
||||||
|
v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0);
|
||||||
|
v_unit_cost numeric := COALESCE((payload->>'unit_cost')::numeric, 0);
|
||||||
|
v_movement_date date := COALESCE(NULLIF(payload->>'movement_date', '')::date, CURRENT_DATE);
|
||||||
|
v_expense_id bigint := NULLIF(payload->>'expense_id', '')::bigint;
|
||||||
|
v_wh record;
|
||||||
|
v_mov_id bigint;
|
||||||
|
v_total numeric;
|
||||||
|
v_env jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_warehouse_id IS NULL OR v_qty <= 0 THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_entry_create: warehouse_id y quantity>0 son obligatorios',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_entry_create'));
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_wh FROM warehouses WHERE id = v_warehouse_id AND tenant_id = v_tid;
|
||||||
|
IF NOT FOUND OR v_wh.status <> 'open' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', format('fn_warehouse_entry_create: almacén id=%s no disponible', v_warehouse_id),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_entry_create', 'warehouse_id', v_warehouse_id));
|
||||||
|
END IF;
|
||||||
|
IF v_material_id IS NULL AND v_material_code IS NOT NULL THEN
|
||||||
|
v_env := core.fn_warehouse_material_upsert(jsonb_build_object(
|
||||||
|
'tenant_id', v_tid, 'code', v_material_code, 'name', coalesce(v_material_name, v_material_code), 'unit', v_unit
|
||||||
|
));
|
||||||
|
IF NOT COALESCE((v_env->>'ok')::boolean, false) THEN
|
||||||
|
RETURN v_env;
|
||||||
|
END IF;
|
||||||
|
v_material_id := (v_env->'data'->>'id')::bigint;
|
||||||
|
END IF;
|
||||||
|
IF v_material_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_entry_create: material_id o material_code requerido',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_entry_create'));
|
||||||
|
END IF;
|
||||||
|
v_total := round(v_qty * v_unit_cost, 2);
|
||||||
|
PERFORM core._warehouse_stock_apply(v_warehouse_id, v_material_id, v_qty, v_unit_cost);
|
||||||
|
INSERT INTO warehouse_movements (
|
||||||
|
tenant_id, warehouse_id, material_id, movement_type, movement_date,
|
||||||
|
quantity, unit_cost, total_cost, expense_id, created_by_id, created_by_name
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_warehouse_id, v_material_id, 'entry', v_movement_date,
|
||||||
|
v_qty, v_unit_cost, v_total, v_expense_id,
|
||||||
|
NULLIF(payload->>'created_by_id', '')::integer,
|
||||||
|
coalesce(nullif(btrim(payload->>'created_by_name'), ''), '')
|
||||||
|
) RETURNING id INTO v_mov_id;
|
||||||
|
RETURN core.rpc_created(
|
||||||
|
jsonb_build_object('movement_id', v_mov_id, 'warehouse_id', v_warehouse_id, 'quantity', v_qty, 'total_cost', v_total),
|
||||||
|
format('Entrada de %s unidades al almacén id=%s por %s', v_qty, v_warehouse_id, v_total),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_entry_create', 'movement_id', v_mov_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_entry_create', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023h-fn-warehouse-exit-create splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_exit_create(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
|
||||||
|
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
|
||||||
|
v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0);
|
||||||
|
v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
|
||||||
|
v_destination text := coalesce(nullif(btrim(payload->>'destination_note'), ''), '');
|
||||||
|
v_movement_date date := COALESCE(NULLIF(payload->>'movement_date', '')::date, CURRENT_DATE);
|
||||||
|
v_stock record;
|
||||||
|
v_unit_cost numeric;
|
||||||
|
v_total numeric;
|
||||||
|
v_mov_id bigint;
|
||||||
|
v_warning jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_warehouse_id IS NULL OR v_material_id IS NULL OR v_qty <= 0 THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_exit_create: warehouse_id, material_id y quantity>0 obligatorios',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_exit_create'));
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_stock FROM warehouse_stock WHERE warehouse_id = v_warehouse_id AND material_id = v_material_id;
|
||||||
|
IF NOT FOUND OR v_stock.qty_on_hand < v_qty THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION',
|
||||||
|
format('fn_warehouse_exit_create: stock insuficiente (disponible %s, solicitado %s)',
|
||||||
|
COALESCE(v_stock.qty_on_hand, 0), v_qty),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_exit_create', 'warehouse_id', v_warehouse_id));
|
||||||
|
END IF;
|
||||||
|
v_unit_cost := v_stock.avg_unit_cost;
|
||||||
|
v_total := round(v_qty * v_unit_cost, 2);
|
||||||
|
PERFORM core._warehouse_stock_apply(v_warehouse_id, v_material_id, -v_qty, v_unit_cost);
|
||||||
|
INSERT INTO warehouse_movements (
|
||||||
|
tenant_id, warehouse_id, material_id, movement_type, movement_date,
|
||||||
|
quantity, unit_cost, total_cost, budget_item_id, destination_note,
|
||||||
|
created_by_id, created_by_name
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_warehouse_id, v_material_id, 'exit', v_movement_date,
|
||||||
|
v_qty, v_unit_cost, v_total, v_budget_item_id, v_destination,
|
||||||
|
NULLIF(payload->>'created_by_id', '')::integer,
|
||||||
|
coalesce(nullif(btrim(payload->>'created_by_name'), ''), '')
|
||||||
|
) RETURNING id INTO v_mov_id;
|
||||||
|
IF v_budget_item_id IS NOT NULL THEN
|
||||||
|
v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0);
|
||||||
|
END IF;
|
||||||
|
RETURN core.rpc_created(
|
||||||
|
jsonb_build_object('movement_id', v_mov_id, 'total_cost', v_total, 'budget_warning', v_warning),
|
||||||
|
format('Salida de %s unidades del almacén id=%s hacia concepto %s', v_qty, v_warehouse_id, v_budget_item_id),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_exit_create', 'movement_id', v_mov_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_exit_create', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023i-fn-warehouse-transfer-create splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_transfer_create(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_from_id bigint := NULLIF(payload->>'from_warehouse_id', '')::bigint;
|
||||||
|
v_to_id bigint := NULLIF(payload->>'to_warehouse_id', '')::bigint;
|
||||||
|
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
|
||||||
|
v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0);
|
||||||
|
v_transfer_date date := COALESCE(NULLIF(payload->>'transfer_date', '')::date, CURRENT_DATE);
|
||||||
|
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
|
||||||
|
v_stock record;
|
||||||
|
v_unit_cost numeric;
|
||||||
|
v_transfer_id bigint;
|
||||||
|
v_created_by text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
|
||||||
|
BEGIN
|
||||||
|
IF v_from_id IS NULL OR v_to_id IS NULL OR v_material_id IS NULL OR v_qty <= 0 OR v_from_id = v_to_id THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: origen, destino, material y cantidad válidos son obligatorios',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_transfer_create'));
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_stock FROM warehouse_stock WHERE warehouse_id = v_from_id AND material_id = v_material_id;
|
||||||
|
IF NOT FOUND OR v_stock.qty_on_hand < v_qty THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: stock insuficiente en almacén origen',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'from_warehouse_id', v_from_id));
|
||||||
|
END IF;
|
||||||
|
v_unit_cost := v_stock.avg_unit_cost;
|
||||||
|
INSERT INTO warehouse_transfers (
|
||||||
|
tenant_id, from_warehouse_id, to_warehouse_id, material_id, quantity, unit_cost,
|
||||||
|
status, transfer_date, notes, created_by_name
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_from_id, v_to_id, v_material_id, v_qty, v_unit_cost,
|
||||||
|
'completed', v_transfer_date, v_notes, v_created_by
|
||||||
|
) RETURNING id INTO v_transfer_id;
|
||||||
|
PERFORM core._warehouse_stock_apply(v_from_id, v_material_id, -v_qty, v_unit_cost);
|
||||||
|
PERFORM core._warehouse_stock_apply(v_to_id, v_material_id, v_qty, v_unit_cost);
|
||||||
|
INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name)
|
||||||
|
VALUES (v_tid, v_from_id, v_material_id, 'transfer_out', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by);
|
||||||
|
INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name)
|
||||||
|
VALUES (v_tid, v_to_id, v_material_id, 'transfer_in', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by);
|
||||||
|
RETURN core.rpc_created(
|
||||||
|
jsonb_build_object('transfer_id', v_transfer_id, 'quantity', v_qty, 'unit_cost', v_unit_cost),
|
||||||
|
format('Transferencia %s unidades del almacén %s al %s', v_qty, v_from_id, v_to_id),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'transfer_id', v_transfer_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_transfer_create', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023j-fn-warehouse-movement-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_movement_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint;
|
||||||
|
v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint;
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.movement_date DESC, x.id DESC), '[]'::jsonb) INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT m.*, mat.code AS material_code, mat.name AS material_name, bi.description AS budget_item_description
|
||||||
|
FROM warehouse_movements m
|
||||||
|
JOIN warehouse_materials mat ON mat.id = m.material_id
|
||||||
|
LEFT JOIN budget_items bi ON bi.id = m.budget_item_id
|
||||||
|
WHERE (v_warehouse_id IS NULL OR m.warehouse_id = v_warehouse_id)
|
||||||
|
AND (v_material_id IS NULL OR m.material_id = v_material_id)
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('movements', v_rows),
|
||||||
|
format('Kardex: %s movimiento(s)', jsonb_array_length(v_rows)),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_movement_list', 'warehouse_id', v_warehouse_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_movement_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023k-fn-warehouse-close-project splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_warehouse_close_project(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_force boolean := COALESCE((payload->>'force')::boolean, false);
|
||||||
|
v_closed_by text := coalesce(nullif(btrim(payload->>'closed_by_name'), ''), '');
|
||||||
|
v_notes text := coalesce(nullif(btrim(payload->>'close_notes'), ''), '');
|
||||||
|
v_wh record;
|
||||||
|
v_stock_count integer;
|
||||||
|
BEGIN
|
||||||
|
IF v_project_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_warehouse_close_project: project_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_close_project'));
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_wh FROM warehouses WHERE project_id = v_project_id AND kind = 'project';
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('closed', false, 'reason', 'no_warehouse'),
|
||||||
|
format('Proyecto %s no tenía almacén abierto', v_project_id),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
|
||||||
|
END IF;
|
||||||
|
IF v_wh.status = 'closed' THEN
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('warehouse_id', v_wh.id, 'status', 'closed'),
|
||||||
|
format('Almacén del proyecto %s ya estaba cerrado', v_project_id),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
|
||||||
|
END IF;
|
||||||
|
SELECT COUNT(*)::integer INTO v_stock_count FROM warehouse_stock
|
||||||
|
WHERE warehouse_id = v_wh.id AND qty_on_hand > 0;
|
||||||
|
IF v_stock_count > 0 AND NOT v_force THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION',
|
||||||
|
format('fn_warehouse_close_project: quedan %s material(es) con stock; transfiera o ajuste antes de cerrar', v_stock_count),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id, 'stock_lines', v_stock_count));
|
||||||
|
END IF;
|
||||||
|
UPDATE warehouses SET status = 'closed', closed_at = now(), closed_by_name = v_closed_by, close_notes = v_notes
|
||||||
|
WHERE id = v_wh.id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('warehouse_id', v_wh.id, 'status', 'closed', 'forced', v_force),
|
||||||
|
format('Almacén del proyecto %s cerrado', v_project_id),
|
||||||
|
jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_warehouse_close_project', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-023l-warehouse-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core._warehouse_stock_apply(bigint, bigint, numeric, numeric) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_ensure_central(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_open_project(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_list(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_stock_list(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_material_upsert(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_entry_create(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_exit_create(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_transfer_create(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_movement_list(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_warehouse_close_project(jsonb) TO panels_core_app;
|
||||||
186
db/core/changesets/024-rpc-cost-control.sql
Normal file
186
db/core/changesets/024-rpc-cost-control.sql
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · RPC control presupuestal
|
||||||
|
|
||||||
|
--changeset panel:core-024a-fn-cost-control-by-item splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_cost_control_by_item(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_warn numeric; v_critical numeric;
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_project_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_by_item: project_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_cost_control_by_item', 'field', 'project_id'));
|
||||||
|
END IF;
|
||||||
|
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical FROM core._cost_settings(v_tid);
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.code, x.id), '[]'::jsonb) INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
bi.id, bi.code, bi.description, bi.unit, bi.chapter_id,
|
||||||
|
bi.quantity AS qty_budget, bi.amount AS amount_budget, bi.unit_price,
|
||||||
|
core._cost_executed_qty(bi.id) AS qty_executed,
|
||||||
|
core._cost_executed_amount(bi.id) AS amount_executed,
|
||||||
|
round(CASE WHEN bi.amount > 0 THEN core._cost_executed_amount(bi.id) / bi.amount * 100 ELSE 0 END, 1) AS pct_amount,
|
||||||
|
round(core._cost_executed_amount(bi.id) - bi.amount, 2) AS delta_amount,
|
||||||
|
CASE
|
||||||
|
WHEN bi.amount <= 0 AND core._cost_executed_amount(bi.id) > 0 THEN 'critical'
|
||||||
|
WHEN bi.amount > 0 AND core._cost_executed_amount(bi.id) / bi.amount * 100 > v_critical THEN 'critical'
|
||||||
|
WHEN bi.amount > 0 AND core._cost_executed_amount(bi.id) / bi.amount * 100 > v_warn THEN 'warn'
|
||||||
|
ELSE 'ok'
|
||||||
|
END AS semaphore
|
||||||
|
FROM budget_items bi
|
||||||
|
WHERE bi.project_id = v_project_id
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('items', v_rows, 'count', jsonb_array_length(v_rows)),
|
||||||
|
format('Control por concepto del proyecto %s: %s renglón(es)', v_project_id, jsonb_array_length(v_rows)),
|
||||||
|
jsonb_build_object('fn', 'fn_cost_control_by_item', 'project_id', v_project_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_cost_control_by_item', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-024b-fn-cost-control-by-chapter splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_cost_control_by_chapter(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_warn numeric; v_critical numeric;
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_project_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_by_chapter: project_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_cost_control_by_chapter'));
|
||||||
|
END IF;
|
||||||
|
SELECT budget_warn_pct, budget_critical_pct INTO v_warn, v_critical FROM core._cost_settings(v_tid);
|
||||||
|
WITH chapter_base AS (
|
||||||
|
SELECT bc.id, bc.parent_id, bc.code, bc.name, bc.wbs, bc.sort_order
|
||||||
|
FROM budget_chapters bc
|
||||||
|
WHERE bc.project_id = v_project_id
|
||||||
|
)
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(cs) ORDER BY cs.sort_order, cs.id), '[]'::jsonb) INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT cb.id, cb.parent_id, cb.code, cb.name, cb.wbs, cb.sort_order,
|
||||||
|
COALESCE(SUM(bi.amount), 0) AS amount_budget,
|
||||||
|
COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) AS amount_executed,
|
||||||
|
round(CASE WHEN COALESCE(SUM(bi.amount), 0) > 0
|
||||||
|
THEN COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 ELSE 0 END, 1) AS pct_amount,
|
||||||
|
round(COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) - COALESCE(SUM(bi.amount), 0), 2) AS delta_amount,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(SUM(bi.amount), 0) <= 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) > 0 THEN 'critical'
|
||||||
|
WHEN COALESCE(SUM(bi.amount), 0) > 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 > v_critical THEN 'critical'
|
||||||
|
WHEN COALESCE(SUM(bi.amount), 0) > 0 AND COALESCE(SUM(core._cost_executed_amount(bi.id)), 0) / COALESCE(SUM(bi.amount), 0) * 100 > v_warn THEN 'warn'
|
||||||
|
ELSE 'ok'
|
||||||
|
END AS semaphore
|
||||||
|
FROM chapter_base cb
|
||||||
|
LEFT JOIN budget_items bi ON bi.chapter_id = cb.id
|
||||||
|
GROUP BY cb.id, cb.parent_id, cb.code, cb.name, cb.wbs, cb.sort_order
|
||||||
|
) cs;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('chapters', v_rows),
|
||||||
|
format('Control por partida del proyecto %s', v_project_id),
|
||||||
|
jsonb_build_object('fn', 'fn_cost_control_by_chapter', 'project_id', v_project_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_cost_control_by_chapter', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-024c-fn-cost-control-project-summary splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_cost_control_project_summary(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_budget numeric;
|
||||||
|
v_executed numeric;
|
||||||
|
v_unclassified numeric;
|
||||||
|
v_in_warehouse numeric;
|
||||||
|
v_red_items integer;
|
||||||
|
v_red_chapters integer;
|
||||||
|
BEGIN
|
||||||
|
IF v_project_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_cost_control_project_summary: project_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_cost_control_project_summary'));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(SUM(amount), 0) INTO v_budget FROM budget_items WHERE project_id = v_project_id;
|
||||||
|
SELECT COALESCE(SUM(core._cost_executed_amount(id)), 0) INTO v_executed FROM budget_items WHERE project_id = v_project_id;
|
||||||
|
SELECT COALESCE(SUM(subtotal), 0) INTO v_unclassified
|
||||||
|
FROM expense_entries
|
||||||
|
WHERE project_id = v_project_id AND status = 'confirmed'
|
||||||
|
AND budget_item_id IS NULL AND counts_toward_budget = true;
|
||||||
|
SELECT COALESCE(SUM(s.qty_on_hand * s.avg_unit_cost), 0) INTO v_in_warehouse
|
||||||
|
FROM warehouse_stock s
|
||||||
|
JOIN warehouses w ON w.id = s.warehouse_id
|
||||||
|
WHERE w.project_id = v_project_id AND w.status = 'open';
|
||||||
|
SELECT COUNT(*)::integer INTO v_red_items FROM budget_items bi
|
||||||
|
WHERE bi.project_id = v_project_id AND bi.amount > 0
|
||||||
|
AND core._cost_executed_amount(bi.id) > bi.amount;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object(
|
||||||
|
'project_id', v_project_id,
|
||||||
|
'budget_subtotal', round(v_budget, 2),
|
||||||
|
'executed_subtotal', round(v_executed, 2),
|
||||||
|
'pct_executed', round(CASE WHEN v_budget > 0 THEN v_executed / v_budget * 100 ELSE 0 END, 1),
|
||||||
|
'delta_subtotal', round(v_executed - v_budget, 2),
|
||||||
|
'unclassified_amount', round(v_unclassified, 2),
|
||||||
|
'warehouse_stock_value', round(v_in_warehouse, 2),
|
||||||
|
'concepts_over_budget', v_red_items
|
||||||
|
),
|
||||||
|
format('Resumen de control del proyecto %s: %s%% ejecutado', v_project_id,
|
||||||
|
round(CASE WHEN v_budget > 0 THEN v_executed / v_budget * 100 ELSE 0 END, 1)),
|
||||||
|
jsonb_build_object('fn', 'fn_cost_control_project_summary', 'project_id', v_project_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_cost_control_project_summary', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-024d-fn-cost-control-top-deviations splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_cost_control_top_deviations(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_limit integer := COALESCE((payload->>'limit')::integer, 10);
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM (
|
||||||
|
SELECT bi.id, bi.code, bi.description, bi.amount AS amount_budget,
|
||||||
|
core._cost_executed_amount(bi.id) AS amount_executed,
|
||||||
|
round(core._cost_executed_amount(bi.id) - bi.amount, 2) AS delta_amount
|
||||||
|
FROM budget_items bi
|
||||||
|
WHERE bi.project_id = v_project_id AND bi.amount > 0
|
||||||
|
AND core._cost_executed_amount(bi.id) > bi.amount
|
||||||
|
ORDER BY core._cost_executed_amount(bi.id) - bi.amount DESC
|
||||||
|
LIMIT v_limit
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('deviations', v_rows),
|
||||||
|
format('Top %s desviaciones del proyecto %s', v_limit, v_project_id),
|
||||||
|
jsonb_build_object('fn', 'fn_cost_control_top_deviations', 'project_id', v_project_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_cost_control_top_deviations', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-024e-cost-control-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_cost_control_by_item(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_cost_control_by_chapter(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_cost_control_project_summary(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_cost_control_top_deviations(jsonb) TO panels_core_app;
|
||||||
197
db/core/changesets/025-rpc-payroll-cost-sync.sql
Normal file
197
db/core/changesets/025-rpc-payroll-cost-sync.sql
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · sincronización nómina/destajo → gastos
|
||||||
|
|
||||||
|
--changeset panel:core-025a-fn-payroll-sync-cost-entries splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_payroll_sync_cost_entries(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_line record;
|
||||||
|
v_company_id bigint;
|
||||||
|
v_expense_date date;
|
||||||
|
v_desc text;
|
||||||
|
v_budget_item_id bigint;
|
||||||
|
v_inserted integer := 0;
|
||||||
|
BEGIN
|
||||||
|
IF v_week_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_payroll_sync_cost_entries: week_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_sync_cost_entries'));
|
||||||
|
END IF;
|
||||||
|
SELECT week_end INTO v_expense_date FROM payroll_weeks WHERE id = v_week_id;
|
||||||
|
FOR v_line IN
|
||||||
|
SELECT l.id AS line_id, l.payable_net, l.concepto, l.project_id, l.worker_id,
|
||||||
|
s.kind AS sheet_kind, s.project_id AS sheet_project_id,
|
||||||
|
w.company_id AS worker_company_id, p.company_id AS project_company_id,
|
||||||
|
dj.budget_item_id AS destajo_budget_item_id
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
JOIN workers w ON w.id = l.worker_id
|
||||||
|
LEFT JOIN projects p ON p.id = COALESCE(l.project_id, s.project_id)
|
||||||
|
LEFT JOIN destajo_cut_lines dcl ON dcl.id = l.destajo_cut_line_id
|
||||||
|
LEFT JOIN destajo_jobs dj ON dj.id = dcl.job_id
|
||||||
|
WHERE s.week_id = v_week_id
|
||||||
|
AND l.payable_net > 0
|
||||||
|
LOOP
|
||||||
|
v_company_id := COALESCE(v_line.project_company_id, v_line.worker_company_id);
|
||||||
|
IF v_company_id IS NULL THEN
|
||||||
|
SELECT id INTO v_company_id FROM companies WHERE tenant_id = v_tid AND kind = 'principal' LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
v_budget_item_id := v_line.destajo_budget_item_id;
|
||||||
|
v_desc := coalesce(nullif(btrim(v_line.concepto), ''),
|
||||||
|
CASE v_line.sheet_kind
|
||||||
|
WHEN 'destajo' THEN 'Destajo semana ' || v_week_id
|
||||||
|
WHEN 'obra' THEN 'Nómina obra semana ' || v_week_id
|
||||||
|
ELSE 'Nómina admin semana ' || v_week_id
|
||||||
|
END);
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM expense_entries
|
||||||
|
WHERE source = 'payroll' AND source_ref_id = v_line.line_id AND status <> 'void'
|
||||||
|
) THEN
|
||||||
|
UPDATE expense_entries SET
|
||||||
|
subtotal = v_line.payable_net,
|
||||||
|
total = v_line.payable_net,
|
||||||
|
project_id = COALESCE(v_line.project_id, v_line.sheet_project_id),
|
||||||
|
budget_item_id = v_budget_item_id,
|
||||||
|
description = v_desc,
|
||||||
|
expense_date = v_expense_date
|
||||||
|
WHERE source = 'payroll' AND source_ref_id = v_line.line_id AND status <> 'void';
|
||||||
|
ELSE
|
||||||
|
INSERT INTO expense_entries (
|
||||||
|
tenant_id, company_id, project_id, expense_date, source, source_ref_id,
|
||||||
|
description, subtotal, iva_rate, iva_amount, total, tax_included,
|
||||||
|
is_deductible, counts_toward_budget, is_locked, status,
|
||||||
|
budget_item_id, created_by_name
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_company_id, COALESCE(v_line.project_id, v_line.sheet_project_id),
|
||||||
|
v_expense_date, 'payroll', v_line.line_id,
|
||||||
|
v_desc, v_line.payable_net, 0, 0, v_line.payable_net, false,
|
||||||
|
false, true, true, 'confirmed',
|
||||||
|
v_budget_item_id, 'nómina'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
v_inserted := v_inserted + 1;
|
||||||
|
END LOOP;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('week_id', v_week_id, 'lines_synced', v_inserted),
|
||||||
|
format('Sincronizados %s gastos de nómina de la semana %s', v_inserted, v_week_id),
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_sync_cost_entries', 'week_id', v_week_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_payroll_sync_cost_entries', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-025b-fn-payroll-week-pay-with-costs splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_payroll_week_pay(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
|
||||||
|
v_now_iso date := COALESCE(
|
||||||
|
NULLIF(btrim(payload->>'now_iso'), '')::date,
|
||||||
|
core._payroll_tenant_today(v_tid)
|
||||||
|
);
|
||||||
|
v_week record;
|
||||||
|
v_line record;
|
||||||
|
v_loan record;
|
||||||
|
v_take numeric;
|
||||||
|
v_n integer;
|
||||||
|
v_period_id bigint;
|
||||||
|
v_job record;
|
||||||
|
v_paid numeric;
|
||||||
|
v_env jsonb;
|
||||||
|
v_sync jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_week_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
'fn_payroll_week_pay: week_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_week_pay', 'field', 'week_id')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_week FROM payroll_weeks WHERE id = v_week_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'NOT_FOUND',
|
||||||
|
format('fn_payroll_week_pay: semana id=%s no encontrada', v_week_id),
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF v_week.status = 'paid' THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
'fn_payroll_week_pay: la semana ya está pagada',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF v_week.status <> 'assembled' THEN
|
||||||
|
v_env := core.fn_payroll_week_assemble(jsonb_build_object(
|
||||||
|
'week_id', v_week_id,
|
||||||
|
'tenant_id', v_tid,
|
||||||
|
'now_iso', v_now_iso::text
|
||||||
|
));
|
||||||
|
IF NOT COALESCE((v_env->>'ok')::boolean, false) THEN
|
||||||
|
RETURN v_env;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
PERFORM core._payroll_recalc_week(v_week_id);
|
||||||
|
FOR v_line IN
|
||||||
|
SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE s.week_id = v_week_id
|
||||||
|
AND l.loan_id IS NOT NULL
|
||||||
|
AND l.loan_discount > 0
|
||||||
|
LOOP
|
||||||
|
SELECT * INTO v_loan FROM loans WHERE id = v_line.loan_id;
|
||||||
|
IF NOT FOUND OR v_loan.balance <= 0 THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
v_take := core.round_money(LEAST(v_line.loan_discount, v_loan.balance));
|
||||||
|
IF v_take <= 0 THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id;
|
||||||
|
INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label)
|
||||||
|
VALUES (v_loan.id, v_week_id, v_take, v_n, v_line.loan_label);
|
||||||
|
UPDATE loans
|
||||||
|
SET balance = core.round_money(balance - v_take)
|
||||||
|
WHERE id = v_loan.id;
|
||||||
|
END LOOP;
|
||||||
|
SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
|
||||||
|
IF v_period_id IS NOT NULL THEN
|
||||||
|
FOR v_job IN SELECT id, qty_total_estimated FROM destajo_jobs LOOP
|
||||||
|
SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) INTO v_paid
|
||||||
|
FROM destajo_cut_lines c
|
||||||
|
JOIN destajo_periods p ON p.id = c.period_id
|
||||||
|
JOIN payroll_weeks w ON w.id = p.week_id
|
||||||
|
WHERE c.job_id = v_job.id
|
||||||
|
AND (w.status = 'paid' OR w.id = v_week_id);
|
||||||
|
IF v_paid >= v_job.qty_total_estimated THEN
|
||||||
|
UPDATE destajo_jobs SET status = 'done' WHERE id = v_job.id;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
UPDATE payroll_weeks SET status = 'paid', paid_at = now() WHERE id = v_week_id;
|
||||||
|
v_sync := core.fn_payroll_sync_cost_entries(jsonb_build_object('week_id', v_week_id, 'tenant_id', v_tid));
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('week_id', v_week_id, 'status', 'paid', 'cost_sync', v_sync->'data'),
|
||||||
|
format('Semana de nómina %s marcada como pagada y costos sincronizados', v_week_id),
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_payroll_week_pay', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-025c-payroll-cost-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_payroll_sync_cost_entries(jsonb) TO panels_core_app;
|
||||||
125
db/core/changesets/026-rpc-project-update-warehouse.sql
Normal file
125
db/core/changesets/026-rpc-project-update-warehouse.sql
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · cierre de almacén al concluir proyecto
|
||||||
|
|
||||||
|
--changeset panel:core-026a-fn-project-update-warehouse splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_project_update(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := (payload->>'id')::bigint;
|
||||||
|
v_cur projects%ROWTYPE;
|
||||||
|
v_name text;
|
||||||
|
v_address text;
|
||||||
|
v_theme_id text;
|
||||||
|
v_status text;
|
||||||
|
v_company_id integer;
|
||||||
|
v_contract_amount numeric(14,2);
|
||||||
|
v_start_date date;
|
||||||
|
v_end_date date;
|
||||||
|
v_resident_name text;
|
||||||
|
v_siroc text;
|
||||||
|
v_payroll_tax_pct numeric(5,2);
|
||||||
|
v_close_env jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
'fn_project_update: id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_project_update', 'field', 'id')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_cur FROM projects WHERE id = v_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'NOT_FOUND',
|
||||||
|
format('fn_project_update: proyecto id=%s no encontrado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_project_update', 'id', v_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name);
|
||||||
|
v_address := CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE v_cur.address END;
|
||||||
|
v_theme_id := coalesce(nullif(btrim(payload->>'theme_id'), ''), v_cur.theme_id);
|
||||||
|
v_status := CASE
|
||||||
|
WHEN payload ? 'status' AND btrim(payload->>'status') IN ('activo', 'pausado', 'concluido', 'cancelado')
|
||||||
|
THEN btrim(payload->>'status')
|
||||||
|
ELSE v_cur.status
|
||||||
|
END;
|
||||||
|
v_company_id := coalesce((payload->>'company_id')::integer, v_cur.company_id);
|
||||||
|
v_contract_amount := CASE
|
||||||
|
WHEN payload ? 'contract_amount' THEN (payload->>'contract_amount')::numeric(14,2)
|
||||||
|
ELSE v_cur.contract_amount
|
||||||
|
END;
|
||||||
|
v_start_date := CASE
|
||||||
|
WHEN payload ? 'start_date' THEN nullif(btrim(payload->>'start_date'), '')::date
|
||||||
|
ELSE v_cur.start_date
|
||||||
|
END;
|
||||||
|
v_end_date := CASE
|
||||||
|
WHEN payload ? 'end_date' THEN nullif(btrim(payload->>'end_date'), '')::date
|
||||||
|
ELSE v_cur.end_date
|
||||||
|
END;
|
||||||
|
v_resident_name := CASE
|
||||||
|
WHEN payload ? 'resident_name' THEN coalesce(nullif(btrim(payload->>'resident_name'), ''), '')
|
||||||
|
ELSE v_cur.resident_name
|
||||||
|
END;
|
||||||
|
v_siroc := CASE
|
||||||
|
WHEN payload ? 'siroc' THEN upper(coalesce(nullif(btrim(payload->>'siroc'), ''), ''))
|
||||||
|
ELSE v_cur.siroc
|
||||||
|
END;
|
||||||
|
v_payroll_tax_pct := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), v_cur.payroll_tax_pct);
|
||||||
|
IF v_company_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
'fn_project_update: company_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_project_update', 'field', 'company_id', 'id', v_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
format('fn_project_update: empresa id=%s no encontrada', v_company_id),
|
||||||
|
jsonb_build_object('fn', 'fn_project_update', 'company_id', v_company_id, 'id', v_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN
|
||||||
|
RETURN core.rpc_err(
|
||||||
|
'VALIDATION',
|
||||||
|
'fn_project_update: la fecha de término no puede ser anterior al inicio',
|
||||||
|
jsonb_build_object('fn', 'fn_project_update', 'field', 'end_date', 'id', v_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF v_status IN ('concluido', 'cancelado') AND v_cur.status NOT IN ('concluido', 'cancelado') THEN
|
||||||
|
v_close_env := core.fn_warehouse_close_project(jsonb_build_object(
|
||||||
|
'project_id', v_id,
|
||||||
|
'force', COALESCE((payload->>'force_warehouse_close')::boolean, false),
|
||||||
|
'closed_by_name', coalesce(nullif(btrim(payload->>'closed_by_name'), ''), 'sistema'),
|
||||||
|
'close_notes', coalesce(nullif(btrim(payload->>'warehouse_close_notes'), ''), 'Cierre automático al concluir obra')
|
||||||
|
));
|
||||||
|
IF NOT COALESCE((v_close_env->>'ok')::boolean, false) THEN
|
||||||
|
RETURN v_close_env;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
UPDATE projects SET
|
||||||
|
name = v_name,
|
||||||
|
address = v_address,
|
||||||
|
theme_id = v_theme_id,
|
||||||
|
status = v_status,
|
||||||
|
company_id = v_company_id,
|
||||||
|
contract_amount = v_contract_amount,
|
||||||
|
start_date = v_start_date,
|
||||||
|
end_date = v_end_date,
|
||||||
|
resident_name = v_resident_name,
|
||||||
|
siroc = v_siroc,
|
||||||
|
payroll_tax_pct = v_payroll_tax_pct
|
||||||
|
WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('id', v_id, 'code', v_cur.code, 'status', v_status),
|
||||||
|
format('Proyecto %s actualizado', v_cur.code),
|
||||||
|
jsonb_build_object('fn', 'fn_project_update', 'id', v_id, 'code', v_cur.code)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_project_update', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
@ -8,5 +8,7 @@
|
||||||
<!-- panels_product, esquema iam: identidad/roles/permisos del tenant. -->
|
<!-- panels_product, esquema iam: identidad/roles/permisos del tenant. -->
|
||||||
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/001-baseline.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/002-rls.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/002-rls.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/003-expenses-warehouse-permissions.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
27
db/iam/changesets/003-expenses-warehouse-permissions.sql
Normal file
27
db/iam/changesets/003-expenses-warehouse-permissions.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · iam · permisos gastos, almacén y control de costos
|
||||||
|
|
||||||
|
--changeset panel:iam-003a-permissions endDelimiter:; splitStatements:true
|
||||||
|
INSERT INTO iam.permissions (code, label) VALUES
|
||||||
|
('view_expenses', 'Ver gastos y reportes de costos'),
|
||||||
|
('manage_expenses', 'Capturar y editar gastos'),
|
||||||
|
('view_warehouse', 'Ver almacén global y de obra'),
|
||||||
|
('manage_warehouse', 'Entradas, salidas y transferencias de almacén'),
|
||||||
|
('close_warehouse', 'Cerrar almacén de obra'),
|
||||||
|
('manage_cost_settings', 'Configurar umbrales de alerta presupuestal')
|
||||||
|
ON CONFLICT (code) DO NOTHING;
|
||||||
|
|
||||||
|
--changeset panel:iam-003b-tenant-admin-permissions endDelimiter:; splitStatements:true
|
||||||
|
INSERT INTO iam.role_permissions (role_code, permission_code)
|
||||||
|
SELECT 'tenant_admin', code FROM iam.permissions
|
||||||
|
WHERE code IN (
|
||||||
|
'view_expenses', 'manage_expenses', 'view_warehouse', 'manage_warehouse',
|
||||||
|
'close_warehouse', 'manage_cost_settings'
|
||||||
|
)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
--changeset panel:iam-003c-user-view-permissions endDelimiter:; splitStatements:true
|
||||||
|
INSERT INTO iam.role_permissions (role_code, permission_code) VALUES
|
||||||
|
('user', 'view_expenses'),
|
||||||
|
('user', 'view_warehouse')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
118
db/iam/changesets/004-rpc-users-permissions.sql
Normal file
118
db/iam/changesets/004-rpc-users-permissions.sql
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · iam · RPC usuarios y permisos del tenant
|
||||||
|
|
||||||
|
--changeset panel:iam-004a-fn-iam-user-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION iam.fn_user_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = iam
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := COALESCE(
|
||||||
|
NULLIF(payload->>'tenant_id', '')::integer,
|
||||||
|
NULLIF(current_setting('app.tenant_id', true), '')::integer
|
||||||
|
);
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.display_name), '[]'::jsonb) INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT id, username, display_name, email, role_code, company_id, tenant_id, created_at
|
||||||
|
FROM users WHERE tenant_id = v_tid
|
||||||
|
) u;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('users', v_rows),
|
||||||
|
format('Usuarios del tenant %s: %s cuenta(s)', v_tid, jsonb_array_length(v_rows)),
|
||||||
|
jsonb_build_object('fn', 'fn_user_list', 'tenant_id', v_tid)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_user_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:iam-004b-fn-iam-permission-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION iam.fn_permission_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = iam
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.code), '[]'::jsonb) INTO v_rows
|
||||||
|
FROM (SELECT code, label FROM permissions) p;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('permissions', v_rows),
|
||||||
|
format('Catálogo de permisos: %s', jsonb_array_length(v_rows)),
|
||||||
|
jsonb_build_object('fn', 'fn_permission_list'));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_permission_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:iam-004c-fn-iam-role-permissions-get splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_get(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = iam
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_role text := nullif(btrim(payload->>'role_code'), '');
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_role IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_get: role_code es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_role_permissions_get'));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(jsonb_agg(rp.permission_code), '[]'::jsonb) INTO v_rows
|
||||||
|
FROM role_permissions rp WHERE rp.role_code = v_role;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('role_code', v_role, 'permissions', v_rows),
|
||||||
|
format('Permisos del rol %s: %s', v_role, jsonb_array_length(v_rows)),
|
||||||
|
jsonb_build_object('fn', 'fn_role_permissions_get', 'role_code', v_role));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_role_permissions_get', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:iam-004d-fn-iam-role-permissions-save splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION iam.fn_role_permissions_save(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = iam
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_role text := nullif(btrim(payload->>'role_code'), '');
|
||||||
|
v_perms jsonb := COALESCE(payload->'permissions', '[]'::jsonb);
|
||||||
|
v_perm text;
|
||||||
|
BEGIN
|
||||||
|
IF v_role IS NULL OR v_role = 'tenant_admin' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_role_permissions_save: no se puede modificar tenant_admin',
|
||||||
|
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_code', v_role));
|
||||||
|
END IF;
|
||||||
|
DELETE FROM role_permissions WHERE role_code = v_role;
|
||||||
|
FOR v_perm IN SELECT jsonb_array_elements_text(v_perms)
|
||||||
|
LOOP
|
||||||
|
INSERT INTO role_permissions (role_code, permission_code)
|
||||||
|
VALUES (v_role, v_perm)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('role_code', v_role, 'permissions', v_perms),
|
||||||
|
format('Permisos del rol %s actualizados (%s permiso(s))', v_role, jsonb_array_length(v_perms)),
|
||||||
|
jsonb_build_object('fn', 'fn_role_permissions_save', 'role_code', v_role));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_role_permissions_save', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:iam-004e-iam-rpc-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT USAGE ON SCHEMA core TO panels_iam_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_iam_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_iam_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_iam_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION iam.fn_user_list(jsonb) TO panels_iam_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION iam.fn_permission_list(jsonb) TO panels_iam_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_get(jsonb) TO panels_iam_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION iam.fn_role_permissions_save(jsonb) TO panels_iam_app;
|
||||||
|
|
@ -164,5 +164,21 @@ BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
|
||||||
check_status "catalogs" "200" "$CODE" "$BODY"
|
check_status "catalogs" "200" "$CODE" "$BODY"
|
||||||
echo "$BODY" | grep -q '"risks"' || fail "catalogs missing risks"
|
echo "$BODY" | grep -q '"risks"' || fail "catalogs missing risks"
|
||||||
|
|
||||||
|
echo "=== Expenses LIST ==="
|
||||||
|
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/expenses")
|
||||||
|
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
|
||||||
|
check_status "expenses list" "200" "$CODE" "$BODY"
|
||||||
|
echo "$BODY" | grep -q '"ok":true' || fail "expenses list envelope"
|
||||||
|
|
||||||
|
echo "=== Warehouses LIST ==="
|
||||||
|
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/warehouses")
|
||||||
|
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
|
||||||
|
check_status "warehouses list" "200" "$CODE" "$BODY"
|
||||||
|
|
||||||
|
echo "=== Cost control (project 1) ==="
|
||||||
|
R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/1/cost-control/summary")
|
||||||
|
BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
|
||||||
|
check_status "cost control summary" "200" "$CODE" "$BODY"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "All CRUD smoke tests passed."
|
echo "All CRUD smoke tests passed."
|
||||||
|
|
|
||||||
|
|
@ -2231,3 +2231,8 @@ html.dark .p-dialog .p-dialog-header-icon {
|
||||||
.detail-tabs .nomina-loan-fieldset {
|
.detail-tabs .nomina-loan-fieldset {
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cost-ok { background: #e8f5e9 !important; color: #1b5e20 !important; }
|
||||||
|
.cost-warn { background: #fff8e1 !important; color: #e65100 !important; }
|
||||||
|
.cost-critical { background: #ffebee !important; color: #b42318 !important; }
|
||||||
|
.cost-critical-text { color: #b42318; font-weight: 600; }
|
||||||
|
|
|
||||||
73
web-panel/components/BudgetItemPicker.vue
Normal file
73
web-panel/components/BudgetItemPicker.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<template>
|
||||||
|
<AutoComplete
|
||||||
|
v-model="selected"
|
||||||
|
:suggestions="filtered"
|
||||||
|
option-label="label"
|
||||||
|
placeholder="Buscar concepto del presupuesto..."
|
||||||
|
class="w-full"
|
||||||
|
force-selection
|
||||||
|
@complete="onSearch"
|
||||||
|
@item-select="onSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: number;
|
||||||
|
modelValue?: number | null;
|
||||||
|
}>();
|
||||||
|
const emit = defineEmits<{ "update:modelValue": [number | null]; select: [BudgetOption | null] }>();
|
||||||
|
|
||||||
|
type BudgetOption = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
unit: string;
|
||||||
|
amount: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { api } = useApi();
|
||||||
|
const items = ref<BudgetOption[]>([]);
|
||||||
|
const selected = ref<BudgetOption | null>(null);
|
||||||
|
const filtered = ref<BudgetOption[]>([]);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!props.projectId) {
|
||||||
|
items.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await api<{ items?: Array<Record<string, unknown>> }>(
|
||||||
|
`/v1/projects/${props.projectId}/budget`,
|
||||||
|
);
|
||||||
|
const raw = r.items ?? [];
|
||||||
|
items.value = raw.map((row) => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
code: String(row.code ?? ""),
|
||||||
|
description: String(row.description ?? ""),
|
||||||
|
unit: String(row.unit ?? ""),
|
||||||
|
amount: Number(row.amount ?? 0),
|
||||||
|
label: [row.code, row.description].filter(Boolean).join(" — "),
|
||||||
|
}));
|
||||||
|
filtered.value = items.value.slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch(event: { query: string }) {
|
||||||
|
const q = event.query.toLowerCase();
|
||||||
|
filtered.value = items.value.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(event: { value: BudgetOption }) {
|
||||||
|
emit("update:modelValue", event.value?.id ?? null);
|
||||||
|
emit("select", event.value ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.projectId, load, { immediate: true });
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(id) => {
|
||||||
|
if (!id) selected.value = null;
|
||||||
|
else selected.value = items.value.find((i) => i.id === id) ?? null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
18
web-panel/components/CostSemaphore.vue
Normal file
18
web-panel/components/CostSemaphore.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<Chip :class="chipClass" :label="label" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
pct: number;
|
||||||
|
compact?: boolean;
|
||||||
|
}>(), { compact: false });
|
||||||
|
|
||||||
|
const label = computed(() => props.compact ? `${props.pct}%` : `${props.pct}% ejecutado`);
|
||||||
|
|
||||||
|
const chipClass = computed(() => {
|
||||||
|
if (props.pct > 100) return "cost-critical";
|
||||||
|
if (props.pct > 85) return "cost-warn";
|
||||||
|
return "cost-ok";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -144,10 +144,14 @@ const menu = [
|
||||||
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") },
|
{ label: "Inicio", icon: "pi pi-home", to: "/", command: () => navigateTo("/") },
|
||||||
{ label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
|
{ label: "Proyectos", icon: "pi pi-building", to: "/proyectos", command: () => navigateTo("/proyectos") },
|
||||||
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") },
|
{ label: "Presupuesto", icon: "pi pi-calculator", to: "/presupuesto", command: () => navigateTo("/presupuesto") },
|
||||||
|
{ label: "Control costos", icon: "pi pi-chart-bar", to: "/control-presupuesto", command: () => navigateTo("/control-presupuesto") },
|
||||||
|
{ label: "Gastos", icon: "pi pi-money-bill", to: "/gastos", command: () => navigateTo("/gastos") },
|
||||||
|
{ label: "Almacén", icon: "pi pi-box", to: "/almacen", command: () => navigateTo("/almacen") },
|
||||||
{ label: "Padrón", icon: "pi pi-users", to: "/padron", command: () => navigateTo("/padron") },
|
{ label: "Padrón", icon: "pi pi-users", to: "/padron", command: () => navigateTo("/padron") },
|
||||||
{ label: "Kanban", icon: "pi pi-th-large", to: "/kanban", command: () => navigateTo("/kanban") },
|
{ label: "Kanban", icon: "pi pi-th-large", to: "/kanban", command: () => navigateTo("/kanban") },
|
||||||
{ label: "Gafetes", icon: "pi pi-id-card", to: "/gafetes", command: () => navigateTo("/gafetes") },
|
{ label: "Gafetes", icon: "pi pi-id-card", to: "/gafetes", command: () => navigateTo("/gafetes") },
|
||||||
{ label: "Nómina", icon: "pi pi-wallet", to: "/nomina", command: () => navigateTo("/nomina") },
|
{ label: "Nómina", icon: "pi pi-wallet", to: "/nomina", command: () => navigateTo("/nomina") },
|
||||||
|
{ label: "Usuarios", icon: "pi pi-shield", to: "/usuarios", command: () => navigateTo("/usuarios") },
|
||||||
{ label: "Ayuda", icon: "pi pi-question-circle", command: showHelp },
|
{ label: "Ayuda", icon: "pi pi-question-circle", command: showHelp },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
235
web-panel/pages/almacen.vue
Normal file
235
web-panel/pages/almacen.vue
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
<template>
|
||||||
|
<div class="almacen-page">
|
||||||
|
<DeskTableBar v-model="tableQ" placeholder="Buscar material o obra">
|
||||||
|
<Chip label="Almacén global" icon="pi pi-box" />
|
||||||
|
<Chip :label="`${stockRows.length} líneas`" icon="pi pi-list" />
|
||||||
|
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
|
||||||
|
</DeskTableBar>
|
||||||
|
|
||||||
|
<TabView>
|
||||||
|
<TabPanel header="Stock global">
|
||||||
|
<DataTable :value="filteredStock" data-key="material_id" paginator :rows="25" scrollable scroll-height="flex">
|
||||||
|
<Column field="project_code" header="Obra / Central" />
|
||||||
|
<Column field="material_code" header="Clave" style="width: 8rem" />
|
||||||
|
<Column field="material_name" header="Material" />
|
||||||
|
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
|
||||||
|
<Column field="stock_value" header="Valor" style="width: 9rem">
|
||||||
|
<template #body="{ data }">{{ money(data.stock_value) }}</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel header="Kardex">
|
||||||
|
<DataTable :value="movements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
|
||||||
|
<Column field="movement_date" header="Fecha" style="width: 7rem" />
|
||||||
|
<Column field="movement_type" header="Tipo" style="width: 8rem" />
|
||||||
|
<Column field="material_name" header="Material" />
|
||||||
|
<Column field="quantity" header="Cant." style="width: 7rem" />
|
||||||
|
<Column field="total_cost" header="Importe" style="width: 9rem">
|
||||||
|
<template #body="{ data }">{{ money(data.total_cost) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="destination_note" header="Destino / nota" />
|
||||||
|
</DataTable>
|
||||||
|
</TabPanel>
|
||||||
|
</TabView>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '640px' }">
|
||||||
|
<div class="form-grid form-grid-compact">
|
||||||
|
<FormField label="Almacén">
|
||||||
|
<Select v-model="entryForm.warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Código material">
|
||||||
|
<InputText v-model="entryForm.material_code" class="w-full identifier-input" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Nombre material">
|
||||||
|
<InputText v-model="entryForm.material_name" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Cantidad">
|
||||||
|
<InputNumber v-model="entryForm.quantity" :min="0" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Costo unitario">
|
||||||
|
<InputNumber v-model="entryForm.unit_cost" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancelar" text @click="showEntry = false" />
|
||||||
|
<Button label="Registrar entrada" icon="pi pi-check" @click="saveEntry" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="showExit" header="Salida a obra / concepto" modal :style="{ width: '720px' }">
|
||||||
|
<div class="form-grid form-grid-compact">
|
||||||
|
<FormField label="Almacén obra">
|
||||||
|
<Select v-model="exitForm.warehouse_id" :options="projectWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Material">
|
||||||
|
<Select v-model="exitForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Cantidad">
|
||||||
|
<InputNumber v-model="exitForm.quantity" :min="0" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Concepto presupuesto" class="span-2">
|
||||||
|
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Destino físico" class="span-2">
|
||||||
|
<InputText v-model="exitForm.destination_note" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<Message v-if="exitWarning" severity="warn" :closable="false">{{ exitWarning }}</Message>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancelar" text @click="showExit = false" />
|
||||||
|
<Button label="Registrar salida" icon="pi pi-check" @click="saveExit" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="showTransfer" header="Transferencia central ↔ obra" modal :style="{ width: '640px' }">
|
||||||
|
<div class="form-grid form-grid-compact">
|
||||||
|
<FormField label="Desde">
|
||||||
|
<Select v-model="transferForm.from_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Hacia">
|
||||||
|
<Select v-model="transferForm.to_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Material">
|
||||||
|
<Select v-model="transferForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Cantidad">
|
||||||
|
<InputNumber v-model="transferForm.quantity" :min="0" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancelar" text @click="showTransfer = false" />
|
||||||
|
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
|
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
||||||
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
|
||||||
|
type WarehouseRow = { id: number; kind: string; project_id?: number; project_code?: string; label: string };
|
||||||
|
type StockRow = Record<string, unknown>;
|
||||||
|
type MovementRow = Record<string, unknown>;
|
||||||
|
|
||||||
|
const desk = useDesktop();
|
||||||
|
const { api } = useApi();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const tableQ = ref("");
|
||||||
|
const warehouses = ref<WarehouseRow[]>([]);
|
||||||
|
const stockRows = ref<StockRow[]>([]);
|
||||||
|
const movements = ref<MovementRow[]>([]);
|
||||||
|
const materialOptions = ref<{ id: number; label: string }[]>([]);
|
||||||
|
const showEntry = ref(false);
|
||||||
|
const showExit = ref(false);
|
||||||
|
const showTransfer = ref(false);
|
||||||
|
const exitWarning = ref("");
|
||||||
|
|
||||||
|
const entryForm = reactive({ warehouse_id: 0, material_code: "", material_name: "", quantity: 0, unit_cost: 0 });
|
||||||
|
const exitForm = reactive({ warehouse_id: 0, material_id: 0, quantity: 0, budget_item_id: null as number | null, destination_note: "" });
|
||||||
|
const transferForm = reactive({ from_warehouse_id: 0, to_warehouse_id: 0, material_id: 0, quantity: 0 });
|
||||||
|
|
||||||
|
const projectWarehouses = computed(() => warehouses.value.filter((w) => w.kind === "project"));
|
||||||
|
const exitProjectId = computed(() => projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0);
|
||||||
|
const stockValue = computed(() => stockRows.value.reduce((s, r) => s + Number(r.stock_value || 0), 0));
|
||||||
|
const filteredStock = computed(() => {
|
||||||
|
const q = tableQ.value.toLowerCase();
|
||||||
|
return stockRows.value.filter((r) =>
|
||||||
|
!q || [r.material_name, r.material_code, r.project_code].join(" ").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function money(v: number) {
|
||||||
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWarehouses() {
|
||||||
|
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} });
|
||||||
|
const r = await api<{ data?: { warehouses?: Array<Record<string, unknown>> } }>("/v1/warehouses");
|
||||||
|
const rows = (r.data?.warehouses ?? []) as Array<Record<string, unknown>>;
|
||||||
|
warehouses.value = rows.map((w) => ({
|
||||||
|
id: Number(w.id),
|
||||||
|
kind: String(w.kind),
|
||||||
|
project_id: w.project_id ? Number(w.project_id) : undefined,
|
||||||
|
project_code: String(w.project_code ?? (w.kind === "central" ? "CENTRAL" : "")),
|
||||||
|
label: w.kind === "central" ? "Almacén central" : `${w.project_code} — ${w.project_name ?? "Obra"}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStock() {
|
||||||
|
const r = await api<{ data?: { stock?: StockRow[] } }>("/v1/warehouses/stock/global");
|
||||||
|
stockRows.value = r.data?.stock ?? [];
|
||||||
|
const mats = new Map<number, string>();
|
||||||
|
for (const row of stockRows.value) {
|
||||||
|
mats.set(Number(row.material_id), `${row.material_code} — ${row.material_name}`);
|
||||||
|
}
|
||||||
|
materialOptions.value = [...mats.entries()].map(([id, label]) => ({ id, label }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMovements() {
|
||||||
|
const wh = warehouses.value[0]?.id;
|
||||||
|
if (!wh) return;
|
||||||
|
const r = await api<{ data?: { movements?: MovementRow[] } }>(`/v1/warehouses/${wh}/movements`);
|
||||||
|
movements.value = r.data?.movements ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEntry() {
|
||||||
|
try {
|
||||||
|
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
|
||||||
|
method: "POST",
|
||||||
|
json: entryForm,
|
||||||
|
});
|
||||||
|
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
|
||||||
|
showEntry.value = false;
|
||||||
|
await loadStock();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveExit() {
|
||||||
|
try {
|
||||||
|
const r = await api<{ data?: { budget_warning?: { message: string } }; message?: string }>(
|
||||||
|
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
|
||||||
|
{ method: "POST", json: exitForm },
|
||||||
|
);
|
||||||
|
exitWarning.value = r.data?.budget_warning?.message ?? "";
|
||||||
|
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
|
||||||
|
if (!exitWarning.value) showExit.value = false;
|
||||||
|
await loadStock();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTransfer() {
|
||||||
|
try {
|
||||||
|
const r = await api<{ message?: string }>("/v1/warehouse-transfers", { method: "POST", json: transferForm });
|
||||||
|
toast.add({ severity: "success", summary: r.message || "Transferencia completada", life: 2000 });
|
||||||
|
showTransfer.value = false;
|
||||||
|
await loadStock();
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
desk.configure({
|
||||||
|
tab: "Almacén",
|
||||||
|
ribbon: [{
|
||||||
|
key: "wh",
|
||||||
|
label: "Movimientos",
|
||||||
|
items: [
|
||||||
|
{ key: "entry", label: "Entrada", icon: "pi pi-download", command: () => { showEntry.value = true; } },
|
||||||
|
{ key: "exit", label: "Salida", icon: "pi pi-upload", command: () => { showExit.value = true; } },
|
||||||
|
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", command: () => { showTransfer.value = true; } },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
await loadWarehouses();
|
||||||
|
await loadStock();
|
||||||
|
await loadMovements();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
153
web-panel/pages/control-presupuesto.vue
Normal file
153
web-panel/pages/control-presupuesto.vue
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<template>
|
||||||
|
<div class="control-page">
|
||||||
|
<DeskTableBar v-model="tableQ" placeholder="Buscar partida o concepto">
|
||||||
|
<Chip :label="projectName" icon="pi pi-building" />
|
||||||
|
<Chip v-if="summary" :label="`${summary.pct_executed}% ejecutado`" icon="pi pi-chart-line" />
|
||||||
|
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
|
||||||
|
</DeskTableBar>
|
||||||
|
|
||||||
|
<div v-if="summary" class="kpi-row mb-3">
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">Presupuesto</span>
|
||||||
|
<strong>{{ money(summary.budget_subtotal) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">Ejecutado</span>
|
||||||
|
<strong>{{ money(summary.executed_subtotal) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">En bodega</span>
|
||||||
|
<strong>{{ money(summary.warehouse_stock_value) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">Sin clasificar</span>
|
||||||
|
<strong>{{ money(summary.unclassified_amount) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<span class="kpi-label">Conceptos en rojo</span>
|
||||||
|
<strong>{{ summary.concepts_over_budget }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="filteredItems"
|
||||||
|
data-key="id"
|
||||||
|
paginator
|
||||||
|
:rows="50"
|
||||||
|
scrollable
|
||||||
|
scroll-height="flex"
|
||||||
|
table-style="min-width: 72rem"
|
||||||
|
empty-message="Seleccione una obra con presupuesto cargado."
|
||||||
|
>
|
||||||
|
<Column field="code" header="Clave" style="width: 7rem">
|
||||||
|
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
|
||||||
|
</Column>
|
||||||
|
<Column field="description" header="Concepto" />
|
||||||
|
<Column field="amount_budget" header="Ppto $" style="width: 9rem">
|
||||||
|
<template #body="{ data }">{{ money(data.amount_budget) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="amount_executed" header="Ejecutado $" style="width: 9rem">
|
||||||
|
<template #body="{ data }">{{ money(data.amount_executed) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="pct_amount" header="%" style="width: 6rem">
|
||||||
|
<template #body="{ data }"><CostSemaphore :pct="Number(data.pct_amount)" compact /></template>
|
||||||
|
</Column>
|
||||||
|
<Column field="delta_amount" header="Δ $" style="width: 9rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span :class="Number(data.delta_amount) > 0 ? 'cost-critical-text' : ''">{{ money(data.delta_amount) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="qty_budget" header="Ppto cant." style="width: 8rem" />
|
||||||
|
<Column field="qty_executed" header="Ejec. cant." style="width: 8rem" />
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import CostSemaphore from "~/components/CostSemaphore.vue";
|
||||||
|
|
||||||
|
type ControlItem = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
amount_budget: number;
|
||||||
|
amount_executed: number;
|
||||||
|
pct_amount: number;
|
||||||
|
delta_amount: number;
|
||||||
|
qty_budget: number;
|
||||||
|
qty_executed: number;
|
||||||
|
semaphore: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Summary = {
|
||||||
|
budget_subtotal: number;
|
||||||
|
executed_subtotal: number;
|
||||||
|
pct_executed: number;
|
||||||
|
warehouse_stock_value: number;
|
||||||
|
unclassified_amount: number;
|
||||||
|
concepts_over_budget: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const desk = useDesktop();
|
||||||
|
const { api } = useApi();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const tableQ = ref("");
|
||||||
|
const projects = ref<{ id: number; name: string }[]>([]);
|
||||||
|
const items = ref<ControlItem[]>([]);
|
||||||
|
const summary = ref<Summary | null>(null);
|
||||||
|
|
||||||
|
const projectId = computed(() => Number(route.query.project_id || 0) || projects.value[0]?.id || 0);
|
||||||
|
const projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra");
|
||||||
|
const filteredItems = computed(() => {
|
||||||
|
const q = tableQ.value.toLowerCase();
|
||||||
|
return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
function money(v: number) {
|
||||||
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjects() {
|
||||||
|
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
|
||||||
|
projects.value = r.projects ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadControl() {
|
||||||
|
if (!projectId.value) return;
|
||||||
|
const [s, i] = await Promise.all([
|
||||||
|
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
|
||||||
|
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
|
||||||
|
]);
|
||||||
|
summary.value = s.data ?? null;
|
||||||
|
items.value = i.data?.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadProjects();
|
||||||
|
desk.configure({
|
||||||
|
tab: "Control presupuesto",
|
||||||
|
treeTitle: "Obras",
|
||||||
|
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
|
||||||
|
onTreeSelect: (key) => {
|
||||||
|
if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await loadControl();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(projectId, loadControl);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kpi-row { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.kpi-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
|
||||||
|
.cost-critical-text { color: #b42318; font-weight: 600; }
|
||||||
|
</style>
|
||||||
235
web-panel/pages/gastos.vue
Normal file
235
web-panel/pages/gastos.vue
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
<template>
|
||||||
|
<div class="gastos-page">
|
||||||
|
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
|
||||||
|
<Chip :label="projectLabel" icon="pi pi-building" />
|
||||||
|
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" />
|
||||||
|
<Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
|
||||||
|
</DeskTableBar>
|
||||||
|
|
||||||
|
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
|
||||||
|
{{ budgetWarning.message }}
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="listed"
|
||||||
|
data-key="id"
|
||||||
|
paginator
|
||||||
|
:rows="25"
|
||||||
|
empty-message="No hay gastos en el periodo seleccionado."
|
||||||
|
scrollable
|
||||||
|
scroll-height="flex"
|
||||||
|
table-style="min-width: 56rem"
|
||||||
|
>
|
||||||
|
<Column field="expense_date" header="Fecha" sortable style="width: 7rem" />
|
||||||
|
<Column field="description" header="Descripción" sortable />
|
||||||
|
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
|
||||||
|
<Column field="budget_item_code" header="Concepto" style="width: 8rem">
|
||||||
|
<template #body="{ data }"><span class="identifier">{{ data.budget_item_code || "—" }}</span></template>
|
||||||
|
</Column>
|
||||||
|
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
|
||||||
|
<template #body="{ data }">{{ money(data.subtotal) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="iva_amount" header="IVA" sortable style="width: 8rem">
|
||||||
|
<template #body="{ data }">{{ money(data.iva_amount) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="total" header="Total" sortable style="width: 9rem">
|
||||||
|
<template #body="{ data }"><strong>{{ money(data.total) }}</strong></template>
|
||||||
|
</Column>
|
||||||
|
<Column field="source" header="Origen" style="width: 7rem" />
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="showForm" header="Nuevo gasto" modal :style="{ width: '720px' }">
|
||||||
|
<div class="form-grid form-grid-compact">
|
||||||
|
<FormField label="Empresa" required>
|
||||||
|
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Obra (opcional)">
|
||||||
|
<Select v-model="form.project_id" :options="projects" option-label="name" option-value="id" show-clear placeholder="Gasto de empresa" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Fecha" required>
|
||||||
|
<InputText v-model="form.expense_date" type="date" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Concepto presupuesto" class="span-2">
|
||||||
|
<BudgetItemPicker v-if="form.project_id" v-model="form.budget_item_id" :project-id="form.project_id" />
|
||||||
|
<InputText v-else disabled placeholder="Seleccione una obra para ligar concepto" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Descripción" class="span-2" required>
|
||||||
|
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Proveedor">
|
||||||
|
<InputText v-model="form.supplier_name" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="RFC proveedor">
|
||||||
|
<InputText v-model="form.supplier_rfc" class="w-full identifier-input" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Cantidad">
|
||||||
|
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Unidad">
|
||||||
|
<InputText v-model="form.unit" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Precio unitario">
|
||||||
|
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Subtotal">
|
||||||
|
<InputNumber v-model="form.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="IVA incluido en total">
|
||||||
|
<Checkbox v-model="form.tax_included" binary />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Total con IVA">
|
||||||
|
<InputNumber v-model="form.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Comprobante PDF" class="span-2">
|
||||||
|
<input type="file" accept="application/pdf" @change="onFile" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cancelar" text @click="showForm = false" />
|
||||||
|
<Button label="Guardar" icon="pi pi-check" @click="save" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
|
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
||||||
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
|
||||||
|
type Expense = {
|
||||||
|
id: number;
|
||||||
|
expense_date: string;
|
||||||
|
description: string;
|
||||||
|
supplier_name: string;
|
||||||
|
budget_item_code?: string;
|
||||||
|
subtotal: number;
|
||||||
|
iva_amount: number;
|
||||||
|
total: number;
|
||||||
|
source: string;
|
||||||
|
project_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const desk = useDesktop();
|
||||||
|
const { api } = useApi();
|
||||||
|
const toast = useToast();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const tableQ = ref("");
|
||||||
|
const expenses = ref<Expense[]>([]);
|
||||||
|
const companies = ref<{ id: number; name: string }[]>([]);
|
||||||
|
const projects = ref<{ id: number; name: string }[]>([]);
|
||||||
|
const showForm = ref(false);
|
||||||
|
const formError = ref("");
|
||||||
|
const budgetWarning = ref<{ level: string; message: string } | null>(null);
|
||||||
|
const receiptFile = ref<File | null>(null);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
company_id: null as number | null,
|
||||||
|
project_id: null as number | null,
|
||||||
|
expense_date: new Date().toISOString().slice(0, 10),
|
||||||
|
budget_item_id: null as number | null,
|
||||||
|
description: "",
|
||||||
|
supplier_name: "",
|
||||||
|
supplier_rfc: "",
|
||||||
|
quantity: 0,
|
||||||
|
unit: "",
|
||||||
|
unit_price: 0,
|
||||||
|
subtotal: 0,
|
||||||
|
total: 0,
|
||||||
|
tax_included: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectId = computed(() => Number(route.query.project_id || 0) || null);
|
||||||
|
const projectLabel = computed(() => {
|
||||||
|
const p = projects.value.find((x) => x.id === projectId.value);
|
||||||
|
return p?.name || "Todas las obras";
|
||||||
|
});
|
||||||
|
const listed = computed(() => {
|
||||||
|
const q = tableQ.value.toLowerCase();
|
||||||
|
return expenses.value.filter((e) =>
|
||||||
|
!q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const totalSubtotal = computed(() => listed.value.reduce((s, e) => s + Number(e.subtotal || 0), 0));
|
||||||
|
|
||||||
|
function money(v: number) {
|
||||||
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFile(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
receiptFile.value = input.files?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCatalogs() {
|
||||||
|
const [cat, pr] = await Promise.all([
|
||||||
|
api<{ companies?: { id: number; name: string }[] }>("/v1/catalogs"),
|
||||||
|
api<{ projects?: { id: number; name: string }[] }>("/v1/projects"),
|
||||||
|
]);
|
||||||
|
companies.value = cat.companies ?? [];
|
||||||
|
projects.value = pr.projects ?? [];
|
||||||
|
if (!form.company_id && companies.value[0]) form.company_id = companies.value[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExpenses() {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (projectId.value) qs.set("project_id", String(projectId.value));
|
||||||
|
const r = await api<{ data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
|
||||||
|
expenses.value = (r.data?.expenses ?? []) as Expense[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
formError.value = "";
|
||||||
|
if (!form.company_id || !form.description.trim()) {
|
||||||
|
formError.value = "Empresa y descripción son obligatorios";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
|
||||||
|
"/v1/expenses",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
json: { ...form, project_id: form.project_id || null },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const expenseId = r.data?.expense?.id;
|
||||||
|
if (expenseId && receiptFile.value) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", receiptFile.value);
|
||||||
|
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
|
||||||
|
}
|
||||||
|
budgetWarning.value = r.data?.budget_warning ?? null;
|
||||||
|
toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 });
|
||||||
|
showForm.value = false;
|
||||||
|
await loadExpenses();
|
||||||
|
} catch (e) {
|
||||||
|
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadCatalogs();
|
||||||
|
desk.configure({
|
||||||
|
tab: "Gastos",
|
||||||
|
treeTitle: "Obras",
|
||||||
|
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
|
||||||
|
onTreeSelect: (key) => {
|
||||||
|
if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`);
|
||||||
|
},
|
||||||
|
ribbon: [
|
||||||
|
{
|
||||||
|
key: "gastos",
|
||||||
|
label: "Captura",
|
||||||
|
items: [
|
||||||
|
{ key: "new", label: "Nuevo gasto", icon: "pi pi-plus", command: () => { showForm.value = true; } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await loadExpenses();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(projectId, loadExpenses);
|
||||||
|
</script>
|
||||||
78
web-panel/pages/usuarios.vue
Normal file
78
web-panel/pages/usuarios.vue
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<template>
|
||||||
|
<div class="usuarios-page">
|
||||||
|
<DeskTableBar v-model="tableQ" placeholder="Buscar usuario">
|
||||||
|
<Chip :label="`${users.length} usuarios`" icon="pi pi-users" />
|
||||||
|
</DeskTableBar>
|
||||||
|
|
||||||
|
<DataTable :value="listed" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
|
||||||
|
<Column field="username" header="Usuario" style="width: 10rem" />
|
||||||
|
<Column field="display_name" header="Nombre" />
|
||||||
|
<Column field="role_code" header="Rol" style="width: 9rem" />
|
||||||
|
<Column field="email" header="Correo" style="width: 14rem" />
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<Panel header="Permisos del rol operativo (user)" class="mt-4">
|
||||||
|
<div class="perm-grid">
|
||||||
|
<div v-for="perm in permissions" :key="perm.code" class="perm-item">
|
||||||
|
<Checkbox v-model="rolePerms" :input-id="perm.code" :value="perm.code" />
|
||||||
|
<label :for="perm.code">{{ perm.label }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button label="Guardar permisos" icon="pi pi-save" class="mt-3" @click="saveRolePerms" />
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
|
||||||
|
type UserRow = { id: number; username: string; display_name: string; role_code: string; email: string };
|
||||||
|
type PermRow = { code: string; label: string };
|
||||||
|
|
||||||
|
const desk = useDesktop();
|
||||||
|
const { api } = useApi();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const tableQ = ref("");
|
||||||
|
const users = ref<UserRow[]>([]);
|
||||||
|
const permissions = ref<PermRow[]>([]);
|
||||||
|
const rolePerms = ref<string[]>([]);
|
||||||
|
|
||||||
|
const listed = computed(() => {
|
||||||
|
const q = tableQ.value.toLowerCase();
|
||||||
|
return users.value.filter((u) => !q || [u.username, u.display_name, u.email].join(" ").toLowerCase().includes(q));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const [u, p, rp] = await Promise.all([
|
||||||
|
api<{ data?: { users?: UserRow[] } }>("/v1/iam/users"),
|
||||||
|
api<{ data?: { permissions?: PermRow[] } }>("/v1/iam/permissions"),
|
||||||
|
api<{ data?: { permissions?: string[] } }>("/v1/iam/roles/user/permissions"),
|
||||||
|
]);
|
||||||
|
users.value = u.data?.users ?? [];
|
||||||
|
permissions.value = p.data?.permissions ?? [];
|
||||||
|
rolePerms.value = rp.data?.permissions ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRolePerms() {
|
||||||
|
try {
|
||||||
|
const r = await api<{ message?: string }>("/v1/iam/roles/user/permissions", {
|
||||||
|
method: "PUT",
|
||||||
|
json: { permissions: rolePerms.value },
|
||||||
|
});
|
||||||
|
toast.add({ severity: "success", summary: r.message || "Permisos guardados", life: 2500 });
|
||||||
|
} catch (e) {
|
||||||
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
desk.configure({ tab: "Usuarios IAM", ribbon: [] });
|
||||||
|
await load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 8px 16px; }
|
||||||
|
.perm-item { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue