From 90bcd48f95746ef7e236479299dddeedb2d6d04f Mon Sep 17 00:00:00 2001
From: Alberto Martinez <349769123@users.noreply.cursor.com>
Date: Fri, 25 Sep 2026 03:26:30 +0000
Subject: [PATCH] =?UTF-8?q?Facturas,=20n=C3=B3mina,=20dispersi=C3=B3n=20e?=
=?UTF-8?q?=20IMSS=20(#28)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Resumen
Implementa las observaciones de facturas, nómina e IMSS sobre el catálogo de proveedores y la nómina semanal que ya existían.
## Qué queda disponible
- Proveedores: régimen fiscal, código postal, estatus activo/inactivo y filtro por nombre o RFC.
- Facturas recibidas y emitidas: Excel, UUID, revisión si el RFC no está en el catálogo, clasificación a obra/concepto y gasto `source = invoice` solo para la recibida clasificada.
- Nómina: bono, descuento con observación, horas y día festivo dentro del armado semanal. La semana pagada no acepta cambios.
- Préstamos: edición (libre sin pagos; con pagos solo nota y cuota) y estado de cuenta con saldo = entregado + comisión − pagos.
- Finiquitos y liquidaciones: conceptos, neto descontando descuentos vigentes y saldo de préstamo, y gasto al pagar.
- Dispersión: corte semanal, corrección antes de definitiva y comprobante.
- Historial de alta, baja y reingreso en la ficha, más movimientos IMSS.
- Reporte por periodo en Excel y PDF.
- Jornal IMSS distinto del jornal real y consulta de cuotas exportable a Excel.
## Verificación
`deno check` de la API pasó. En este entorno no hay Postgres, así que `api/pendientes_test.ts` queda listo para correr cuando la base tenga las migraciones 042–050 y el permiso IAM 007.
---
api/expense_catalogs_http.ts | 1 +
api/iam_catalog.ts | 7 +-
api/invoices_http.ts | 109 +++
api/main.ts | 14 +-
api/payroll_http.ts | 222 ++++++
api/pendientes_test.ts | 219 ++++++
api/storage.ts | 3 +
db/core/changelog-master.xml | 9 +
db/core/changesets/042-suppliers-fiscal.sql | 160 ++++
db/core/changesets/043-invoices.sql | 427 +++++++++++
.../changesets/044-payroll-adjustments.sql | 715 ++++++++++++++++++
db/core/changesets/045-loan-update.sql | 114 +++
db/core/changesets/046-settlements.sql | 230 ++++++
db/core/changesets/047-dispersion.sql | 239 ++++++
db/core/changesets/048-worker-movements.sql | 104 +++
db/core/changesets/049-payroll-report.sql | 81 ++
db/core/changesets/050-imss-wage.sql | 129 ++++
db/iam/changelog-master.xml | 1 +
db/iam/changesets/007-invoices-permission.sql | 29 +
web-panel/components/SupplierFormDialog.vue | 18 +
web-panel/components/WorkerFicha.vue | 21 +
.../components/padron/WorkerFormDialog.vue | 1 +
web-panel/composables/iamPermissionMatrix.ts | 5 +
web-panel/layouts/default.vue | 1 +
web-panel/middleware/auth.global.ts | 1 +
web-panel/pages/configuracion.vue | 8 +
web-panel/pages/facturas.vue | 138 ++++
web-panel/pages/nomina.vue | 148 ++++
web-panel/types/padron.ts | 3 +
29 files changed, 3153 insertions(+), 4 deletions(-)
create mode 100644 api/invoices_http.ts
create mode 100644 api/pendientes_test.ts
create mode 100644 db/core/changesets/042-suppliers-fiscal.sql
create mode 100644 db/core/changesets/043-invoices.sql
create mode 100644 db/core/changesets/044-payroll-adjustments.sql
create mode 100644 db/core/changesets/045-loan-update.sql
create mode 100644 db/core/changesets/046-settlements.sql
create mode 100644 db/core/changesets/047-dispersion.sql
create mode 100644 db/core/changesets/048-worker-movements.sql
create mode 100644 db/core/changesets/049-payroll-report.sql
create mode 100644 db/core/changesets/050-imss-wage.sql
create mode 100644 db/iam/changesets/007-invoices-permission.sql
create mode 100644 web-panel/pages/facturas.vue
diff --git a/api/expense_catalogs_http.ts b/api/expense_catalogs_http.ts
index f2d133c..01293f4 100644
--- a/api/expense_catalogs_http.ts
+++ b/api/expense_catalogs_http.ts
@@ -22,6 +22,7 @@ export function registerExpenseCatalogRoutes(app: App) {
const env = await callCoreFn(db, "core.fn_supplier_list", {
tenant_id: tid(c),
status: c.req.query("status"),
+ q: c.req.query("q"),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
diff --git a/api/iam_catalog.ts b/api/iam_catalog.ts
index 4c50f97..7611118 100644
--- a/api/iam_catalog.ts
+++ b/api/iam_catalog.ts
@@ -36,6 +36,9 @@ export const PERMISSION_CATALOG: PermissionDef[] = [
{ code: "work_program.create", label: "Importar/generar programa", module: "work_program", verb: "create" },
{ code: "work_program.update", label: "Capturar avances", module: "work_program", verb: "update" },
{ code: "cost_control.view", label: "Control de costos", module: "cost_control", verb: "view" },
+ { code: "invoices.view", label: "Ver facturas", module: "invoices", verb: "view" },
+ { code: "invoices.create", label: "Importar facturas", module: "invoices", verb: "create" },
+ { code: "invoices.update", label: "Clasificar facturas", module: "invoices", verb: "update" },
{ code: "expenses.view", label: "Ver gastos", module: "expenses", verb: "view" },
{ code: "expenses.create", label: "Capturar gastos", module: "expenses", verb: "create" },
{ code: "expenses.update", label: "Editar gastos", module: "expenses", verb: "update" },
@@ -80,8 +83,8 @@ export const LEGACY_TO_V2: Record = {
manage_projects: ["projects.view", "projects.create", "projects.update"],
manage_companies: ["companies.view", "companies.create", "companies.update"],
manage_budget: ["budget.view", "budget.create", "budget.update", "budget.delete", "work_program.view", "work_program.create", "work_program.update"],
- manage_expenses: ["expenses.view", "expenses.create", "expenses.update", "expenses.delete", "cost_control.view"],
- view_expenses: ["expenses.view", "cost_control.view"],
+ manage_expenses: ["expenses.view", "expenses.create", "expenses.update", "expenses.delete", "cost_control.view", "invoices.view", "invoices.create", "invoices.update"],
+ view_expenses: ["expenses.view", "cost_control.view", "invoices.view"],
manage_payroll: ["payroll.view", "payroll.create", "payroll.update", "payroll.delete"],
manage_documents: ["documents.view", "documents.create", "project_docs.tecnico.view", "project_docs.tecnico.create", "project_docs.contrato.view", "project_docs.contrato.create", "project_docs.permisos.view", "project_docs.permisos.create"],
manage_users: ["users.view", "users.create", "users.update", "users.delete"],
diff --git a/api/invoices_http.ts b/api/invoices_http.ts
new file mode 100644
index 0000000..7e0b0a2
--- /dev/null
+++ b/api/invoices_http.ts
@@ -0,0 +1,109 @@
+import type { Hono } from "hono";
+import * as XLSX from "xlsx";
+import type { AuthUser } from "./auth.ts";
+import { tenantScope } from "./auth.ts";
+import { requireCoreAuth } from "./scope.ts";
+import { requirePermission } from "./permissions.ts";
+import { callCoreFn } from "./rpc.ts";
+import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
+import type { Db } from "./db.ts";
+
+type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
+
+function tid(c: { get: (k: "user") => AuthUser }): number {
+ return tenantScope(c.get("user")) ?? 0;
+}
+
+function num(v: unknown): number {
+ const n = Number(String(v ?? "").replace(/[$,\s]/g, ""));
+ return Number.isFinite(n) ? n : 0;
+}
+
+function mapInvoiceRow(raw: Record) {
+ const r: Record = {};
+ for (const [k, v] of Object.entries(raw)) {
+ r[k.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().trim()] = String(v ?? "").trim();
+ }
+ const pick = (...keys: string[]) => keys.map((k) => r[k]).find((v) => v) ?? "";
+ return {
+ direction: pick("direccion", "dirección", "tipo") || "recibida",
+ rfc: pick("rfc"),
+ supplier_name: pick("proveedor", "nombre"),
+ uuid: pick("uuid"),
+ serie: pick("serie"),
+ folio: pick("folio"),
+ invoice_date: pick("fecha", "invoice_date"),
+ description: pick("descripcion", "descripción", "concepto"),
+ quantity: num(pick("cantidad")),
+ unit: pick("unidad"),
+ amount: num(pick("importe", "subtotal")),
+ iva: num(pick("iva")),
+ total: num(pick("total")),
+ project_code: pick("obra", "clave obra", "proyecto"),
+ item_code: pick("clave concepto", "concepto presupuesto", "partida"),
+ };
+}
+
+export function registerInvoiceRoutes(app: App) {
+ app.get("/v1/invoices", ...requireCoreAuth, requirePermission("invoices.view"), async (c) => {
+ const db = c.get("db");
+ const env = await callCoreFn(db, "core.fn_invoice_list", {
+ tenant_id: tid(c),
+ direction: c.req.query("direction"),
+ supplier_id: c.req.query("supplier_id"),
+ rfc: c.req.query("rfc"),
+ uuid: c.req.query("uuid"),
+ folio: c.req.query("folio"),
+ q: c.req.query("q"),
+ amount_min: c.req.query("amount_min"),
+ amount_max: c.req.query("amount_max"),
+ date_from: c.req.query("date_from"),
+ date_to: c.req.query("date_to"),
+ project_id: c.req.query("project_id"),
+ classified: c.req.query("classified"),
+ }, { route: routeLabel(c) });
+ return respondRpc(c, env);
+ });
+
+ app.get("/v1/invoices/:id", ...requireCoreAuth, requirePermission("invoices.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_invoice_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/invoices/import", ...requireCoreAuth, requirePermission("invoices.create"), async (c) => {
+ const body = await c.req.parseBody();
+ const companyId = Number(body.company_id);
+ const file = body.file;
+ if (!(file instanceof File)) {
+ return respondApiError(c, "VALIDATION", "Adjunte el Excel de facturas", { route: routeLabel(c) });
+ }
+ const wb = XLSX.read(new Uint8Array(await file.arrayBuffer()), { type: "array", cellDates: true });
+ const sheet = wb.Sheets[wb.SheetNames[0]];
+ const raw = XLSX.utils.sheet_to_json>(sheet, { defval: "", raw: false });
+ const rows = raw.map(mapInvoiceRow).map((row) => {
+ const date = row.invoice_date;
+ const iso = /^\d{4}-\d{2}-\d{2}/.test(date)
+ ? date.slice(0, 10)
+ : /^\d{2}\/\d{2}\/\d{4}/.test(date)
+ ? `${date.slice(6, 10)}-${date.slice(3, 5)}-${date.slice(0, 2)}`
+ : date;
+ return { ...row, invoice_date: iso };
+ });
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_invoice_import", {
+ tenant_id: tid(c),
+ company_id: companyId,
+ rows,
+ }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/invoices/:id/classify", ...requireCoreAuth, requirePermission("invoices.update"), async (c) => {
+ const body = await c.req.json>();
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_invoice_classify", {
+ ...body,
+ id: Number(c.req.param("id")),
+ tenant_id: tid(c),
+ }, { route: routeLabel(c) }));
+ });
+}
diff --git a/api/main.ts b/api/main.ts
index 571e102..7675601 100644
--- a/api/main.ts
+++ b/api/main.ts
@@ -68,6 +68,7 @@ import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pd
import { registerPayrollRoutes } from "./payroll_http.ts";
import { registerExpenseRoutes } from "./expenses_http.ts";
import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts";
+import { registerInvoiceRoutes } from "./invoices_http.ts";
import { registerWarehouseRoutes } from "./warehouse_http.ts";
import { registerCostControlRoutes } from "./cost_control_http.ts";
import { registerWorkProgramRoutes } from "./work_program_http.ts";
@@ -1101,12 +1102,20 @@ app.patch("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.upda
if (cf) {
return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id });
}
- return respondRpc(c, await callCoreFn(
+ const updated = await callCoreFn(
db,
"core.fn_worker_update",
{ id, tenant_id: tid, ...body },
{ route },
- ));
+ );
+ if (!updated.ok) return respondRpc(c, updated);
+ if ("imss_daily_wage" in body) {
+ const wage = await callCoreFn(db, "core.fn_worker_imss_wage_set", {
+ id, imss_daily_wage: (body as { imss_daily_wage?: number | null }).imss_daily_wage,
+ }, { route });
+ if (!wage.ok) return respondRpc(c, wage);
+ }
+ return respondRpc(c, updated);
});
app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, requirePermission("workers.update"), async (c) => {
@@ -1442,6 +1451,7 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePerm
registerPayrollRoutes(app);
registerExpenseRoutes(app);
registerExpenseCatalogRoutes(app);
+registerInvoiceRoutes(app);
registerWarehouseRoutes(app);
registerCostControlRoutes(app);
registerWorkProgramRoutes(app);
diff --git a/api/payroll_http.ts b/api/payroll_http.ts
index 3330ae6..f58da8a 100644
--- a/api/payroll_http.ts
+++ b/api/payroll_http.ts
@@ -6,6 +6,11 @@ import { requirePermission } from "./permissions.ts";
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
import { projectById, projectMustBe, type Db } from "./db.ts";
import { generateLoanReceiptPdf } from "./pdf.ts";
+import { PDFDocument, StandardFonts } from "pdf-lib";
+import * as XLSX from "xlsx";
+import { randomBytes } from "node:crypto";
+import { encryptBytes } from "./docs_crypto.ts";
+import { dispersionDocKey, putObject } from "./storage.ts";
import { fullName } from "./mx.ts";
import { callCoreFn } from "./rpc.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
@@ -462,6 +467,223 @@ export function registerPayrollRoutes(app: App) {
return c.body(csv);
});
+ app.get("/v1/payroll/adjustments", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_payroll_adjustment_list", {
+ tenant_id: tid(c),
+ week_id: c.req.query("week_id"),
+ worker_id: c.req.query("worker_id"),
+ project_id: c.req.query("project_id"),
+ kind: c.req.query("kind"),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/payroll/adjustments", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
+ const body = await c.req.json>();
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_payroll_adjustment_save", {
+ ...body, tenant_id: tid(c),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.delete("/v1/payroll/adjustments/:id", ...requireCoreAuth, requirePermission("payroll.delete"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_payroll_adjustment_delete", {
+ id: Number(c.req.param("id")), tenant_id: tid(c),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.get("/v1/payroll/holidays", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_holiday_list", {
+ tenant_id: tid(c), date_from: c.req.query("date_from"), date_to: c.req.query("date_to"),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/payroll/holidays", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
+ const body = await c.req.json>();
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_holiday_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }));
+ });
+
+ app.patch("/v1/loans/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
+ const body = await c.req.json>();
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_loan_update", {
+ ...body, id: Number(c.req.param("id")),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.get("/v1/loans/:id/statement", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_loan_statement", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
+ });
+
+ app.get("/v1/settlements", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_settlement_list", {
+ tenant_id: tid(c), worker_id: c.req.query("worker_id"),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.get("/v1/settlements/:id", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_settlement_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/settlements", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
+ const body = await c.req.json>();
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_settlement_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/settlements/:id/pay", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_settlement_pay", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
+ });
+
+ app.get("/v1/payroll/weeks/:id/dispersion", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_get", {
+ tenant_id: tid(c), week_id: Number(c.req.param("id")),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/payroll/dispersions/:id/cut", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_cut", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
+ });
+
+ app.patch("/v1/payroll/dispersion-lines/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
+ const body = await c.req.json>();
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_line_update", {
+ ...body, line_id: Number(c.req.param("id")),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/payroll/dispersions/:id/finalize", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
+ const body = await c.req.json>().catch(() => ({}));
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_finalize", {
+ ...body, id: Number(c.req.param("id")),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.post("/v1/payroll/dispersions/:id/attachment", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
+ const id = Number(c.req.param("id"));
+ const form = await c.req.formData();
+ const file = form.get("file");
+ if (!(file instanceof File)) {
+ return respondApiError(c, "VALIDATION", "Adjunte el comprobante de la dispersión", { route: routeLabel(c) });
+ }
+ const bytes = new Uint8Array(await file.arrayBuffer());
+ const { iv, cipher } = await encryptBytes(bytes);
+ const storageName = `${Date.now()}-${randomBytes(8).toString("hex")}`;
+ await putObject(dispersionDocKey(id, storageName), cipher);
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_attachment_add", {
+ dispersion_id: id,
+ original_name: file.name,
+ mime: file.type || "application/pdf",
+ size_bytes: bytes.length,
+ sha256: "",
+ iv,
+ storage_name: storageName,
+ uploaded_by_name: c.get("user").display_name,
+ }, { route: routeLabel(c) }));
+ });
+
+ app.get("/v1/payroll/report.xlsx", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ const env = await callCoreFn<{ lines: Record[]; payable_total: number }>(db, "core.fn_payroll_report", {
+ tenant_id: tid(c),
+ date_from: c.req.query("date_from"),
+ date_to: c.req.query("date_to"),
+ worker_id: c.req.query("worker_id"),
+ project_id: c.req.query("project_id"),
+ }, { route: routeLabel(c) });
+ if (!env.ok || !env.data) return respondRpc(c, env);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(env.data.lines ?? []), "Nomina");
+ const out = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array;
+ c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ c.header("Content-Disposition", `attachment; filename="nomina.xlsx"`);
+ return c.body(out.buffer as ArrayBuffer);
+ });
+
+ app.get("/v1/payroll/report.pdf", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ const env = await callCoreFn<{ lines: Record[]; payable_total: number }>(db, "core.fn_payroll_report", {
+ tenant_id: tid(c),
+ date_from: c.req.query("date_from"),
+ date_to: c.req.query("date_to"),
+ worker_id: c.req.query("worker_id"),
+ project_id: c.req.query("project_id"),
+ }, { route: routeLabel(c) });
+ if (!env.ok || !env.data) return respondRpc(c, env);
+ const pdf = await PDFDocument.create();
+ const page = pdf.addPage([612, 792]);
+ const font = await pdf.embedFont(StandardFonts.Helvetica);
+ page.drawText(`Nómina ${c.req.query("date_from")} a ${c.req.query("date_to")}`, { x: 40, y: 750, size: 14, font });
+ page.drawText(`Neto ${env.data.payable_total}`, { x: 40, y: 730, size: 11, font });
+ let y = 700;
+ for (const line of (env.data.lines ?? []).slice(0, 30)) {
+ page.drawText(`${line.first_name ?? ""} ${line.last_name_p ?? ""} ${line.payable_net ?? ""}`, { x: 40, y, size: 9, font });
+ y -= 14;
+ if (y < 40) break;
+ }
+ const bytes = await pdf.save();
+ c.header("Content-Type", "application/pdf");
+ c.header("Content-Disposition", `attachment; filename="nomina.pdf"`);
+ return c.body(bytes.buffer as ArrayBuffer);
+ });
+
+ app.get("/v1/payroll/imss-quote.xlsx", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ const env = await callCoreFn<{ quotes: Record[] }>(db, "core.fn_imss_quote", {
+ tenant_id: tid(c),
+ worker_id: c.req.query("worker_id"),
+ date_from: c.req.query("date_from"),
+ date_to: c.req.query("date_to"),
+ }, { route: routeLabel(c) });
+ if (!env.ok || !env.data) return respondRpc(c, env);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(env.data.quotes ?? []), "IMSS");
+ const out = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array;
+ c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ c.header("Content-Disposition", `attachment; filename="imss.xlsx"`);
+ return c.body(out.buffer as ArrayBuffer);
+ });
+
+ app.get("/v1/payroll/report", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_payroll_report", {
+ tenant_id: tid(c),
+ date_from: c.req.query("date_from"),
+ date_to: c.req.query("date_to"),
+ worker_id: c.req.query("worker_id"),
+ project_id: c.req.query("project_id"),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.get("/v1/payroll/imss-quote", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_imss_quote", {
+ tenant_id: tid(c),
+ worker_id: c.req.query("worker_id"),
+ date_from: c.req.query("date_from"),
+ date_to: c.req.query("date_to"),
+ }, { route: routeLabel(c) }));
+ });
+
+ app.get("/v1/workers/:id/movements", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
+ const db = c.get("db");
+ return respondRpc(c, await callCoreFn(db, "core.fn_worker_movement_list", {
+ worker_id: Number(c.req.param("id")),
+ }, { route: routeLabel(c) }));
+ });
+
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
const id = Number(c.req.param("id"));
const { status } = await c.req.json<{ status: string }>();
diff --git a/api/pendientes_test.ts b/api/pendientes_test.ts
new file mode 100644
index 0000000..8196131
--- /dev/null
+++ b/api/pendientes_test.ts
@@ -0,0 +1,219 @@
+import { assert, assertEquals } from "jsr:@std/assert@1";
+import { callCoreFn } from "./rpc.ts";
+import { withTestDb } from "./test_helpers.ts";
+import type { PgDb } from "./pg.ts";
+
+const TENANT_ID = 999003;
+
+async function seed(db: PgDb) {
+ await db.prepare(
+ `INSERT INTO companies (code, name, kind, tenant_id) VALUES ('PNDCO', 'Pend Co', 'principal', ?)`,
+ ).run(TENANT_ID);
+ const companyId = await db.lastInsertId();
+ await db.prepare(
+ `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id)
+ VALUES ('PND-1', 'Obra Pend', 'activo', 'arctec-dos-logos-fold', ?, ?)`,
+ ).run(companyId, TENANT_ID);
+ const projectId = await db.lastInsertId();
+ await db.prepare(
+ `INSERT INTO budget_items (project_id, code, description, unit, quantity, unit_price, amount)
+ VALUES (?, 'MAT-1', 'Cemento', 'bulto', 1, 100, 100)`,
+ ).run(projectId);
+ await db.prepare(
+ `INSERT INTO workers
+ (first_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
+ hire_type, company_id, position, risk_code, work_type, daily_wage, tenant_id, status)
+ VALUES ('Ana', 'Lopez', 'Diaz', 'CURPPEND0001', 'RFCPEND0001', 'NSSPEND0001', '9991111111',
+ 'ana@example.com', 'Calle 1', 'PNDCO', ?, 'Ayudante', 'rojo', 'N', 400, ?, 'activo')`,
+ ).run(companyId, TENANT_ID);
+ const workerId = await db.lastInsertId();
+ await db.prepare(
+ `INSERT INTO assignments (worker_id, project_id, active, start_date) VALUES (?, ?, true, DATE '2026-09-21')`,
+ ).run(workerId, projectId);
+ return { companyId, projectId, workerId };
+}
+
+Deno.test("proveedor: RFC duplicado e inactivo fuera del listado activo", async () => {
+ await withTestDb(async (db) => {
+ const created = await callCoreFn(db, "core.fn_supplier_create", {
+ tenant_id: TENANT_ID, name: "Aceros", rfc: "ACE123456AB1", regimen_fiscal: "601", postal_code: "97000",
+ });
+ assertEquals(created.ok, true, JSON.stringify(created));
+ const dup = await callCoreFn(db, "core.fn_supplier_create", {
+ tenant_id: TENANT_ID, name: "Otro", rfc: "ACE123456AB1",
+ });
+ assertEquals(dup.ok, false);
+ const id = (created.data as { supplier: { id: number } }).supplier.id;
+ const off = await callCoreFn(db, "core.fn_supplier_update", { tenant_id: TENANT_ID, id, status: "inactivo" });
+ assertEquals(off.ok, true);
+ const listed = await callCoreFn(db, "core.fn_supplier_list", { tenant_id: TENANT_ID, status: "activo", q: "aceros" });
+ const rows = (listed.data as { suppliers: { name: string }[] }).suppliers;
+ assert(!rows.some((s) => s.name === "Aceros"));
+ });
+});
+
+Deno.test("facturas: RFC conocido, desconocido, UUID repetido y un solo gasto", async () => {
+ await withTestDb(async (db) => {
+ const fx = await seed(db);
+ await callCoreFn(db, "core.fn_supplier_create", { tenant_id: TENANT_ID, name: "Aceros", rfc: "ACE123456AB1" });
+ const known = await callCoreFn(db, "core.fn_invoice_import", {
+ tenant_id: TENANT_ID,
+ company_id: fx.companyId,
+ rows: [{
+ direction: "recibida", rfc: "ACE123456AB1", supplier_name: "Aceros", uuid: "UUID-1",
+ folio: "10", invoice_date: "2026-09-01", description: "Cemento", amount: 100, iva: 16, total: 116,
+ project_code: "PND-1", item_code: "MAT-1",
+ }],
+ });
+ assertEquals(known.ok, true, JSON.stringify(known));
+ const again = await callCoreFn(db, "core.fn_invoice_import", {
+ tenant_id: TENANT_ID,
+ company_id: fx.companyId,
+ rows: [{
+ direction: "recibida", rfc: "ACE123456AB1", uuid: "UUID-1", invoice_date: "2026-09-01", amount: 100,
+ }],
+ });
+ assertEquals((again.data as { skipped: number }).skipped, 1);
+ const unknown = await callCoreFn(db, "core.fn_invoice_import", {
+ tenant_id: TENANT_ID,
+ company_id: fx.companyId,
+ rows: [{ direction: "recibida", rfc: "XXX123456AB1", uuid: "UUID-2", invoice_date: "2026-09-02", amount: 50, supplier_name: "Nuevo" }],
+ });
+ assertEquals((unknown.data as { review: number }).review, 1);
+ const expenses = await db.prepare(
+ `SELECT COUNT(*)::int AS n FROM expense_entries WHERE source = 'invoice' AND tenant_id = ?`,
+ ).get(TENANT_ID) as { n: number };
+ assertEquals(expenses.n, 1);
+ });
+});
+
+Deno.test("nómina: bono, descuento, horas, festivo y semana pagada", async () => {
+ await withTestDb(async (db) => {
+ const fx = await seed(db);
+ const week = await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-21" });
+ assertEquals(week.ok, true, JSON.stringify(week));
+ const weekId = (week.data as { week_id: number }).week_id;
+ await callCoreFn(db, "core.fn_holiday_save", { tenant_id: TENANT_ID, holiday_date: "2026-09-21", name: "Puente" });
+ const bono = await callCoreFn(db, "core.fn_payroll_adjustment_save", {
+ tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, project_id: fx.projectId,
+ kind: "bono", concept: "Bono", amount: 100,
+ });
+ assertEquals(bono.ok, true, JSON.stringify(bono));
+ const missing = await callCoreFn(db, "core.fn_payroll_adjustment_save", {
+ tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, kind: "descuento", concept: "Falta", amount: 40,
+ });
+ assertEquals(missing.ok, false);
+ await callCoreFn(db, "core.fn_payroll_adjustment_save", {
+ tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, project_id: fx.projectId,
+ kind: "descuento", concept: "Herramienta", amount: 40, note: "Perdió herramienta",
+ });
+ await callCoreFn(db, "core.fn_payroll_adjustment_save", {
+ tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, project_id: fx.projectId,
+ kind: "horas", concept: "Extra", hours: 8,
+ });
+ const bundle = await callCoreFn(db, "core.fn_payroll_week_bundle", { week_id: weekId, tenant_id: TENANT_ID });
+ assertEquals(bundle.ok, true, JSON.stringify(bundle));
+ await db.prepare(`UPDATE payroll_weeks SET status = 'paid' WHERE id = ?`).run(weekId);
+ const blocked = await callCoreFn(db, "core.fn_payroll_adjustment_save", {
+ tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, kind: "bono", concept: "Tarde", amount: 1,
+ });
+ assertEquals(blocked.ok, false);
+ });
+});
+
+Deno.test("préstamo: edición sin pagos y saldo con pagos", async () => {
+ await withTestDb(async (db) => {
+ const fx = await seed(db);
+ const created = await callCoreFn(db, "core.fn_loan_create", {
+ tenant_id: TENANT_ID, worker_id: fx.workerId, delivered: 1000, commission_enabled: false,
+ });
+ assertEquals(created.ok, true, JSON.stringify(created));
+ const id = (created.data as { id: number }).id;
+ const edited = await callCoreFn(db, "core.fn_loan_update", { id, delivered: 800, commission_pct: 0, note: "Corregido" });
+ assertEquals(edited.ok, true, JSON.stringify(edited));
+ const week = await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-21" });
+ const weekId = (week.data as { week_id: number }).week_id;
+ await db.prepare(`INSERT INTO loan_payments (loan_id, week_id, amount) VALUES (?, ?, 200)`).run(id, weekId);
+ const statement = await callCoreFn(db, "core.fn_loan_statement", { id });
+ const loan = (statement.data as { loan: { balance: number; delivered: number } }).loan;
+ assertEquals(Number(loan.delivered), 800);
+ assertEquals(Number(loan.balance), 600);
+ const locked = await callCoreFn(db, "core.fn_loan_update", { id, delivered: 50 });
+ const after = await callCoreFn(db, "core.fn_loan_statement", { id });
+ assertEquals(Number((after.data as { loan: { delivered: number } }).loan.delivered), 800);
+ assertEquals(locked.ok, true);
+ });
+});
+
+Deno.test("finiquito: el neto es líneas menos descuentos y préstamo", async () => {
+ await withTestDb(async (db) => {
+ const fx = await seed(db);
+ await callCoreFn(db, "core.fn_loan_create", {
+ tenant_id: TENANT_ID, worker_id: fx.workerId, delivered: 300, commission_enabled: false,
+ });
+ const saved = await callCoreFn(db, "core.fn_settlement_save", {
+ tenant_id: TENANT_ID, worker_id: fx.workerId, company_id: fx.companyId, project_id: fx.projectId,
+ kind: "finiquito", termination_date: "2026-09-24",
+ lines: [{ concept: "Sueldo", amount: 1000, note: "" }],
+ });
+ assertEquals(saved.ok, true, JSON.stringify(saved));
+ const id = (saved.data as { id: number }).id;
+ const got = await callCoreFn(db, "core.fn_settlement_get", { id });
+ const net = Number((got.data as { settlement: { net: number } }).settlement.net);
+ assertEquals(net, 700);
+ });
+});
+
+Deno.test("dispersión: definitiva no se corrige", async () => {
+ await withTestDb(async (db) => {
+ const fx = await seed(db);
+ const week = await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-21" });
+ const weekId = (week.data as { week_id: number }).week_id;
+ const opened = await callCoreFn(db, "core.fn_dispersion_get", { tenant_id: TENANT_ID, week_id: weekId });
+ const id = (opened.data as { dispersion: { id: number } }).dispersion.id;
+ await callCoreFn(db, "core.fn_dispersion_cut", { id });
+ const fin = await callCoreFn(db, "core.fn_dispersion_finalize", { id });
+ assertEquals(fin.ok, true);
+ const line = await db.prepare(`SELECT id FROM payroll_dispersion_lines WHERE dispersion_id = ? LIMIT 1`).get(id) as { id: number } | undefined;
+ if (line) {
+ const upd = await callCoreFn(db, "core.fn_dispersion_line_update", { line_id: line.id, amount: 1 });
+ assertEquals(upd.ok, false);
+ }
+ assert(fx.workerId > 0);
+ });
+});
+
+Deno.test("historial: alta, baja y reingreso", async () => {
+ await withTestDb(async (db) => {
+ const fx = await seed(db);
+ const alta = await callCoreFn(db, "core.fn_worker_movement_list", { worker_id: fx.workerId });
+ const kinds = (alta.data as { movements: { kind: string }[] }).movements.map((m) => m.kind);
+ assert(kinds.includes("alta"));
+ await callCoreFn(db, "core.fn_worker_set_pipeline", { id: fx.workerId, tenant_id: TENANT_ID, pipeline_status: "baja" });
+ await callCoreFn(db, "core.fn_worker_set_pipeline", { id: fx.workerId, tenant_id: TENANT_ID, pipeline_status: "activo" });
+ const later = await callCoreFn(db, "core.fn_worker_movement_list", { worker_id: fx.workerId });
+ const all = (later.data as { movements: { kind: string }[] }).movements.map((m) => m.kind);
+ assert(all.includes("baja"));
+ assert(all.includes("reingreso"));
+ });
+});
+
+Deno.test("reporte mensual e IMSS con jornal distinto", async () => {
+ await withTestDb(async (db) => {
+ const fx = await seed(db);
+ await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-07" });
+ await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-14" });
+ const report = await callCoreFn(db, "core.fn_payroll_report", {
+ tenant_id: TENANT_ID, date_from: "2026-09-01", date_to: "2026-09-30", worker_id: fx.workerId,
+ });
+ assertEquals(report.ok, true, JSON.stringify(report));
+ await callCoreFn(db, "core.fn_worker_imss_wage_set", { id: fx.workerId, imss_daily_wage: 250 });
+ const quote = await callCoreFn(db, "core.fn_imss_quote", {
+ tenant_id: TENANT_ID, worker_id: fx.workerId, date_from: "2026-09-01", date_to: "2026-09-30",
+ });
+ const row = (quote.data as { quotes: { daily_wage: number; imss_daily_wage: number; used_real_wage: boolean }[] }).quotes[0];
+ assertEquals(Number(row.daily_wage), 400);
+ assertEquals(Number(row.imss_daily_wage), 250);
+ assertEquals(row.used_real_wage, false);
+ });
+});
diff --git a/api/storage.ts b/api/storage.ts
index 6701f41..e095e7a 100644
--- a/api/storage.ts
+++ b/api/storage.ts
@@ -194,6 +194,9 @@ export function loanReceiptPdfKey(loanId: number, weekId: number): string {
export function expenseDocKey(expenseId: number, storageName: string): string {
return `expenses/${expenseId}/${storageName}`;
}
+export function dispersionDocKey(dispersionId: number, storageName: string): string {
+ return `dispersions/${dispersionId}/${storageName}`;
+}
export function projectLogoKey(projectId: number, side: "left" | "right", ext: string): string {
return `logos/${projectId}-${side}.${ext}`;
}
diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml
index 216d163..912cb77 100644
--- a/db/core/changelog-master.xml
+++ b/db/core/changelog-master.xml
@@ -51,5 +51,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/db/core/changesets/042-suppliers-fiscal.sql b/db/core/changesets/042-suppliers-fiscal.sql
new file mode 100644
index 0000000..2ef9607
--- /dev/null
+++ b/db/core/changesets/042-suppliers-fiscal.sql
@@ -0,0 +1,160 @@
+--liquibase formatted sql
+-- PANELS · core · datos fiscales de proveedores y filtro de listado
+
+--changeset panel:core-042a-supplier-fiscal-cols endDelimiter:; splitStatements:true
+ALTER TABLE core.suppliers
+ ADD COLUMN IF NOT EXISTS regimen_fiscal TEXT NOT NULL DEFAULT '';
+
+--changeset panel:core-042b-fn-supplier-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_supplier_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._cost_tenant_id(payload);
+ v_status text := nullif(btrim(payload->>'status'), '');
+ v_q text := lower(coalesce(nullif(btrim(payload->>'q'), ''), ''));
+ v_rows jsonb;
+BEGIN
+ SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY lower(s.name), s.id), '[]'::jsonb)
+ INTO v_rows
+ FROM suppliers s
+ WHERE s.tenant_id = v_tid
+ AND (v_status IS NULL OR s.status = v_status)
+ AND (
+ v_q = ''
+ OR lower(s.name) LIKE '%' || v_q || '%'
+ OR lower(s.legal_name) LIKE '%' || v_q || '%'
+ OR lower(s.rfc) LIKE '%' || v_q || '%'
+ );
+ RETURN core.rpc_ok(
+ jsonb_build_object('suppliers', v_rows, 'count', jsonb_array_length(v_rows)),
+ format('Catálogo de proveedores: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid),
+ jsonb_build_object('fn', 'fn_supplier_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_supplier_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-042c-fn-supplier-create splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_supplier_create(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._cost_tenant_id(payload);
+ v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
+ v_legal text := coalesce(nullif(btrim(payload->>'legal_name'), ''), '');
+ v_rfc text := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g'));
+ v_email text := coalesce(nullif(btrim(payload->>'email'), ''), '');
+ v_phone text := coalesce(nullif(btrim(payload->>'phone'), ''), '');
+ v_address text := coalesce(nullif(btrim(payload->>'address'), ''), '');
+ v_city text := coalesce(nullif(btrim(payload->>'city'), ''), '');
+ v_state text := coalesce(nullif(btrim(payload->>'state'), ''), '');
+ v_zip text := coalesce(nullif(btrim(payload->>'postal_code'), ''), '');
+ v_regimen text := coalesce(nullif(btrim(payload->>'regimen_fiscal'), ''), '');
+ v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
+ v_status text := coalesce(nullif(btrim(payload->>'status'), ''), 'activo');
+ v_id bigint;
+ v_row jsonb;
+BEGIN
+ IF v_name = '' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_supplier_create: el nombre del proveedor es obligatorio',
+ jsonb_build_object('fn', 'fn_supplier_create', 'field', 'name'));
+ END IF;
+ IF v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN
+ RETURN core.rpc_err('VALIDATION', format('fn_supplier_create: RFC %s no tiene 12 o 13 caracteres', v_rfc),
+ jsonb_build_object('fn', 'fn_supplier_create', 'field', 'rfc'));
+ END IF;
+ IF v_status NOT IN ('activo', 'inactivo') THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_supplier_create: status debe ser activo o inactivo',
+ jsonb_build_object('fn', 'fn_supplier_create', 'field', 'status'));
+ END IF;
+ INSERT INTO suppliers (
+ tenant_id, name, legal_name, rfc, email, phone, address, city, state, postal_code,
+ regimen_fiscal, notes, status
+ ) VALUES (
+ v_tid, v_name, v_legal, v_rfc, v_email, v_phone, v_address, v_city, v_state, v_zip,
+ v_regimen, v_notes, v_status
+ ) RETURNING id INTO v_id;
+ SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id;
+ RETURN core.rpc_created(
+ jsonb_build_object('supplier', v_row),
+ format('Proveedor %s registrado', v_name),
+ jsonb_build_object('fn', 'fn_supplier_create', 'id', v_id, 'tenant_id', v_tid)
+ );
+EXCEPTION
+ WHEN unique_violation THEN
+ RETURN core.rpc_err('CONFLICT', format('Ya existe un proveedor con RFC %s en este tenant', v_rfc),
+ jsonb_build_object('fn', 'fn_supplier_create', 'rfc', v_rfc));
+ WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_supplier_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-042d-fn-supplier-update splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_supplier_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_rfc text;
+ v_status text;
+ v_row jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_supplier_update: id es obligatorio',
+ jsonb_build_object('fn', 'fn_supplier_update', 'field', 'id'));
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM suppliers WHERE id = v_id AND tenant_id = v_tid) THEN
+ RETURN core.rpc_err('NOT_FOUND', format('fn_supplier_update: proveedor id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id));
+ END IF;
+ v_rfc := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g'));
+ IF payload ? 'rfc' AND v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN
+ RETURN core.rpc_err('VALIDATION', format('fn_supplier_update: RFC %s no tiene 12 o 13 caracteres', v_rfc),
+ jsonb_build_object('fn', 'fn_supplier_update', 'field', 'rfc'));
+ END IF;
+ v_status := nullif(btrim(payload->>'status'), '');
+ IF payload ? 'status' AND v_status NOT IN ('activo', 'inactivo') THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_supplier_update: status debe ser activo o inactivo',
+ jsonb_build_object('fn', 'fn_supplier_update', 'field', 'status'));
+ END IF;
+ UPDATE suppliers SET
+ name = CASE WHEN payload ? 'name' THEN coalesce(nullif(btrim(payload->>'name'), ''), name) ELSE name END,
+ legal_name = CASE WHEN payload ? 'legal_name' THEN coalesce(nullif(btrim(payload->>'legal_name'), ''), '') ELSE legal_name END,
+ rfc = CASE WHEN payload ? 'rfc' THEN v_rfc ELSE rfc END,
+ email = CASE WHEN payload ? 'email' THEN coalesce(nullif(btrim(payload->>'email'), ''), '') ELSE email END,
+ phone = CASE WHEN payload ? 'phone' THEN coalesce(nullif(btrim(payload->>'phone'), ''), '') ELSE phone END,
+ address = CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE address END,
+ city = CASE WHEN payload ? 'city' THEN coalesce(nullif(btrim(payload->>'city'), ''), '') ELSE city END,
+ state = CASE WHEN payload ? 'state' THEN coalesce(nullif(btrim(payload->>'state'), ''), '') ELSE state END,
+ postal_code = CASE WHEN payload ? 'postal_code' THEN coalesce(nullif(btrim(payload->>'postal_code'), ''), '') ELSE postal_code END,
+ regimen_fiscal = CASE WHEN payload ? 'regimen_fiscal' THEN coalesce(nullif(btrim(payload->>'regimen_fiscal'), ''), '') ELSE regimen_fiscal END,
+ notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END,
+ status = CASE WHEN payload ? 'status' THEN v_status ELSE status END,
+ updated_at = now()
+ WHERE id = v_id AND tenant_id = v_tid;
+ SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('supplier', v_row),
+ format('Proveedor id=%s actualizado', v_id),
+ jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id)
+ );
+EXCEPTION
+ WHEN unique_violation THEN
+ RETURN core.rpc_err('CONFLICT', 'Ya existe otro proveedor con ese RFC en este tenant',
+ jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id));
+ WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_supplier_update', SQLSTATE, SQLERRM);
+END;
+$$;
diff --git a/db/core/changesets/043-invoices.sql b/db/core/changesets/043-invoices.sql
new file mode 100644
index 0000000..b09ddf9
--- /dev/null
+++ b/db/core/changesets/043-invoices.sql
@@ -0,0 +1,427 @@
+--liquibase formatted sql
+-- PANELS · core · facturas recibidas y emitidas
+
+--changeset panel:core-043a-expense-source-invoice endDelimiter:; splitStatements:true
+ALTER TABLE core.expense_entries DROP CONSTRAINT IF EXISTS expense_entries_source_check;
+ALTER TABLE core.expense_entries
+ ADD CONSTRAINT expense_entries_source_check
+ CHECK (source IN ('manual', 'payroll', 'destajo', 'invoice', 'settlement'));
+
+--changeset panel:core-043b-invoices endDelimiter:; splitStatements:true
+--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='invoices'
+CREATE TABLE core.invoices (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ tenant_id INTEGER NOT NULL,
+ direction TEXT NOT NULL CHECK (direction IN ('recibida', 'emitida')),
+ supplier_id BIGINT REFERENCES core.suppliers(id) ON DELETE SET NULL,
+ supplier_name TEXT NOT NULL DEFAULT '',
+ supplier_rfc TEXT NOT NULL DEFAULT '',
+ uuid TEXT NOT NULL DEFAULT '',
+ serie TEXT NOT NULL DEFAULT '',
+ folio TEXT NOT NULL DEFAULT '',
+ invoice_date DATE NOT NULL,
+ subtotal NUMERIC(14,2) NOT NULL DEFAULT 0,
+ iva_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ total NUMERIC(14,2) NOT NULL DEFAULT 0,
+ currency TEXT NOT NULL DEFAULT 'MXN',
+ status TEXT NOT NULL DEFAULT 'registrada' CHECK (status IN ('borrador', 'registrada', 'clasificada', 'anulada')),
+ review_note TEXT NOT NULL DEFAULT '',
+ company_id BIGINT REFERENCES core.companies(id),
+ expense_id BIGINT REFERENCES core.expense_entries(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE UNIQUE INDEX idx_core_invoices_uuid
+ ON core.invoices (tenant_id, uuid)
+ WHERE uuid <> '';
+CREATE INDEX idx_core_invoices_tenant_date ON core.invoices (tenant_id, invoice_date DESC);
+ALTER TABLE core.invoices ENABLE ROW LEVEL SECURITY;
+CREATE POLICY tenant_isolation ON core.invoices
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.invoices TO panels_core_app;
+
+CREATE TABLE core.invoice_lines (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ invoice_id BIGINT NOT NULL REFERENCES core.invoices(id) ON DELETE CASCADE,
+ description TEXT NOT NULL DEFAULT '',
+ quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
+ unit TEXT NOT NULL DEFAULT '',
+ amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
+ budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL,
+ project_code TEXT NOT NULL DEFAULT '',
+ item_code TEXT NOT NULL DEFAULT ''
+);
+CREATE INDEX idx_core_invoice_lines_invoice ON core.invoice_lines (invoice_id);
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.invoice_lines TO panels_core_app;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
+
+--changeset panel:core-043c-fn-invoice-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_invoice_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._cost_tenant_id(payload);
+ v_direction text := coalesce(nullif(btrim(payload->>'direction'), ''), 'recibida');
+ v_supplier_id bigint := NULLIF(payload->>'supplier_id', '')::bigint;
+ v_rfc text := upper(coalesce(nullif(btrim(payload->>'rfc'), ''), ''));
+ v_uuid text := upper(coalesce(nullif(btrim(payload->>'uuid'), ''), ''));
+ v_folio text := coalesce(nullif(btrim(payload->>'folio'), ''), '');
+ v_q text := lower(coalesce(nullif(btrim(payload->>'q'), ''), ''));
+ v_min numeric := NULLIF(payload->>'amount_min', '')::numeric;
+ v_max numeric := NULLIF(payload->>'amount_max', '')::numeric;
+ v_from date := NULLIF(payload->>'date_from', '')::date;
+ v_to date := NULLIF(payload->>'date_to', '')::date;
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_classified text := nullif(btrim(payload->>'classified'), '');
+ v_rows jsonb;
+BEGIN
+ SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.invoice_date DESC, x.id DESC), '[]'::jsonb)
+ INTO v_rows
+ FROM (
+ SELECT i.*,
+ EXISTS (
+ SELECT 1 FROM invoice_lines l
+ WHERE l.invoice_id = i.id AND l.budget_item_id IS NOT NULL
+ ) AS has_concept
+ FROM invoices i
+ WHERE i.tenant_id = v_tid
+ AND i.direction = v_direction
+ AND i.status <> 'anulada'
+ AND (v_supplier_id IS NULL OR i.supplier_id = v_supplier_id)
+ AND (v_rfc = '' OR i.supplier_rfc = v_rfc)
+ AND (v_uuid = '' OR i.uuid = v_uuid)
+ AND (v_folio = '' OR i.folio ILIKE '%' || v_folio || '%')
+ AND (v_min IS NULL OR i.total >= v_min)
+ AND (v_max IS NULL OR i.total <= v_max)
+ AND (v_from IS NULL OR i.invoice_date >= v_from)
+ AND (v_to IS NULL OR i.invoice_date <= v_to)
+ AND (
+ v_q = ''
+ OR lower(i.supplier_name) LIKE '%' || v_q || '%'
+ OR lower(i.supplier_rfc) LIKE '%' || v_q || '%'
+ OR lower(i.uuid) LIKE '%' || v_q || '%'
+ OR lower(i.folio) LIKE '%' || v_q || '%'
+ )
+ AND (
+ v_project_id IS NULL
+ OR EXISTS (SELECT 1 FROM invoice_lines l WHERE l.invoice_id = i.id AND l.project_id = v_project_id)
+ )
+ AND (
+ v_classified IS NULL
+ OR (v_classified = 'si' AND i.status = 'clasificada')
+ OR (v_classified = 'no' AND i.status <> 'clasificada')
+ )
+ ) x;
+ RETURN core.rpc_ok(
+ jsonb_build_object('invoices', v_rows, 'count', jsonb_array_length(v_rows)),
+ format('Facturas %s: %s registro(s)', v_direction, jsonb_array_length(v_rows)),
+ jsonb_build_object('fn', 'fn_invoice_list', 'tenant_id', v_tid)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_invoice_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-043d-fn-invoice-get splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_invoice_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_inv jsonb;
+ v_lines jsonb;
+BEGIN
+ SELECT to_jsonb(i) INTO v_inv FROM invoices i WHERE i.id = v_id;
+ IF v_inv IS NULL THEN
+ RETURN core.rpc_err('NOT_FOUND', format('fn_invoice_get: factura id=%s no encontrada', v_id),
+ jsonb_build_object('fn', 'fn_invoice_get', 'id', v_id));
+ END IF;
+ SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.id), '[]'::jsonb)
+ INTO v_lines FROM invoice_lines l WHERE l.invoice_id = v_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('invoice', v_inv, 'lines', v_lines),
+ format('Factura id=%s cargada', v_id),
+ jsonb_build_object('fn', 'fn_invoice_get', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_invoice_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-043e-fn-invoice-import splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_invoice_import(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_row jsonb;
+ v_key text;
+ v_groups jsonb := '{}'::jsonb;
+ v_group jsonb;
+ v_invoice_id bigint;
+ v_created integer := 0;
+ v_review integer := 0;
+ v_skipped integer := 0;
+ v_direction text;
+ v_rfc text;
+ v_uuid text;
+ v_serie text;
+ v_folio text;
+ v_date date;
+ v_name text;
+ v_supplier_id bigint;
+ v_supplier_name text;
+ v_status text;
+ v_note text;
+ v_subtotal numeric;
+ v_iva numeric;
+ v_total numeric;
+ v_project_id bigint;
+ v_item_id bigint;
+ v_line jsonb;
+ v_all_classified boolean;
+ v_expense_id bigint;
+BEGIN
+ IF v_company_id IS NULL OR NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_invoice_import: company_id es obligatorio',
+ jsonb_build_object('fn', 'fn_invoice_import', 'field', 'company_id'));
+ END IF;
+ FOR v_row IN SELECT value FROM jsonb_array_elements(COALESCE(payload->'rows', '[]'::jsonb))
+ LOOP
+ v_direction := lower(coalesce(nullif(btrim(v_row->>'direction'), ''), 'recibida'));
+ IF v_direction IN ('emitida', 'emitidas', 'ingreso') THEN
+ v_direction := 'emitida';
+ ELSE
+ v_direction := 'recibida';
+ END IF;
+ v_rfc := upper(regexp_replace(coalesce(v_row->>'rfc', ''), '\s+', '', 'g'));
+ v_uuid := upper(regexp_replace(coalesce(v_row->>'uuid', ''), '\s+', '', 'g'));
+ v_serie := coalesce(btrim(v_row->>'serie'), '');
+ v_folio := coalesce(btrim(v_row->>'folio'), '');
+ v_date := NULLIF(btrim(v_row->>'invoice_date'), '')::date;
+ IF v_date IS NULL THEN
+ v_skipped := v_skipped + 1;
+ CONTINUE;
+ END IF;
+ v_key := v_direction || '|' || CASE
+ WHEN v_uuid <> '' THEN 'u:' || v_uuid
+ ELSE 'k:' || v_rfc || '|' || v_folio || '|' || v_date::text
+ END;
+ v_groups := jsonb_set(
+ v_groups,
+ ARRAY[v_key],
+ COALESCE(v_groups->v_key, '[]'::jsonb) || jsonb_build_array(v_row || jsonb_build_object('direction', v_direction, 'rfc', v_rfc, 'uuid', v_uuid)),
+ true
+ );
+ END LOOP;
+
+ FOR v_key, v_group IN SELECT key, value FROM jsonb_each(v_groups)
+ LOOP
+ v_row := v_group->0;
+ v_direction := v_row->>'direction';
+ v_rfc := v_row->>'rfc';
+ v_uuid := v_row->>'uuid';
+ v_serie := coalesce(v_row->>'serie', '');
+ v_folio := coalesce(v_row->>'folio', '');
+ v_date := (v_row->>'invoice_date')::date;
+ v_name := coalesce(nullif(btrim(v_row->>'supplier_name'), ''), '');
+ v_supplier_id := NULL;
+ v_supplier_name := v_name;
+ v_note := '';
+ v_status := 'registrada';
+ IF v_uuid <> '' AND EXISTS (
+ SELECT 1 FROM invoices WHERE tenant_id = v_tid AND uuid = v_uuid
+ ) THEN
+ v_skipped := v_skipped + 1;
+ CONTINUE;
+ END IF;
+ IF v_rfc <> '' THEN
+ SELECT id, name INTO v_supplier_id, v_supplier_name
+ FROM suppliers
+ WHERE tenant_id = v_tid AND rfc = v_rfc AND status = 'activo'
+ LIMIT 1;
+ IF v_supplier_id IS NULL THEN
+ v_status := 'borrador';
+ v_note := 'RFC sin proveedor en el catálogo';
+ v_review := v_review + 1;
+ IF v_supplier_name = '' THEN
+ v_supplier_name := v_rfc;
+ END IF;
+ END IF;
+ END IF;
+ v_subtotal := 0;
+ v_iva := 0;
+ v_total := 0;
+ FOR v_line IN SELECT value FROM jsonb_array_elements(v_group)
+ LOOP
+ v_subtotal := v_subtotal + COALESCE((v_line->>'amount')::numeric, 0);
+ v_iva := v_iva + COALESCE((v_line->>'iva')::numeric, 0);
+ v_total := v_total + COALESCE(NULLIF(v_line->>'total', '')::numeric, COALESCE((v_line->>'amount')::numeric, 0) + COALESCE((v_line->>'iva')::numeric, 0));
+ END LOOP;
+ INSERT INTO invoices (
+ tenant_id, direction, supplier_id, supplier_name, supplier_rfc, uuid, serie, folio,
+ invoice_date, subtotal, iva_amount, total, status, review_note, company_id
+ ) VALUES (
+ v_tid, v_direction, v_supplier_id, coalesce(v_supplier_name, ''), v_rfc, v_uuid, v_serie, v_folio,
+ v_date, core.round_money(v_subtotal), core.round_money(v_iva), core.round_money(v_total),
+ v_status, v_note, v_company_id
+ ) RETURNING id INTO v_invoice_id;
+ v_all_classified := v_direction = 'recibida' AND v_status = 'registrada';
+ FOR v_line IN SELECT value FROM jsonb_array_elements(v_group)
+ LOOP
+ v_project_id := NULL;
+ v_item_id := NULL;
+ IF coalesce(btrim(v_line->>'project_code'), '') <> '' THEN
+ SELECT id INTO v_project_id FROM projects
+ WHERE tenant_id = v_tid AND lower(code) = lower(btrim(v_line->>'project_code'))
+ LIMIT 1;
+ END IF;
+ IF v_project_id IS NOT NULL AND coalesce(btrim(v_line->>'item_code'), '') <> '' THEN
+ SELECT id INTO v_item_id FROM budget_items
+ WHERE project_id = v_project_id AND lower(code) = lower(btrim(v_line->>'item_code'))
+ LIMIT 1;
+ END IF;
+ IF v_project_id IS NULL OR v_item_id IS NULL THEN
+ v_all_classified := false;
+ END IF;
+ INSERT INTO invoice_lines (
+ invoice_id, description, quantity, unit, amount, project_id, budget_item_id, project_code, item_code
+ ) VALUES (
+ v_invoice_id,
+ coalesce(nullif(btrim(v_line->>'description'), ''), 'Partida'),
+ COALESCE((v_line->>'quantity')::numeric, 0),
+ coalesce(btrim(v_line->>'unit'), ''),
+ COALESCE((v_line->>'amount')::numeric, 0),
+ v_project_id, v_item_id,
+ coalesce(btrim(v_line->>'project_code'), ''),
+ coalesce(btrim(v_line->>'item_code'), '')
+ );
+ END LOOP;
+ IF v_all_classified AND v_status = 'registrada' THEN
+ v_item_id := (SELECT budget_item_id FROM invoice_lines WHERE invoice_id = v_invoice_id AND budget_item_id IS NOT NULL ORDER BY id LIMIT 1);
+ v_project_id := (SELECT project_id FROM invoice_lines WHERE invoice_id = v_invoice_id AND project_id IS NOT NULL ORDER BY id LIMIT 1);
+ INSERT INTO expense_entries (
+ tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
+ supplier_id, supplier_name, supplier_rfc, budget_item_id,
+ subtotal, iva_amount, total, status, notes
+ ) VALUES (
+ v_tid, v_company_id, v_project_id, v_date, 'invoice', v_invoice_id,
+ format('Factura %s %s', NULLIF(v_serie, ''), COALESCE(NULLIF(v_folio, ''), v_uuid)),
+ v_supplier_id, coalesce(v_supplier_name, ''), v_rfc, v_item_id,
+ core.round_money(v_subtotal), core.round_money(v_iva), core.round_money(v_total),
+ 'confirmed', 'Generado al importar factura clasificada'
+ ) RETURNING id INTO v_expense_id;
+ UPDATE invoices SET status = 'clasificada', expense_id = v_expense_id WHERE id = v_invoice_id;
+ END IF;
+ v_created := v_created + 1;
+ END LOOP;
+ RETURN core.rpc_ok(
+ jsonb_build_object('created', v_created, 'review', v_review, 'skipped', v_skipped),
+ format('Importación de facturas: %s creadas, %s en revisión, %s omitidas', v_created, v_review, v_skipped),
+ jsonb_build_object('fn', 'fn_invoice_import', 'created', v_created)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_invoice_import', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-043f-fn-invoice-classify splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_invoice_classify(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._cost_tenant_id(payload);
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
+ v_inv invoices%ROWTYPE;
+ v_expense_id bigint;
+ v_subtotal numeric;
+ v_iva numeric;
+BEGIN
+ SELECT * INTO v_inv FROM invoices WHERE id = v_id AND tenant_id = v_tid;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', format('fn_invoice_classify: factura id=%s no encontrada', v_id),
+ jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
+ END IF;
+ IF v_inv.direction <> 'recibida' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_invoice_classify: solo se clasifican facturas recibidas',
+ jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
+ END IF;
+ IF v_inv.status = 'anulada' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_invoice_classify: la factura está anulada',
+ jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
+ END IF;
+ IF v_line_id IS NOT NULL THEN
+ UPDATE invoice_lines
+ SET project_id = COALESCE(v_project_id, project_id),
+ budget_item_id = COALESCE(v_item_id, budget_item_id)
+ WHERE id = v_line_id AND invoice_id = v_id;
+ ELSE
+ UPDATE invoice_lines
+ SET project_id = COALESCE(v_project_id, project_id),
+ budget_item_id = COALESCE(v_item_id, budget_item_id)
+ WHERE invoice_id = v_id;
+ END IF;
+ IF EXISTS (
+ SELECT 1 FROM invoice_lines WHERE invoice_id = v_id AND (project_id IS NULL OR budget_item_id IS NULL)
+ ) OR v_inv.supplier_id IS NULL THEN
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id, 'status', v_inv.status),
+ 'Factura actualizada; falta proveedor, obra o concepto para generar el gasto',
+ jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id)
+ );
+ END IF;
+ SELECT project_id, budget_item_id INTO v_project_id, v_item_id
+ FROM invoice_lines WHERE invoice_id = v_id ORDER BY id LIMIT 1;
+ v_subtotal := v_inv.subtotal;
+ v_iva := v_inv.iva_amount;
+ IF v_inv.expense_id IS NULL THEN
+ INSERT INTO expense_entries (
+ tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
+ supplier_id, supplier_name, supplier_rfc, budget_item_id,
+ subtotal, iva_amount, total, status, notes
+ ) VALUES (
+ v_tid, v_inv.company_id, v_project_id, v_inv.invoice_date, 'invoice', v_id,
+ format('Factura %s', COALESCE(NULLIF(v_inv.folio, ''), NULLIF(v_inv.uuid, ''), v_id::text)),
+ v_inv.supplier_id, v_inv.supplier_name, v_inv.supplier_rfc, v_item_id,
+ v_subtotal, v_iva, v_inv.total, 'confirmed', 'Clasificación de factura recibida'
+ ) RETURNING id INTO v_expense_id;
+ UPDATE invoices SET status = 'clasificada', expense_id = v_expense_id WHERE id = v_id;
+ ELSE
+ UPDATE expense_entries
+ SET project_id = v_project_id, budget_item_id = v_item_id
+ WHERE id = v_inv.expense_id AND status <> 'void';
+ v_expense_id := v_inv.expense_id;
+ UPDATE invoices SET status = 'clasificada' WHERE id = v_id;
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id, 'status', 'clasificada', 'expense_id', v_expense_id),
+ format('Factura id=%s clasificada; gasto id=%s', v_id, v_expense_id),
+ jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id, 'expense_id', v_expense_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_invoice_classify', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-043g-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_invoice_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_invoice_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_invoice_import(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_invoice_classify(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/044-payroll-adjustments.sql b/db/core/changesets/044-payroll-adjustments.sql
new file mode 100644
index 0000000..4db5761
--- /dev/null
+++ b/db/core/changesets/044-payroll-adjustments.sql
@@ -0,0 +1,715 @@
+--liquibase formatted sql
+-- PANELS · core · bono, descuentos, horas y días festivos
+
+--changeset panel:core-044a-payroll-adjustments endDelimiter:; splitStatements:true
+CREATE TABLE IF NOT EXISTS core.holidays (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ tenant_id INTEGER NOT NULL,
+ holiday_date DATE NOT NULL,
+ name TEXT NOT NULL,
+ UNIQUE (tenant_id, holiday_date)
+);
+ALTER TABLE core.holidays ENABLE ROW LEVEL SECURITY;
+DROP POLICY IF EXISTS tenant_isolation ON core.holidays;
+CREATE POLICY tenant_isolation ON core.holidays
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.holidays TO panels_core_app;
+
+CREATE TABLE IF NOT EXISTS core.payroll_adjustments (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ tenant_id INTEGER NOT NULL,
+ week_id BIGINT NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
+ worker_id BIGINT NOT NULL REFERENCES core.workers(id),
+ project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
+ kind TEXT NOT NULL CHECK (kind IN ('descuento', 'bono', 'horas')),
+ concept TEXT NOT NULL,
+ amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ hours NUMERIC(8,2) NOT NULL DEFAULT 0,
+ note TEXT NOT NULL DEFAULT '',
+ adjustment_date DATE NOT NULL DEFAULT CURRENT_DATE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_core_payroll_adjustments_week ON core.payroll_adjustments (week_id, worker_id);
+ALTER TABLE core.payroll_adjustments ENABLE ROW LEVEL SECURITY;
+DROP POLICY IF EXISTS tenant_isolation ON core.payroll_adjustments;
+CREATE POLICY tenant_isolation ON core.payroll_adjustments
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_adjustments TO panels_core_app;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
+
+--changeset panel:core-044b-fn-apply-adjustments splitStatements:false
+CREATE OR REPLACE FUNCTION core._payroll_apply_adjustments(p_week_id bigint)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_adj record;
+ v_line_id bigint;
+ v_wage numeric;
+ v_extra numeric;
+BEGIN
+ FOR v_adj IN
+ SELECT * FROM payroll_adjustments WHERE week_id = p_week_id ORDER BY id
+ LOOP
+ SELECT l.id INTO v_line_id
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id
+ AND l.worker_id = v_adj.worker_id
+ AND (v_adj.project_id IS NULL OR s.project_id = v_adj.project_id OR l.project_id = v_adj.project_id)
+ ORDER BY CASE WHEN s.project_id IS NOT DISTINCT FROM v_adj.project_id THEN 0 ELSE 1 END, l.gross DESC, l.id
+ LIMIT 1;
+ IF v_line_id IS NULL THEN
+ CONTINUE;
+ END IF;
+ IF v_adj.kind = 'bono' THEN
+ UPDATE payroll_week_lines
+ SET gross = core.round_money(gross + v_adj.amount),
+ payable_net = core.round_money(payable_net + v_adj.amount)
+ WHERE id = v_line_id;
+ ELSIF v_adj.kind = 'descuento' THEN
+ UPDATE payroll_week_lines
+ SET discounts = core.round_money(discounts + v_adj.amount),
+ payable_net = core.round_money(payable_net - v_adj.amount)
+ WHERE id = v_line_id;
+ ELSIF v_adj.kind = 'horas' THEN
+ SELECT w.daily_wage INTO v_wage FROM workers w WHERE w.id = v_adj.worker_id;
+ v_extra := core.round_money(v_adj.hours * COALESCE(v_wage, 0) / 8);
+ UPDATE payroll_week_lines
+ SET gross = core.round_money(gross + v_extra),
+ payable_net = core.round_money(payable_net + v_extra)
+ WHERE id = v_line_id;
+ END IF;
+ END LOOP;
+END;
+$$;
+
+--changeset panel:core-044c-recalc-week splitStatements:false
+CREATE OR REPLACE FUNCTION core._payroll_recalc_week(p_week_id bigint)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_week record;
+ v_sheet record;
+ v_worker record;
+ v_cut record;
+ v_line record;
+ v_loan record;
+ v_line_id bigint;
+ v_att_n integer;
+ v_holiday_n integer;
+ v_wage numeric;
+ v_gross numeric;
+ v_payable_qty numeric;
+ v_required_gross numeric;
+ v_keep bigint[];
+ v_stale record;
+ v_tot numeric;
+ v_worker_id bigint;
+ v_lines record;
+ v_gross_sum numeric;
+ v_remaining numeric;
+ v_placed boolean;
+ v_n integer;
+ v_is_last boolean;
+ v_want numeric;
+ v_take numeric;
+ v_label text;
+ v_host_id bigint;
+ v_host_gross numeric;
+ v_host_discounts numeric;
+BEGIN
+ SELECT * INTO v_week FROM payroll_weeks WHERE id = p_week_id;
+ IF NOT FOUND OR v_week.status = 'paid' THEN
+ RETURN;
+ END IF;
+
+ UPDATE payroll_week_lines
+ SET loan_id = NULL, loan_discount = 0, loan_label = NULL, discounts = 0
+ WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = p_week_id);
+
+ FOR v_sheet IN SELECT * FROM payroll_sheets WHERE week_id = p_week_id LOOP
+ IF v_sheet.kind = 'obra' AND v_sheet.project_id IS NOT NULL THEN
+ FOR v_worker IN
+ SELECT w.id, w.daily_wage
+ FROM workers w
+ WHERE w.status = 'activo'
+ AND w.pipeline_status != 'baja'
+ AND (
+ EXISTS (
+ SELECT 1 FROM assignments a
+ WHERE a.worker_id = w.id AND a.project_id = v_sheet.project_id
+ AND a.active = true AND w.work_type = 'N'
+ )
+ OR EXISTS (
+ SELECT 1 FROM attendance att
+ WHERE att.worker_id = w.id AND att.project_id = v_sheet.project_id
+ AND att.work_date BETWEEN v_week.week_start AND v_week.week_end
+ )
+ OR EXISTS (
+ SELECT 1 FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE l.worker_id = w.id AND s.project_id = v_sheet.project_id
+ AND s.kind = 'obra' AND s.week_id = p_week_id
+ )
+ )
+ ORDER BY w.last_name_p, w.first_name
+ LOOP
+ v_line_id := core._payroll_sheet_line(v_sheet.id, v_worker.id, NULL);
+ SELECT COUNT(*)::integer INTO v_att_n
+ FROM attendance
+ WHERE worker_id = v_worker.id
+ AND project_id = v_sheet.project_id
+ AND work_date BETWEEN v_week.week_start AND v_week.week_end
+ AND present = true;
+ SELECT COUNT(*)::integer INTO v_holiday_n
+ FROM holidays h
+ WHERE h.tenant_id = v_week.tenant_id
+ AND h.holiday_date BETWEEN v_week.week_start AND v_week.week_end
+ AND NOT EXISTS (
+ SELECT 1 FROM attendance att
+ WHERE att.worker_id = v_worker.id
+ AND att.project_id = v_sheet.project_id
+ AND att.work_date = h.holiday_date
+ );
+ SELECT daily_wage INTO v_wage FROM payroll_week_lines WHERE id = v_line_id;
+ IF v_week.status = 'assembled' AND COALESCE(v_wage, 0) > 0 THEN
+ NULL;
+ ELSE
+ v_wage := v_worker.daily_wage;
+ END IF;
+ v_gross := core.round_money((v_att_n + v_holiday_n) * v_wage);
+ UPDATE payroll_week_lines
+ SET days = v_att_n + v_holiday_n,
+ daily_wage = v_wage,
+ amount = v_gross,
+ gross = v_gross,
+ discounts = 0,
+ payable_net = v_gross
+ WHERE id = v_line_id;
+ END LOOP;
+ END IF;
+
+ IF v_sheet.kind = 'destajo' AND v_sheet.project_id IS NOT NULL THEN
+ SELECT id INTO v_line_id FROM destajo_periods WHERE week_id = p_week_id;
+ IF v_line_id IS NULL THEN
+ CONTINUE;
+ END IF;
+ v_keep := ARRAY[]::bigint[];
+ FOR v_cut IN
+ SELECT c.id, c.qty_planned, c.qty_actual, c.qty_extra,
+ j.worker_id, j.unit_price, j.unit_code, j.concepto
+ FROM destajo_cut_lines c
+ JOIN destajo_jobs j ON j.id = c.job_id
+ WHERE c.period_id = v_line_id
+ AND j.project_id = v_sheet.project_id
+ LOOP
+ v_line_id := core._payroll_sheet_line(v_sheet.id, v_cut.worker_id, v_cut.id);
+ v_keep := array_append(v_keep, v_line_id);
+ IF v_week.status = 'draft'
+ AND v_cut.qty_actual = 0 AND v_cut.qty_extra = 0 THEN
+ v_payable_qty := core.round_money(v_cut.qty_planned);
+ ELSE
+ v_payable_qty := core.round_money(v_cut.qty_actual + v_cut.qty_extra);
+ END IF;
+ v_gross := core.round_money(v_payable_qty * v_cut.unit_price);
+ v_required_gross := core.round_money(v_cut.qty_planned * v_cut.unit_price);
+ UPDATE payroll_week_lines
+ SET destajo_cut_line_id = v_cut.id,
+ qty_planned = v_cut.qty_planned,
+ qty_actual = v_cut.qty_actual,
+ qty_extra = v_cut.qty_extra,
+ unit_price = v_cut.unit_price,
+ unit_code = v_cut.unit_code,
+ concepto = v_cut.concepto,
+ amount = v_required_gross,
+ gross = v_gross,
+ payable_net = v_gross
+ WHERE id = v_line_id;
+ END LOOP;
+ FOR v_stale IN
+ SELECT id FROM payroll_week_lines WHERE sheet_id = v_sheet.id
+ LOOP
+ IF NOT (v_stale.id = ANY (v_keep)) THEN
+ DELETE FROM payroll_week_lines WHERE id = v_stale.id;
+ END IF;
+ END LOOP;
+ END IF;
+
+ IF v_sheet.kind = 'admin' THEN
+ FOR v_line IN SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id = v_sheet.id LOOP
+ v_gross := core.round_money(v_line.amount);
+ UPDATE payroll_week_lines
+ SET gross = v_gross,
+ payable_net = core.round_money(v_gross - v_line.discounts)
+ WHERE id = v_line.id;
+ END LOOP;
+ END IF;
+ END LOOP;
+
+ PERFORM core._payroll_apply_adjustments(p_week_id);
+
+ FOR v_worker_id IN
+ SELECT DISTINCT l.worker_id
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id
+ LOOP
+ SELECT core.round_money(COALESCE(SUM(l.gross - l.discounts), 0))
+ INTO v_gross_sum
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id
+ AND l.worker_id = v_worker_id;
+
+ v_remaining := GREATEST(0, v_gross_sum);
+ v_placed := false;
+
+ FOR v_loan IN
+ SELECT *
+ FROM loans
+ WHERE worker_id = v_worker_id
+ AND balance > 0
+ AND first_due IS NOT NULL
+ AND first_due <= v_week.week_end
+ ORDER BY first_due, id
+ LOOP
+ EXIT WHEN v_remaining <= 0;
+ SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id;
+ v_is_last := v_n >= GREATEST(COALESCE(v_loan.installments_n, 1), 1);
+ IF v_is_last THEN
+ v_want := v_loan.balance;
+ ELSE
+ v_want := LEAST(COALESCE(NULLIF(v_loan.weekly_payment, 0), v_loan.balance), v_loan.balance);
+ END IF;
+ v_take := core.round_money(LEAST(v_want, v_remaining));
+ IF v_take <= 0 THEN
+ CONTINUE;
+ END IF;
+ v_remaining := core.round_money(v_remaining - v_take);
+ IF NOT v_placed THEN
+ SELECT l.id, l.gross, l.discounts
+ INTO v_host_id, v_host_gross, v_host_discounts
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id
+ AND l.worker_id = v_worker_id
+ ORDER BY l.gross DESC, l.id
+ LIMIT 1;
+ v_label := core._payroll_loan_condition_label(
+ v_loan.delivered, v_loan.commission_pct, v_take, v_n, v_loan.installments_n
+ );
+ UPDATE payroll_week_lines
+ SET loan_id = v_loan.id,
+ loan_discount = v_take,
+ loan_label = v_label,
+ payable_net = core.round_money(v_host_gross - v_host_discounts - v_take)
+ WHERE id = v_host_id;
+ v_placed := true;
+ END IF;
+ EXIT;
+ END LOOP;
+
+ IF NOT v_placed THEN
+ UPDATE payroll_week_lines l
+ SET payable_net = core.round_money(l.gross - l.discounts)
+ FROM payroll_sheets s
+ WHERE s.id = l.sheet_id
+ AND s.week_id = p_week_id
+ AND l.worker_id = v_worker_id;
+ ELSE
+ UPDATE payroll_week_lines l
+ SET payable_net = core.round_money(l.gross - l.discounts)
+ FROM payroll_sheets s
+ WHERE s.id = l.sheet_id
+ AND s.week_id = p_week_id
+ AND l.worker_id = v_worker_id
+ AND l.id <> v_host_id;
+ END IF;
+ END LOOP;
+
+ SELECT COALESCE(SUM(l.payable_net), 0) INTO v_tot
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id;
+
+ UPDATE payroll_weeks
+ SET payable_net = core.round_money(v_tot)
+ WHERE id = p_week_id;
+
+ IF v_week.status = 'draft' THEN
+ UPDATE payroll_weeks
+ SET required_net = core.round_money(v_tot)
+ WHERE id = p_week_id;
+ UPDATE payroll_week_lines
+ SET required_net = payable_net
+ WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = p_week_id);
+ END IF;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_ensure_cut_for_open_jobs(p_week_id bigint)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_period record;
+ v_job record;
+BEGIN
+ SELECT id, tenant_id INTO v_period FROM destajo_periods WHERE week_id = p_week_id;
+ IF NOT FOUND THEN
+ RETURN;
+ END IF;
+ FOR v_job IN
+ SELECT id FROM destajo_jobs WHERE tenant_id = v_period.tenant_id AND status = 'open'
+ LOOP
+ INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
+ VALUES (v_period.id, v_job.id, 0, 0, 0)
+ ON CONFLICT (period_id, job_id) DO NOTHING;
+ END LOOP;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_ensure_week(p_tenant_id integer, p_week_start date)
+RETURNS bigint
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_week_start date;
+ v_week_end date;
+ v_week_id bigint;
+ v_bounds record;
+ v_period_id bigint;
+ v_proj record;
+BEGIN
+ PERFORM core._payroll_seed_defaults(p_tenant_id);
+ SELECT wc.week_start, wc.week_end
+ INTO v_week_start, v_week_end
+ FROM core._payroll_week_containing(p_week_start) wc;
+
+ SELECT id INTO v_week_id
+ FROM payroll_weeks
+ WHERE tenant_id = p_tenant_id AND week_start = v_week_start;
+
+ IF v_week_id IS NULL THEN
+ INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
+ VALUES (v_week_start, v_week_end, 'draft', p_tenant_id)
+ RETURNING id INTO v_week_id;
+ END IF;
+
+ SELECT * INTO v_bounds FROM core._payroll_destajo_period_bounds(v_week_start);
+ SELECT id INTO v_period_id
+ FROM destajo_periods
+ WHERE tenant_id = p_tenant_id AND period_end = v_bounds.period_end;
+ IF v_period_id IS NULL THEN
+ INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
+ VALUES (v_bounds.period_start, v_bounds.period_end, v_week_id, p_tenant_id);
+ END IF;
+
+ FOR v_proj IN
+ SELECT id FROM projects
+ WHERE status IN ('activo', 'pausado')
+ AND (p_tenant_id = 0 OR tenant_id = p_tenant_id OR tenant_id IS NULL)
+ ORDER BY name
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1 FROM payroll_sheets
+ WHERE week_id = v_week_id AND kind = 'obra' AND COALESCE(project_id, 0) = v_proj.id
+ ) THEN
+ INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'obra', v_proj.id);
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM payroll_sheets
+ WHERE week_id = v_week_id AND kind = 'destajo' AND COALESCE(project_id, 0) = v_proj.id
+ ) THEN
+ INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'destajo', v_proj.id);
+ END IF;
+ END LOOP;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM payroll_sheets
+ WHERE week_id = v_week_id AND kind = 'admin' AND project_id IS NULL
+ ) THEN
+ INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'admin', NULL);
+ END IF;
+
+ RETURN v_week_id;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_first_due_for_loan(
+ p_delivered numeric,
+ p_small_max numeric,
+ p_plan text,
+ p_grant_iso date,
+ p_week_assembled boolean,
+ p_weekday_mon0 integer
+)
+RETURNS date
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+BEGIN
+ IF p_delivered <= p_small_max AND p_plan = 'single' THEN
+ RETURN core._payroll_next_saturday_after_week(p_grant_iso);
+ END IF;
+ IF p_week_assembled AND p_weekday_mon0 >= 4 THEN
+ RETURN core._payroll_next_saturday_after_week(p_grant_iso);
+ END IF;
+ RETURN core._payroll_this_saturday(p_grant_iso);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_prefill_thu_sat(
+ p_project_id bigint,
+ p_week_start date,
+ p_worker_id bigint
+)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_offset integer;
+ v_day date;
+ v_other record;
+ v_existing record;
+BEGIN
+ FOR v_offset IN SELECT unnest(ARRAY[3, 4, 5]) LOOP
+ v_day := core._payroll_add_days(p_week_start, v_offset);
+ SELECT a.project_id, p.name AS project_name
+ INTO v_other
+ FROM attendance a
+ JOIN projects p ON p.id = a.project_id
+ WHERE a.worker_id = p_worker_id
+ AND a.work_date = v_day
+ AND a.present = true
+ AND a.project_id <> p_project_id
+ LIMIT 1;
+ IF FOUND THEN
+ CONTINUE;
+ END IF;
+ SELECT id, present INTO v_existing
+ FROM attendance
+ WHERE worker_id = p_worker_id
+ AND project_id = p_project_id
+ AND work_date = v_day;
+ IF FOUND THEN
+ CONTINUE;
+ END IF;
+ INSERT INTO attendance (worker_id, project_id, work_date, present)
+ VALUES (p_worker_id, p_project_id, v_day, true);
+ END LOOP;
+END;
+$$;
+
+--changeset panel:core-044d-fn-adjustments splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_save(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_kind text := nullif(btrim(payload->>'kind'), '');
+ v_concept text := coalesce(nullif(btrim(payload->>'concept'), ''), '');
+ v_note text := coalesce(nullif(btrim(payload->>'note'), ''), '');
+ v_amount numeric := COALESCE((payload->>'amount')::numeric, 0);
+ v_hours numeric := COALESCE((payload->>'hours')::numeric, 0);
+ v_date date := COALESCE(NULLIF(payload->>'adjustment_date', '')::date, CURRENT_DATE);
+ v_status text;
+BEGIN
+ IF v_week_id IS NULL OR v_worker_id IS NULL OR v_kind IS NULL OR v_concept = '' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: semana, persona, tipo y concepto son obligatorios',
+ jsonb_build_object('fn', 'fn_payroll_adjustment_save'));
+ END IF;
+ IF v_kind NOT IN ('descuento', 'bono', 'horas') THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: tipo inválido',
+ jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'kind', v_kind));
+ END IF;
+ IF v_kind = 'descuento' AND v_note = '' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: el descuento requiere observación',
+ jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'field', 'note'));
+ END IF;
+ SELECT status INTO v_status FROM payroll_weeks WHERE id = v_week_id AND tenant_id = v_tid;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', format('fn_payroll_adjustment_save: semana id=%s no encontrada', v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'week_id', v_week_id));
+ END IF;
+ IF v_status = 'paid' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: la semana ya está pagada',
+ jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'week_id', v_week_id));
+ END IF;
+ IF v_id IS NULL THEN
+ INSERT INTO payroll_adjustments (
+ tenant_id, week_id, worker_id, project_id, kind, concept, amount, hours, note, adjustment_date
+ ) VALUES (
+ v_tid, v_week_id, v_worker_id, v_project_id, v_kind, v_concept, v_amount, v_hours, v_note, v_date
+ ) RETURNING id INTO v_id;
+ ELSE
+ UPDATE payroll_adjustments SET
+ project_id = v_project_id, kind = v_kind, concept = v_concept,
+ amount = v_amount, hours = v_hours, note = v_note, adjustment_date = v_date
+ WHERE id = v_id AND tenant_id = v_tid AND week_id = v_week_id;
+ END IF;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id),
+ format('Ajuste de nómina %s guardado', v_kind),
+ jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_adjustment_save', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_list(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_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_kind text := nullif(btrim(payload->>'kind'), '');
+ v_rows jsonb;
+BEGIN
+ SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.adjustment_date DESC, x.id DESC), '[]'::jsonb)
+ INTO v_rows
+ FROM (
+ SELECT a.*, w.first_name, w.last_name_p, p.name AS project_name
+ FROM payroll_adjustments a
+ JOIN workers w ON w.id = a.worker_id
+ LEFT JOIN projects p ON p.id = a.project_id
+ WHERE a.tenant_id = v_tid
+ AND (v_week_id IS NULL OR a.week_id = v_week_id)
+ AND (v_worker_id IS NULL OR a.worker_id = v_worker_id)
+ AND (v_project_id IS NULL OR a.project_id = v_project_id)
+ AND (v_kind IS NULL OR a.kind = v_kind)
+ ) x;
+ RETURN core.rpc_ok(
+ jsonb_build_object('adjustments', v_rows, 'count', jsonb_array_length(v_rows)),
+ format('Ajustes de nómina: %s', jsonb_array_length(v_rows)),
+ jsonb_build_object('fn', 'fn_payroll_adjustment_list')
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_adjustment_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_delete(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_week_id bigint;
+ v_status text;
+BEGIN
+ SELECT a.week_id, wk.status INTO v_week_id, v_status
+ FROM payroll_adjustments a
+ JOIN payroll_weeks wk ON wk.id = a.week_id
+ WHERE a.id = v_id AND a.tenant_id = v_tid;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Ajuste no encontrado',
+ jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'id', v_id));
+ END IF;
+ IF v_status = 'paid' THEN
+ RETURN core.rpc_err('VALIDATION', 'La semana ya está pagada',
+ jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'week_id', v_week_id));
+ END IF;
+ DELETE FROM payroll_adjustments WHERE id = v_id;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_ok(jsonb_build_object('id', v_id), 'Ajuste eliminado',
+ jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'id', v_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_adjustment_delete', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_holiday_save(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_date date := NULLIF(payload->>'holiday_date', '')::date;
+ v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
+ v_id bigint;
+ v_week record;
+BEGIN
+ IF v_date IS NULL OR v_name = '' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_holiday_save: fecha y nombre son obligatorios',
+ jsonb_build_object('fn', 'fn_holiday_save'));
+ END IF;
+ INSERT INTO holidays (tenant_id, holiday_date, name)
+ VALUES (v_tid, v_date, v_name)
+ ON CONFLICT (tenant_id, holiday_date) DO UPDATE SET name = EXCLUDED.name
+ RETURNING id INTO v_id;
+ FOR v_week IN
+ SELECT id FROM payroll_weeks
+ WHERE tenant_id = v_tid AND status <> 'paid'
+ AND v_date BETWEEN week_start AND week_end
+ LOOP
+ PERFORM core._payroll_recalc_week(v_week.id);
+ END LOOP;
+ RETURN core.rpc_ok(jsonb_build_object('id', v_id), format('Festivo %s registrado', v_name),
+ jsonb_build_object('fn', 'fn_holiday_save', 'id', v_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_holiday_save', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_holiday_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_from date := NULLIF(payload->>'date_from', '')::date;
+ v_to date := NULLIF(payload->>'date_to', '')::date;
+ v_rows jsonb;
+BEGIN
+ SELECT COALESCE(jsonb_agg(to_jsonb(h) ORDER BY h.holiday_date), '[]'::jsonb)
+ INTO v_rows
+ FROM holidays h
+ WHERE h.tenant_id = v_tid
+ AND (v_from IS NULL OR h.holiday_date >= v_from)
+ AND (v_to IS NULL OR h.holiday_date <= v_to);
+ RETURN core.rpc_ok(jsonb_build_object('holidays', v_rows), 'Festivos cargados',
+ jsonb_build_object('fn', 'fn_holiday_list'));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_holiday_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-044e-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core._payroll_apply_adjustments(bigint) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_save(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_delete(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_holiday_save(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_holiday_list(jsonb) TO panels_core_app;
+
diff --git a/db/core/changesets/045-loan-update.sql b/db/core/changesets/045-loan-update.sql
new file mode 100644
index 0000000..365543c
--- /dev/null
+++ b/db/core/changesets/045-loan-update.sql
@@ -0,0 +1,114 @@
+--liquibase formatted sql
+-- PANELS · core · edición de préstamos y estado de cuenta
+
+--changeset panel:core-045a-fn-loan-update splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_loan_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_loan loans%ROWTYPE;
+ v_paid numeric;
+ v_n integer;
+ v_delivered numeric;
+ v_pct numeric;
+ v_comm numeric;
+ v_balance numeric;
+ v_weekly numeric;
+ v_note text;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_loan_update: id es obligatorio',
+ jsonb_build_object('fn', 'fn_loan_update', 'field', 'id'));
+ END IF;
+ SELECT * INTO v_loan FROM loans WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', format('fn_loan_update: préstamo id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_loan_update', 'id', v_id));
+ END IF;
+ SELECT COALESCE(SUM(amount), 0), COUNT(*)::integer INTO v_paid, v_n FROM loan_payments WHERE loan_id = v_id;
+ v_note := CASE WHEN payload ? 'note' THEN nullif(btrim(payload->>'note'), '') ELSE v_loan.note END;
+ IF v_n > 0 THEN
+ v_weekly := COALESCE((payload->>'weekly_payment')::numeric, v_loan.weekly_payment);
+ UPDATE loans SET note = v_note, weekly_payment = core.round_money(v_weekly) WHERE id = v_id;
+ ELSE
+ v_delivered := core.round_money(COALESCE((payload->>'delivered')::numeric, v_loan.delivered));
+ IF v_delivered <= 0 THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_loan_update: el monto entregado debe ser mayor a cero',
+ jsonb_build_object('fn', 'fn_loan_update', 'field', 'delivered'));
+ END IF;
+ v_pct := COALESCE((payload->>'commission_pct')::numeric, v_loan.commission_pct);
+ v_comm := core._payroll_commission_amount(v_delivered, v_pct);
+ v_balance := core.round_money(v_delivered + v_comm);
+ v_weekly := COALESCE((payload->>'weekly_payment')::numeric, core.round_money(v_balance / GREATEST(v_loan.installments_n, 1)));
+ UPDATE loans SET
+ amount = v_delivered,
+ delivered = v_delivered,
+ commission_pct = v_pct,
+ commission_amount = v_comm,
+ balance = v_balance,
+ weekly_payment = core.round_money(v_weekly),
+ note = v_note,
+ plan = COALESCE(NULLIF(btrim(payload->>'plan'), ''), plan),
+ installments_n = COALESCE(NULLIF(payload->>'installments_n', '')::integer, installments_n),
+ first_due = COALESCE(NULLIF(payload->>'first_due', '')::date, first_due)
+ WHERE id = v_id;
+ END IF;
+ UPDATE loans SET balance = core.round_money(delivered + commission_amount - v_paid) WHERE id = v_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id),
+ format('Préstamo id=%s actualizado', v_id),
+ jsonb_build_object('fn', 'fn_loan_update', 'id', v_id, 'payments', v_n)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_loan_update', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-045b-fn-loan-statement splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_loan_statement(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_loan jsonb;
+ v_payments jsonb;
+ v_paid numeric;
+BEGIN
+ SELECT to_jsonb(l) INTO v_loan FROM loans l WHERE l.id = v_id;
+ IF v_loan IS NULL THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Préstamo no encontrado',
+ jsonb_build_object('fn', 'fn_loan_statement', 'id', v_id));
+ END IF;
+ SELECT COALESCE(SUM(amount), 0) INTO v_paid FROM loan_payments WHERE loan_id = v_id;
+ UPDATE loans SET balance = core.round_money(
+ (v_loan->>'delivered')::numeric + (v_loan->>'commission_amount')::numeric - v_paid
+ ) WHERE id = v_id;
+ SELECT to_jsonb(l) INTO v_loan FROM loans l WHERE l.id = v_id;
+ SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.created_at, x.id), '[]'::jsonb)
+ INTO v_payments
+ FROM (
+ SELECT p.id, p.amount, p.label, p.installment_n, p.created_at, wk.week_start, wk.week_end
+ FROM loan_payments p
+ JOIN payroll_weeks wk ON wk.id = p.week_id
+ WHERE p.loan_id = v_id
+ ) x;
+ RETURN core.rpc_ok(
+ jsonb_build_object('loan', v_loan, 'payments', v_payments, 'paid', v_paid),
+ format('Estado de cuenta del préstamo id=%s', v_id),
+ jsonb_build_object('fn', 'fn_loan_statement', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_loan_statement', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-045c-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_loan_update(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_loan_statement(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/046-settlements.sql b/db/core/changesets/046-settlements.sql
new file mode 100644
index 0000000..e82ea10
--- /dev/null
+++ b/db/core/changesets/046-settlements.sql
@@ -0,0 +1,230 @@
+--liquibase formatted sql
+-- PANELS · core · finiquitos y liquidaciones
+
+--changeset panel:core-046a-tables endDelimiter:; splitStatements:true
+CREATE TABLE IF NOT EXISTS core.settlements (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ tenant_id INTEGER NOT NULL,
+ worker_id BIGINT NOT NULL REFERENCES core.workers(id),
+ project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
+ company_id BIGINT REFERENCES core.companies(id),
+ kind TEXT NOT NULL CHECK (kind IN ('finiquito', 'liquidacion')),
+ termination_date DATE NOT NULL,
+ status TEXT NOT NULL DEFAULT 'borrador' CHECK (status IN ('borrador', 'pagado')),
+ notes TEXT NOT NULL DEFAULT '',
+ lines_total NUMERIC(14,2) NOT NULL DEFAULT 0,
+ pending_discounts NUMERIC(14,2) NOT NULL DEFAULT 0,
+ loan_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
+ net NUMERIC(14,2) NOT NULL DEFAULT 0,
+ expense_id BIGINT REFERENCES core.expense_entries(id) ON DELETE SET NULL,
+ paid_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_core_settlements_worker ON core.settlements (tenant_id, worker_id);
+ALTER TABLE core.settlements ENABLE ROW LEVEL SECURITY;
+DROP POLICY IF EXISTS tenant_isolation ON core.settlements;
+CREATE POLICY tenant_isolation ON core.settlements
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+
+CREATE TABLE IF NOT EXISTS core.settlement_lines (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ settlement_id BIGINT NOT NULL REFERENCES core.settlements(id) ON DELETE CASCADE,
+ concept TEXT NOT NULL,
+ base_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ note TEXT NOT NULL DEFAULT ''
+);
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.settlements TO panels_core_app;
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.settlement_lines TO panels_core_app;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
+
+--changeset panel:core-046b-fn-settlement splitStatements:false
+CREATE OR REPLACE FUNCTION core._settlement_recalc(p_id bigint)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_s settlements%ROWTYPE;
+ v_lines numeric;
+ v_disc numeric;
+ v_loans numeric;
+BEGIN
+ SELECT * INTO v_s FROM settlements WHERE id = p_id;
+ SELECT COALESCE(SUM(amount), 0) INTO v_lines FROM settlement_lines WHERE settlement_id = p_id;
+ SELECT COALESCE(SUM(a.amount), 0) INTO v_disc
+ FROM payroll_adjustments a
+ JOIN payroll_weeks w ON w.id = a.week_id
+ WHERE a.worker_id = v_s.worker_id
+ AND a.tenant_id = v_s.tenant_id
+ AND a.kind = 'descuento'
+ AND w.status <> 'paid';
+ SELECT COALESCE(SUM(balance), 0) INTO v_loans FROM loans WHERE worker_id = v_s.worker_id AND balance > 0;
+ UPDATE settlements SET
+ lines_total = core.round_money(v_lines),
+ pending_discounts = core.round_money(v_disc),
+ loan_balance = core.round_money(v_loans),
+ net = core.round_money(v_lines - v_disc - v_loans)
+ WHERE id = p_id;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_settlement_save(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_kind text := coalesce(nullif(btrim(payload->>'kind'), ''), 'finiquito');
+ v_date date := NULLIF(payload->>'termination_date', '')::date;
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
+BEGIN
+ IF v_kind NOT IN ('finiquito', 'liquidacion') OR v_worker_id IS NULL OR v_date IS NULL THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_settlement_save: persona, tipo y fecha de baja son obligatorios',
+ jsonb_build_object('fn', 'fn_settlement_save'));
+ END IF;
+ IF v_id IS NULL THEN
+ INSERT INTO settlements (tenant_id, worker_id, project_id, company_id, kind, termination_date, notes)
+ VALUES (v_tid, v_worker_id, v_project_id, v_company_id, v_kind, v_date, coalesce(payload->>'notes', ''))
+ RETURNING id INTO v_id;
+ ELSE
+ UPDATE settlements SET
+ project_id = v_project_id,
+ company_id = v_company_id,
+ kind = v_kind,
+ termination_date = v_date,
+ notes = coalesce(payload->>'notes', notes)
+ WHERE id = v_id AND tenant_id = v_tid AND status = 'borrador';
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_settlement_save: el finiquito no está en borrador',
+ jsonb_build_object('fn', 'fn_settlement_save', 'id', v_id));
+ END IF;
+ END IF;
+ IF jsonb_typeof(payload->'lines') = 'array' THEN
+ DELETE FROM settlement_lines WHERE settlement_id = v_id;
+ INSERT INTO settlement_lines (settlement_id, concept, base_amount, amount, note)
+ SELECT v_id,
+ coalesce(nullif(btrim(l->>'concept'), ''), 'Concepto'),
+ COALESCE((l->>'base_amount')::numeric, 0),
+ COALESCE((l->>'amount')::numeric, 0),
+ coalesce(l->>'note', '')
+ FROM jsonb_array_elements(payload->'lines') l;
+ END IF;
+ PERFORM core._settlement_recalc(v_id);
+ RETURN core.rpc_ok(jsonb_build_object('id', v_id), 'Finiquito guardado',
+ jsonb_build_object('fn', 'fn_settlement_save', 'id', v_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_settlement_save', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_settlement_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_lines jsonb;
+BEGIN
+ PERFORM core._settlement_recalc(v_id);
+ SELECT to_jsonb(s) INTO v_row FROM settlements s WHERE s.id = v_id;
+ IF v_row IS NULL THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Finiquito no encontrado',
+ jsonb_build_object('fn', 'fn_settlement_get', 'id', v_id));
+ END IF;
+ SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.id), '[]'::jsonb)
+ INTO v_lines FROM settlement_lines l WHERE l.settlement_id = v_id;
+ RETURN core.rpc_ok(jsonb_build_object('settlement', v_row, 'lines', v_lines),
+ 'Finiquito cargado', jsonb_build_object('fn', 'fn_settlement_get', 'id', v_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_settlement_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_settlement_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_rows jsonb;
+BEGIN
+ SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.termination_date DESC, x.id DESC), '[]'::jsonb)
+ INTO v_rows
+ FROM (
+ SELECT s.*, w.first_name, w.last_name_p
+ FROM settlements s
+ JOIN workers w ON w.id = s.worker_id
+ WHERE s.tenant_id = v_tid
+ AND (v_worker_id IS NULL OR s.worker_id = v_worker_id)
+ ) x;
+ RETURN core.rpc_ok(jsonb_build_object('settlements', v_rows), 'Finiquitos listados',
+ jsonb_build_object('fn', 'fn_settlement_list'));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_settlement_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_settlement_pay(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_s settlements%ROWTYPE;
+ v_company_id bigint;
+ v_expense_id bigint;
+BEGIN
+ SELECT * INTO v_s FROM settlements WHERE id = NULLIF(payload->>'id', '')::bigint;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Finiquito no encontrado', jsonb_build_object('fn', 'fn_settlement_pay'));
+ END IF;
+ IF v_s.status = 'pagado' THEN
+ RETURN core.rpc_err('VALIDATION', 'El finiquito ya está pagado', jsonb_build_object('fn', 'fn_settlement_pay', 'id', v_s.id));
+ END IF;
+ PERFORM core._settlement_recalc(v_s.id);
+ SELECT * INTO v_s FROM settlements WHERE id = v_s.id;
+ v_company_id := COALESCE(v_s.company_id, (SELECT company_id FROM projects WHERE id = v_s.project_id), (SELECT company_id FROM workers WHERE id = v_s.worker_id));
+ IF v_company_id IS NULL THEN
+ RETURN core.rpc_err('VALIDATION', 'Indique la empresa para registrar el gasto del finiquito',
+ jsonb_build_object('fn', 'fn_settlement_pay', 'field', 'company_id'));
+ END IF;
+ INSERT INTO expense_entries (
+ tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
+ subtotal, iva_amount, total, iva_rate, status, notes
+ ) VALUES (
+ v_s.tenant_id, v_company_id, v_s.project_id, v_s.termination_date, 'settlement', v_s.id,
+ format('%s trabajador %s', v_s.kind, v_s.worker_id),
+ v_s.net, 0, v_s.net, 0, 'confirmed', 'Pago de finiquito o liquidación'
+ ) RETURNING id INTO v_expense_id;
+ UPDATE settlements SET status = 'pagado', paid_at = now(), expense_id = v_expense_id, company_id = v_company_id
+ WHERE id = v_s.id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_s.id, 'net', v_s.net, 'expense_id', v_expense_id),
+ format('Finiquito id=%s pagado por %s', v_s.id, v_s.net),
+ jsonb_build_object('fn', 'fn_settlement_pay', 'id', v_s.id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_settlement_pay', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-046c-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core._settlement_recalc(bigint) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_settlement_save(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_settlement_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_settlement_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_settlement_pay(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/047-dispersion.sql b/db/core/changesets/047-dispersion.sql
new file mode 100644
index 0000000..92c79d5
--- /dev/null
+++ b/db/core/changesets/047-dispersion.sql
@@ -0,0 +1,239 @@
+--liquibase formatted sql
+-- PANELS · core · dispersión de nómina
+
+--changeset panel:core-047a-tables endDelimiter:; splitStatements:true
+CREATE TABLE IF NOT EXISTS core.payroll_dispersions (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ tenant_id INTEGER NOT NULL,
+ week_id BIGINT NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'abierta' CHECK (status IN ('abierta', 'corte', 'definitiva')),
+ net NUMERIC(14,2) NOT NULL DEFAULT 0,
+ note TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (week_id)
+);
+ALTER TABLE core.payroll_dispersions ENABLE ROW LEVEL SECURITY;
+DROP POLICY IF EXISTS tenant_isolation ON core.payroll_dispersions;
+CREATE POLICY tenant_isolation ON core.payroll_dispersions
+ USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
+ WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
+
+CREATE TABLE IF NOT EXISTS core.payroll_dispersion_lines (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ dispersion_id BIGINT NOT NULL REFERENCES core.payroll_dispersions(id) ON DELETE CASCADE,
+ worker_id BIGINT NOT NULL REFERENCES core.workers(id),
+ amount NUMERIC(14,2) NOT NULL DEFAULT 0,
+ note TEXT NOT NULL DEFAULT '',
+ UNIQUE (dispersion_id, worker_id)
+);
+
+CREATE TABLE IF NOT EXISTS core.payroll_dispersion_attachments (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ dispersion_id BIGINT NOT NULL REFERENCES core.payroll_dispersions(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_name TEXT NOT NULL DEFAULT '',
+ uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersions TO panels_core_app;
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersion_lines TO panels_core_app;
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersion_attachments TO panels_core_app;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
+
+--changeset panel:core-047b-fn-dispersion splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_dispersion_get(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_id bigint;
+ v_row jsonb;
+ v_lines jsonb;
+ v_files jsonb;
+ v_net numeric;
+BEGIN
+ IF v_week_id IS NULL THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_dispersion_get: week_id es obligatorio',
+ jsonb_build_object('fn', 'fn_dispersion_get'));
+ END IF;
+ SELECT COALESCE(SUM(l.payable_net), 0) INTO v_net
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = v_week_id;
+ INSERT INTO payroll_dispersions (tenant_id, week_id, net)
+ VALUES (v_tid, v_week_id, core.round_money(v_net))
+ ON CONFLICT (week_id) DO UPDATE SET updated_at = now()
+ RETURNING id INTO v_id;
+ SELECT to_jsonb(d) INTO v_row FROM payroll_dispersions d WHERE d.id = v_id;
+ SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.last_name_p), '[]'::jsonb) INTO v_lines
+ FROM (
+ SELECT dl.*, w.first_name, w.last_name_p
+ FROM payroll_dispersion_lines dl
+ JOIN workers w ON w.id = dl.worker_id
+ WHERE dl.dispersion_id = v_id
+ ) x;
+ SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.uploaded_at), '[]'::jsonb) INTO v_files
+ FROM payroll_dispersion_attachments a WHERE a.dispersion_id = v_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('dispersion', v_row, 'lines', v_lines, 'attachments', v_files, 'week_net', v_net),
+ 'Dispersión cargada', jsonb_build_object('fn', 'fn_dispersion_get', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_dispersion_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_dispersion_cut(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_d payroll_dispersions%ROWTYPE;
+ v_week_status text;
+BEGIN
+ SELECT * INTO v_d FROM payroll_dispersions WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
+ END IF;
+ IF v_d.status = 'definitiva' THEN
+ RETURN core.rpc_err('VALIDATION', 'La dispersión ya es definitiva', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
+ END IF;
+ SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_d.week_id;
+ DELETE FROM payroll_dispersion_lines WHERE dispersion_id = v_id;
+ INSERT INTO payroll_dispersion_lines (dispersion_id, worker_id, amount)
+ SELECT v_id, l.worker_id, core.round_money(SUM(l.payable_net))
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = v_d.week_id
+ GROUP BY l.worker_id;
+ UPDATE payroll_dispersions SET
+ status = 'corte',
+ net = (SELECT COALESCE(SUM(amount), 0) FROM payroll_dispersion_lines WHERE dispersion_id = v_id),
+ updated_at = now()
+ WHERE id = v_id;
+ RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'week_status', v_week_status),
+ 'Corte semanal de dispersión aplicado', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_dispersion_cut', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_dispersion_line_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
+ v_amount numeric := (payload->>'amount')::numeric;
+ v_status text;
+ v_week_status text;
+ v_disp_id bigint;
+BEGIN
+ SELECT d.status, w.status, d.id INTO v_status, v_week_status, v_disp_id
+ FROM payroll_dispersion_lines l
+ JOIN payroll_dispersions d ON d.id = l.dispersion_id
+ JOIN payroll_weeks w ON w.id = d.week_id
+ WHERE l.id = v_line_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Línea de dispersión no encontrada',
+ jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
+ END IF;
+ IF v_status = 'definitiva' OR v_week_status = 'paid' THEN
+ RETURN core.rpc_err('VALIDATION', 'La dispersión ya no se puede corregir',
+ jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
+ END IF;
+ UPDATE payroll_dispersion_lines
+ SET amount = core.round_money(COALESCE(v_amount, amount)),
+ note = CASE WHEN payload ? 'note' THEN coalesce(payload->>'note', '') ELSE note END
+ WHERE id = v_line_id;
+ UPDATE payroll_dispersions
+ SET net = (SELECT COALESCE(SUM(amount), 0) FROM payroll_dispersion_lines WHERE dispersion_id = v_disp_id),
+ updated_at = now()
+ WHERE id = v_disp_id;
+ RETURN core.rpc_ok(jsonb_build_object('id', v_line_id), 'Línea de dispersión actualizada',
+ jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_dispersion_line_update', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_dispersion_finalize(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_status text;
+BEGIN
+ SELECT status INTO v_status FROM payroll_dispersions WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada', jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
+ END IF;
+ IF v_status = 'abierta' THEN
+ RETURN core.rpc_err('VALIDATION', 'Aplique el corte semanal antes de dejar la dispersión definitiva',
+ jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
+ END IF;
+ UPDATE payroll_dispersions SET status = 'definitiva', note = coalesce(payload->>'note', note), updated_at = now()
+ WHERE id = v_id;
+ RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'status', 'definitiva'),
+ 'Dispersión definitiva', jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_dispersion_finalize', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_dispersion_attachment_add(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'dispersion_id', '')::bigint;
+ v_att_id bigint;
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM payroll_dispersions WHERE id = v_id) THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada',
+ jsonb_build_object('fn', 'fn_dispersion_attachment_add', 'id', v_id));
+ END IF;
+ INSERT INTO payroll_dispersion_attachments (
+ dispersion_id, original_name, mime, size_bytes, sha256, iv, storage_name, uploaded_by_name
+ ) VALUES (
+ v_id,
+ coalesce(payload->>'original_name', 'comprobante'),
+ coalesce(payload->>'mime', 'application/pdf'),
+ COALESCE((payload->>'size_bytes')::bigint, 0),
+ coalesce(payload->>'sha256', ''),
+ coalesce(payload->>'iv', ''),
+ coalesce(payload->>'storage_name', ''),
+ coalesce(payload->>'uploaded_by_name', '')
+ ) RETURNING id INTO v_att_id;
+ RETURN core.rpc_created(jsonb_build_object('id', v_att_id), 'Comprobante de dispersión anexado',
+ jsonb_build_object('fn', 'fn_dispersion_attachment_add', 'id', v_att_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_dispersion_attachment_add', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-047c-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_dispersion_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_dispersion_cut(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_dispersion_line_update(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_dispersion_finalize(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_dispersion_attachment_add(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/048-worker-movements.sql b/db/core/changesets/048-worker-movements.sql
new file mode 100644
index 0000000..aa75a62
--- /dev/null
+++ b/db/core/changesets/048-worker-movements.sql
@@ -0,0 +1,104 @@
+--liquibase formatted sql
+-- PANELS · core · historial de alta, baja y reingreso
+
+--changeset panel:core-048a-table endDelimiter:; splitStatements:true
+ALTER TABLE core.workers ADD COLUMN IF NOT EXISTS imss_daily_wage NUMERIC(12,2);
+CREATE TABLE IF NOT EXISTS core.worker_movements (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ tenant_id INTEGER,
+ worker_id BIGINT NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
+ kind TEXT NOT NULL CHECK (kind IN ('alta', 'baja', 'reingreso', 'alta_imss', 'baja_imss', 'modificacion_salario')),
+ movement_date DATE NOT NULL,
+ note TEXT NOT NULL DEFAULT '',
+ actor_name TEXT NOT NULL DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_core_worker_movements_worker ON core.worker_movements (worker_id, movement_date, id);
+GRANT SELECT, INSERT, UPDATE, DELETE ON core.worker_movements TO panels_core_app;
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
+
+--changeset panel:core-048b-backfill endDelimiter:; splitStatements:true
+INSERT INTO core.worker_movements (tenant_id, worker_id, kind, movement_date, note)
+SELECT w.tenant_id, w.id, 'alta', COALESCE(w.created_at::date, CURRENT_DATE), 'Alta de padrón'
+ FROM core.workers w
+ WHERE NOT EXISTS (
+ SELECT 1 FROM core.worker_movements m WHERE m.worker_id = w.id AND m.kind = 'alta'
+ );
+INSERT INTO core.worker_movements (tenant_id, worker_id, kind, movement_date, note)
+SELECT w.tenant_id, w.id, 'reingreso', w.last_rehire_at, 'Reingreso'
+ FROM core.workers w
+ WHERE w.last_rehire_at IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM core.worker_movements m WHERE m.worker_id = w.id AND m.kind = 'reingreso' AND m.movement_date = w.last_rehire_at
+ );
+INSERT INTO core.worker_movements (tenant_id, worker_id, kind, movement_date, note)
+SELECT w.tenant_id, w.id, 'baja', COALESCE(w.updated_at::date, CURRENT_DATE), 'Baja de padrón'
+ FROM core.workers w
+ WHERE w.status = 'baja'
+ AND NOT EXISTS (SELECT 1 FROM core.worker_movements m WHERE m.worker_id = w.id AND m.kind = 'baja');
+
+--changeset panel:core-048c-trigger splitStatements:false
+CREATE OR REPLACE FUNCTION core.trg_worker_movement()
+RETURNS trigger
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+BEGIN
+ IF TG_OP = 'INSERT' THEN
+ INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
+ VALUES (NEW.tenant_id, NEW.id, 'alta', COALESCE(NEW.created_at::date, CURRENT_DATE), 'Alta de padrón');
+ RETURN NEW;
+ END IF;
+ IF OLD.status IS DISTINCT FROM 'baja' AND NEW.status = 'baja' THEN
+ INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
+ VALUES (NEW.tenant_id, NEW.id, 'baja', CURRENT_DATE, 'Baja de padrón');
+ ELSIF OLD.status = 'baja' AND NEW.status = 'activo' THEN
+ INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
+ VALUES (NEW.tenant_id, NEW.id, 'reingreso', COALESCE(NEW.last_rehire_at, CURRENT_DATE), 'Reingreso');
+ END IF;
+ IF OLD.imss_status IS DISTINCT FROM 'alta' AND NEW.imss_status = 'alta' THEN
+ INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
+ VALUES (NEW.tenant_id, NEW.id, 'alta_imss', COALESCE(NEW.imss_alta_at, CURRENT_DATE), 'Alta IMSS');
+ ELSIF OLD.imss_status IS DISTINCT FROM 'baja_imss' AND NEW.imss_status = 'baja_imss' THEN
+ INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
+ VALUES (NEW.tenant_id, NEW.id, 'baja_imss', COALESCE(NEW.imss_baja_at, CURRENT_DATE), 'Baja IMSS');
+ END IF;
+ IF NEW.imss_daily_wage IS NOT NULL AND NEW.imss_daily_wage IS DISTINCT FROM OLD.imss_daily_wage THEN
+ INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
+ VALUES (NEW.tenant_id, NEW.id, 'modificacion_salario', CURRENT_DATE,
+ format('Jornal IMSS %s', NEW.imss_daily_wage));
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_worker_movement ON core.workers;
+CREATE TRIGGER trg_worker_movement
+ AFTER INSERT OR UPDATE OF status, imss_status, imss_daily_wage ON core.workers
+ FOR EACH ROW EXECUTE FUNCTION core.trg_worker_movement();
+
+--changeset panel:core-048d-fn-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_movement_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_rows jsonb;
+BEGIN
+ SELECT COALESCE(jsonb_agg(to_jsonb(m) ORDER BY m.movement_date, m.id), '[]'::jsonb)
+ INTO v_rows
+ FROM worker_movements m
+ WHERE m.worker_id = v_worker_id;
+ RETURN core.rpc_ok(jsonb_build_object('movements', v_rows), 'Historial de movimientos',
+ jsonb_build_object('fn', 'fn_worker_movement_list', 'worker_id', v_worker_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_movement_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-048e-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.trg_worker_movement() TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_movement_list(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/049-payroll-report.sql b/db/core/changesets/049-payroll-report.sql
new file mode 100644
index 0000000..45a2ec3
--- /dev/null
+++ b/db/core/changesets/049-payroll-report.sql
@@ -0,0 +1,81 @@
+--liquibase formatted sql
+-- PANELS · core · reporte de nómina por periodo
+
+--changeset panel:core-049a-fn-payroll-report splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_payroll_report(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_from date := NULLIF(payload->>'date_from', '')::date;
+ v_to date := NULLIF(payload->>'date_to', '')::date;
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_lines jsonb;
+ v_adjustments jsonb;
+ v_loans jsonb;
+ v_settlements jsonb;
+ v_total numeric;
+BEGIN
+ IF v_from IS NULL OR v_to IS NULL THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_payroll_report: date_from y date_to son obligatorios',
+ jsonb_build_object('fn', 'fn_payroll_report'));
+ END IF;
+ SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.week_start, x.last_name_p), '[]'::jsonb),
+ COALESCE(SUM(x.payable_net), 0)
+ INTO v_lines, v_total
+ FROM (
+ SELECT l.id, l.worker_id, w.first_name, w.last_name_p, s.kind, s.project_id, p.name AS project_name,
+ wk.week_start, wk.week_end, l.days, l.daily_wage, l.gross, l.discounts, l.loan_discount, l.payable_net
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ JOIN payroll_weeks wk ON wk.id = s.week_id
+ JOIN workers w ON w.id = l.worker_id
+ LEFT JOIN projects p ON p.id = COALESCE(s.project_id, l.project_id)
+ WHERE wk.tenant_id = v_tid
+ AND wk.week_start BETWEEN v_from AND v_to
+ AND (v_worker_id IS NULL OR l.worker_id = v_worker_id)
+ AND (v_project_id IS NULL OR s.project_id = v_project_id OR l.project_id = v_project_id)
+ ) x;
+ SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.adjustment_date), '[]'::jsonb) INTO v_adjustments
+ FROM payroll_adjustments a
+ JOIN payroll_weeks wk ON wk.id = a.week_id
+ WHERE a.tenant_id = v_tid
+ AND wk.week_start BETWEEN v_from AND v_to
+ AND (v_worker_id IS NULL OR a.worker_id = v_worker_id)
+ AND (v_project_id IS NULL OR a.project_id = v_project_id);
+ SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.created_at), '[]'::jsonb) INTO v_loans
+ FROM (
+ SELECT lp.amount, lp.label, lp.loan_id, l.worker_id, wk.week_start
+ FROM loan_payments lp
+ JOIN loans l ON l.id = lp.loan_id
+ JOIN payroll_weeks wk ON wk.id = lp.week_id
+ WHERE wk.tenant_id = v_tid
+ AND wk.week_start BETWEEN v_from AND v_to
+ AND (v_worker_id IS NULL OR l.worker_id = v_worker_id)
+ ) p;
+ SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY s.termination_date), '[]'::jsonb) INTO v_settlements
+ FROM settlements s
+ WHERE s.tenant_id = v_tid
+ AND s.status = 'pagado'
+ AND s.termination_date BETWEEN v_from AND v_to
+ AND (v_worker_id IS NULL OR s.worker_id = v_worker_id)
+ AND (v_project_id IS NULL OR s.project_id = v_project_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'lines', v_lines, 'adjustments', v_adjustments, 'loan_payments', v_loans,
+ 'settlements', v_settlements, 'payable_total', core.round_money(v_total)
+ ),
+ format('Reporte de nómina %s a %s', v_from, v_to),
+ jsonb_build_object('fn', 'fn_payroll_report', 'date_from', v_from, 'date_to', v_to)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_report', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-049b-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_payroll_report(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/050-imss-wage.sql b/db/core/changesets/050-imss-wage.sql
new file mode 100644
index 0000000..85ca902
--- /dev/null
+++ b/db/core/changesets/050-imss-wage.sql
@@ -0,0 +1,129 @@
+--liquibase formatted sql
+-- PANELS · core · jornal IMSS y consulta de cuotas
+
+--changeset panel:core-050a-columns endDelimiter:; splitStatements:true
+ALTER TABLE core.workers ADD COLUMN IF NOT EXISTS imss_daily_wage NUMERIC(12,2);
+ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_enfermedad NUMERIC(8,6) NOT NULL DEFAULT 0.020000;
+ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_invalidez NUMERIC(8,6) NOT NULL DEFAULT 0.017500;
+ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_retiro NUMERIC(8,6) NOT NULL DEFAULT 0.020000;
+ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_cesantia NUMERIC(8,6) NOT NULL DEFAULT 0.031500;
+ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_infonavit NUMERIC(8,6) NOT NULL DEFAULT 0.050000;
+ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_riesgo NUMERIC(8,6) NOT NULL DEFAULT 0.005433;
+ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS settlement_aguinaldo_days NUMERIC(6,2) NOT NULL DEFAULT 15;
+ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS settlement_prima_pct NUMERIC(6,2) NOT NULL DEFAULT 25;
+
+--changeset panel:core-050b-fn-settings-save splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_payroll_settings_save(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_cur payroll_settings%ROWTYPE;
+ v_settings jsonb;
+BEGIN
+ PERFORM core._payroll_seed_defaults(v_tid);
+ SELECT * INTO v_cur FROM payroll_settings WHERE tenant_id = v_tid;
+ UPDATE payroll_settings SET
+ loan_commission_enabled = COALESCE((payload->>'loan_commission_enabled')::boolean, v_cur.loan_commission_enabled),
+ loan_commission_pct = COALESCE((payload->>'loan_commission_pct')::numeric, v_cur.loan_commission_pct, 0),
+ loan_small_max = COALESCE((payload->>'loan_small_max')::numeric, v_cur.loan_small_max, 0),
+ imss_rate_enfermedad = COALESCE((payload->>'imss_rate_enfermedad')::numeric, v_cur.imss_rate_enfermedad),
+ imss_rate_invalidez = COALESCE((payload->>'imss_rate_invalidez')::numeric, v_cur.imss_rate_invalidez),
+ imss_rate_retiro = COALESCE((payload->>'imss_rate_retiro')::numeric, v_cur.imss_rate_retiro),
+ imss_rate_cesantia = COALESCE((payload->>'imss_rate_cesantia')::numeric, v_cur.imss_rate_cesantia),
+ imss_rate_infonavit = COALESCE((payload->>'imss_rate_infonavit')::numeric, v_cur.imss_rate_infonavit),
+ imss_rate_riesgo = COALESCE((payload->>'imss_rate_riesgo')::numeric, v_cur.imss_rate_riesgo),
+ settlement_aguinaldo_days = COALESCE((payload->>'settlement_aguinaldo_days')::numeric, v_cur.settlement_aguinaldo_days),
+ settlement_prima_pct = COALESCE((payload->>'settlement_prima_pct')::numeric, v_cur.settlement_prima_pct)
+ WHERE tenant_id = v_tid;
+ SELECT to_jsonb(s) INTO v_settings FROM payroll_settings s WHERE tenant_id = v_tid;
+ RETURN core.rpc_ok(jsonb_build_object('settings', v_settings), 'Configuración de nómina guardada',
+ jsonb_build_object('fn', 'fn_payroll_settings_save', 'tenant_id', v_tid));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_settings_save', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-050c-fn-imss-quote splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_imss_quote(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_from date := NULLIF(payload->>'date_from', '')::date;
+ v_to date := NULLIF(payload->>'date_to', '')::date;
+ v_settings payroll_settings%ROWTYPE;
+ v_rows jsonb;
+BEGIN
+ IF v_from IS NULL OR v_to IS NULL THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_imss_quote: indique el periodo',
+ jsonb_build_object('fn', 'fn_imss_quote'));
+ END IF;
+ PERFORM core._payroll_seed_defaults(v_tid);
+ SELECT * INTO v_settings FROM payroll_settings WHERE tenant_id = v_tid;
+ SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.last_name_p), '[]'::jsonb) INTO v_rows
+ FROM (
+ SELECT w.id AS worker_id, w.first_name, w.last_name_p, w.nss, w.daily_wage,
+ w.imss_daily_wage,
+ COALESCE(w.imss_daily_wage, w.daily_wage) AS wage_used,
+ (w.imss_daily_wage IS NULL) AS used_real_wage,
+ COALESCE(d.days, 0) AS days,
+ core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage)) AS sbc,
+ core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_enfermedad) AS cuota_enfermedad,
+ core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_invalidez) AS cuota_invalidez,
+ core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_retiro) AS cuota_retiro,
+ core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_cesantia) AS cuota_cesantia,
+ core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_infonavit) AS cuota_infonavit,
+ core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_riesgo) AS cuota_riesgo
+ FROM workers w
+ LEFT JOIN (
+ SELECT worker_id, COUNT(*)::numeric AS days
+ FROM attendance
+ WHERE present = true AND work_date BETWEEN v_from AND v_to
+ GROUP BY worker_id
+ ) d ON d.worker_id = w.id
+ WHERE w.tenant_id = v_tid
+ AND (v_worker_id IS NULL OR w.id = v_worker_id)
+ ) x;
+ RETURN core.rpc_ok(
+ jsonb_build_object('quotes', v_rows, 'rates', to_jsonb(v_settings)),
+ 'Consulta de cuotas IMSS',
+ jsonb_build_object('fn', 'fn_imss_quote')
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_imss_quote', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-050d-fn-imss-wage splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_imss_wage_set(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_wage numeric := NULLIF(payload->>'imss_daily_wage', '')::numeric;
+BEGIN
+ UPDATE workers SET imss_daily_wage = v_wage, updated_at = now() WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err('NOT_FOUND', 'Trabajador no encontrado', jsonb_build_object('fn', 'fn_worker_imss_wage_set', 'id', v_id));
+ END IF;
+ RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'imss_daily_wage', v_wage), 'Jornal IMSS actualizado',
+ jsonb_build_object('fn', 'fn_worker_imss_wage_set', 'id', v_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_imss_wage_set', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-050e-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_imss_quote(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_imss_wage_set(jsonb) TO panels_core_app;
diff --git a/db/iam/changelog-master.xml b/db/iam/changelog-master.xml
index c677a13..cbeafb1 100644
--- a/db/iam/changelog-master.xml
+++ b/db/iam/changelog-master.xml
@@ -12,5 +12,6 @@
+
diff --git a/db/iam/changesets/007-invoices-permission.sql b/db/iam/changesets/007-invoices-permission.sql
new file mode 100644
index 0000000..63cef42
--- /dev/null
+++ b/db/iam/changesets/007-invoices-permission.sql
@@ -0,0 +1,29 @@
+--liquibase formatted sql
+-- PANELS · iam · permisos de facturas
+
+--changeset panel:iam-007a-invoices endDelimiter:; splitStatements:true
+INSERT INTO iam.permissions (code, label, module, verb, perm_group) VALUES
+ ('invoices.view', 'Ver facturas', 'invoices', 'view', NULL),
+ ('invoices.create', 'Importar facturas', 'invoices', 'create', NULL),
+ ('invoices.update', 'Clasificar facturas', 'invoices', 'update', NULL)
+ON CONFLICT (code) DO UPDATE SET
+ label = EXCLUDED.label,
+ module = EXCLUDED.module,
+ verb = EXCLUDED.verb,
+ perm_group = EXCLUDED.perm_group;
+
+INSERT INTO iam._legacy_perm_map (legacy_code, v2_code) VALUES
+ ('manage_expenses', 'invoices.view'),
+ ('manage_expenses', 'invoices.create'),
+ ('manage_expenses', 'invoices.update'),
+ ('view_expenses', 'invoices.view')
+ON CONFLICT (legacy_code, v2_code) DO NOTHING;
+
+INSERT INTO iam.role_permissions (role_id, permission_code)
+SELECT sr.id, p.code
+ FROM iam.roles sr
+ JOIN iam.permissions p ON p.code IN ('invoices.view', 'invoices.create', 'invoices.update')
+ WHERE sr.code = 'tenant_admin'
+ AND sr.is_system
+ AND sr.tenant_id IS NULL
+ON CONFLICT DO NOTHING;
diff --git a/web-panel/components/SupplierFormDialog.vue b/web-panel/components/SupplierFormDialog.vue
index 2710079..8bec169 100644
--- a/web-panel/components/SupplierFormDialog.vue
+++ b/web-panel/components/SupplierFormDialog.vue
@@ -15,6 +15,15 @@
+
+
+
+
+
+
+
+
+
@@ -54,6 +63,7 @@ export type Supplier = {
city?: string;
state?: string;
postal_code?: string;
+ regimen_fiscal?: string;
notes?: string;
status?: string;
};
@@ -81,9 +91,17 @@ const empty = () => ({
phone: "",
address: "",
city: "",
+ postal_code: "",
+ regimen_fiscal: "",
notes: "",
+ status: "activo",
});
+const statusOpts = [
+ { label: "Activo", value: "activo" },
+ { label: "Inactivo", value: "inactivo" },
+];
+
const form = reactive(empty());
const error = ref("");
const saving = ref(false);
diff --git a/web-panel/components/WorkerFicha.vue b/web-panel/components/WorkerFicha.vue
index 8d8bf92..842e632 100644
--- a/web-panel/components/WorkerFicha.vue
+++ b/web-panel/components/WorkerFicha.vue
@@ -155,6 +155,11 @@
class="w-full mt-2"
@click="registerLoan"
/>
+
+
+
+
+ {{ money(Number(row.delivered || row.amount)) }}{{ money(Number(row.balance)) }}
@@ -162,9 +167,17 @@
+
+
+
+
+ {{ money(Number(row.amount)) }}
+
+
+
@@ -235,6 +248,12 @@ const requiredDone = computed(() => requiredItems.value.filter((item) => item.pr
const activeAssignments = computed(() => data.value?.assignments.filter((assignment) => assignment.active).length || 0);
const activeProjectNames = computed(() => data.value?.assignments.filter((assignment) => assignment.active).map((assignment) => assignment.project_name).join(", ") || "Sin asignar");
const loanBalance = computed(() => data.value?.loans.reduce((sum, loan) => sum + Math.max(0, Number(loan.balance)), 0) || 0);
+const movements = ref<{ movement_date: string; kind: string; note: string }[]>([]);
+const statementPayments = ref<{ week_end?: string; amount: number; label?: string }[]>([]);
+async function loadStatement(id: number) {
+ const r = await api<{ payments?: { week_end?: string; amount: number; label?: string }[]; data?: { payments?: { week_end?: string; amount: number; label?: string }[] } }>(`/v1/loans/${id}/statement`);
+ statementPayments.value = r.payments ?? r.data?.payments ?? [];
+}
const imssStatusLabel = computed(() => {
const status = data.value?.worker.imss_status;
if (status === "alta") return "Alta IMSS";
@@ -288,6 +307,8 @@ async function load() {
api<{ companies: CompanyOption[] }>("/v1/companies"),
]);
data.value = detail;
+ const mov = await api<{ movements?: { movement_date: string; kind: string; note: string }[]; data?: { movements?: { movement_date: string; kind: string; note: string }[] } }>(`/v1/workers/${props.workerId}/movements`);
+ movements.value = mov.movements ?? mov.data?.movements ?? [];
projects.value = projectResponse.projects;
companies.value = companyResponse.companies.filter((c) => c.status === "activo");
photoOk.value = true;
diff --git a/web-panel/components/padron/WorkerFormDialog.vue b/web-panel/components/padron/WorkerFormDialog.vue
index f78d518..453fb7a 100644
--- a/web-panel/components/padron/WorkerFormDialog.vue
+++ b/web-panel/components/padron/WorkerFormDialog.vue
@@ -44,6 +44,7 @@
+ = {
label: "Control de costos",
description: "Comparativo programado vs real y tableros de costo.",
},
+ invoices: {
+ label: "Facturas",
+ description: "Comprobantes recibidos y emitidos, clasificación a la obra.",
+ },
expenses: {
label: "Gastos",
description: "Captura y seguimiento de egresos por obra.",
@@ -125,6 +129,7 @@ const MODULE_ORDER = [
"work_program",
"budget",
"cost_control",
+ "invoices",
"expenses",
"workers",
"kanban",
diff --git a/web-panel/layouts/default.vue b/web-panel/layouts/default.vue
index 8d564ec..0922c1f 100644
--- a/web-panel/layouts/default.vue
+++ b/web-panel/layouts/default.vue
@@ -183,6 +183,7 @@ const menu = computed(() => {
if (obraItems.length) items.push({ label: "Obra", icon: "pi pi-building", items: obraItems });
const opItems = [
+ navIf("Facturas", "pi pi-file", "/facturas", "invoices.view"),
navIf("Gastos", "pi pi-money-bill", "/gastos", "expenses.view"),
navIf("Almacén", "pi pi-box", "/almacen", "warehouse.view"),
navIf("Kanban", "pi pi-th-large", "/kanban", "kanban.view"),
diff --git a/web-panel/middleware/auth.global.ts b/web-panel/middleware/auth.global.ts
index ae613ba..988b377 100644
--- a/web-panel/middleware/auth.global.ts
+++ b/web-panel/middleware/auth.global.ts
@@ -5,6 +5,7 @@ const ROUTE_PERMISSIONS: Record = {
"/presupuesto": "budget.view",
"/programa-obra": "work_program.view",
"/control-presupuesto": "cost_control.view",
+ "/facturas": "invoices.view",
"/gastos": "expenses.view",
"/almacen": "warehouse.view",
"/kanban": "kanban.view",
diff --git a/web-panel/pages/configuracion.vue b/web-panel/pages/configuracion.vue
index 6a5f05f..9e6dc52 100644
--- a/web-panel/pages/configuracion.vue
+++ b/web-panel/pages/configuracion.vue
@@ -107,6 +107,14 @@
{{ data.rfc || "—" }}
+
+ {{ data.regimen_fiscal || "—" }}
+
+
+
+
+
+
{{ data.phone || "—" }}
diff --git a/web-panel/pages/facturas.vue b/web-panel/pages/facturas.vue
new file mode 100644
index 0000000..7d359cf
--- /dev/null
+++ b/web-panel/pages/facturas.vue
@@ -0,0 +1,138 @@
+
+