feat: facturas, ajustes de nómina, finiquitos, dispersión e IMSS

Reutiliza el catálogo de proveedores y agrega carga de facturas, conceptos de la semana, préstamos editables, finiquitos, dispersión, historial de personal, reportes y jornal de cotización.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-24 16:40:08 +00:00
parent ea705c6c92
commit caf40e29fb
No known key found for this signature in database
29 changed files with 3153 additions and 4 deletions

View file

@ -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);
});

View file

@ -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<string, string[]> = {
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"],

109
api/invoices_http.ts Normal file
View file

@ -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<string, unknown>) {
const r: Record<string, string> = {};
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<Record<string, unknown>>(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<Record<string, unknown>>();
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) }));
});
}

View file

@ -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);

View file

@ -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<Record<string, unknown>>();
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<Record<string, unknown>>();
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<Record<string, unknown>>();
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<Record<string, unknown>>();
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<Record<string, unknown>>();
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<Record<string, unknown>>().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<string, unknown>[]; 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<string, unknown>[]; 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<string, unknown>[] }>(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 }>();

219
api/pendientes_test.ts Normal file
View file

@ -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);
});
});

View file

@ -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}`;
}

View file

@ -51,5 +51,14 @@
<include file="changesets/039-fn-next-project-code-global.sql" relativeToChangelogFile="true"/>
<include file="changesets/040-fn-worker-document-delete.sql" relativeToChangelogFile="true"/>
<include file="changesets/041-fn-worker-document-store-replace.sql" relativeToChangelogFile="true"/>
<include file="changesets/042-suppliers-fiscal.sql" relativeToChangelogFile="true"/>
<include file="changesets/043-invoices.sql" relativeToChangelogFile="true"/>
<include file="changesets/044-payroll-adjustments.sql" relativeToChangelogFile="true"/>
<include file="changesets/045-loan-update.sql" relativeToChangelogFile="true"/>
<include file="changesets/046-settlements.sql" relativeToChangelogFile="true"/>
<include file="changesets/047-dispersion.sql" relativeToChangelogFile="true"/>
<include file="changesets/048-worker-movements.sql" relativeToChangelogFile="true"/>
<include file="changesets/049-payroll-report.sql" relativeToChangelogFile="true"/>
<include file="changesets/050-imss-wage.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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;
$$;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -12,5 +12,6 @@
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-iam-v2-roles-crud.sql" relativeToChangelogFile="true"/>
<include file="changesets/006-documents-delete-permission.sql" relativeToChangelogFile="true"/>
<include file="changesets/007-invoices-permission.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -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;

View file

@ -15,6 +15,15 @@
<FormField label="RFC">
<InputText v-model="form.rfc" class="w-full identifier-input" maxlength="13" placeholder="XAXX010101000" />
</FormField>
<FormField label="Régimen fiscal">
<InputText v-model="form.regimen_fiscal" class="w-full identifier-input" maxlength="3" placeholder="601" />
</FormField>
<FormField label="C.P. fiscal">
<InputText v-model="form.postal_code" class="w-full identifier-input" maxlength="5" />
</FormField>
<FormField label="Estatus">
<Select v-model="form.status" :options="statusOpts" option-label="label" option-value="value" class="w-full" />
</FormField>
<FormField label="Teléfono">
<InputText v-model="form.phone" class="w-full" />
</FormField>
@ -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);

View file

@ -155,6 +155,11 @@
class="w-full mt-2"
@click="registerLoan"
/>
<DataTable :value="movements" size="small" class="mt-3" empty-message="Sin movimientos">
<Column field="movement_date" header="Fecha" />
<Column field="kind" header="Movimiento" />
<Column field="note" header="Nota" />
</DataTable>
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos">
<Column header="Entregado"><template #body="{ data: row }">{{ money(Number(row.delivered || row.amount)) }}</template></Column>
<Column header="Saldo"><template #body="{ data: row }">{{ money(Number(row.balance)) }}</template></Column>
@ -162,9 +167,17 @@
<Column header="">
<template #body="{ data: row }">
<Button icon="pi pi-file-pdf" text rounded size="small" aria-label="Recibo" @click="downloadLoan(row.id)" />
<Button icon="pi pi-list" text rounded size="small" aria-label="Estado de cuenta" @click="loadStatement(row.id)" />
</template>
</Column>
</DataTable>
<DataTable v-if="statementPayments.length" :value="statementPayments" size="small" class="mt-2">
<Column field="week_end" header="Semana" />
<Column field="amount" header="Descuento">
<template #body="{ data: row }">{{ money(Number(row.amount)) }}</template>
</Column>
<Column field="label" header="Etiqueta" />
</DataTable>
</TabPanel>
</TabPanels>
</Tabs>
@ -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;

View file

@ -44,6 +44,7 @@
</FormField>
<FormField label="Tipo de trabajo"><SelectButton v-model="form.work_type" :options="workTypes" option-label="label" option-value="value" :allow-empty="false" /></FormField>
<FormField label="Jornal diario" required :error="fieldErr.daily_wage"><InputNumber v-model="form.daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
<FormField label="Jornal IMSS"><InputNumber v-model="form.imss_daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
<FormField label="Gafete">
<SelectButton
:model-value="form.needs_badge ? 'si' : 'no'"

View file

@ -75,6 +75,10 @@ const MODULE_META: Record<string, { label: string; description: string }> = {
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",

View file

@ -183,6 +183,7 @@ const menu = computed<NavItem[]>(() => {
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"),

View file

@ -5,6 +5,7 @@ const ROUTE_PERMISSIONS: Record<string, string> = {
"/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",

View file

@ -107,6 +107,14 @@
<Column field="rfc" header="RFC">
<template #body="{ data }"><span class="identifier">{{ data.rfc || "—" }}</span></template>
</Column>
<Column field="regimen_fiscal" header="Régimen">
<template #body="{ data }">{{ data.regimen_fiscal || "—" }}</template>
</Column>
<Column header="Estatus">
<template #body="{ data }">
<Tag :value="data.status === 'inactivo' ? 'Inactivo' : 'Activo'" :severity="data.status === 'inactivo' ? 'secondary' : 'success'" />
</template>
</Column>
<Column field="phone" header="Teléfono">
<template #body="{ data }">{{ data.phone || "—" }}</template>
</Column>

View file

@ -0,0 +1,138 @@
<template>
<div>
<DeskTableBar v-model="tableQ" placeholder="Proveedor, RFC, UUID o folio">
<SelectButton v-model="direction" :options="dirOpts" option-label="label" option-value="value" :allow-empty="false" @change="load" />
<InputNumber v-model="amountMin" placeholder="Monto mín" />
<InputNumber v-model="amountMax" placeholder="Monto máx" />
<Button label="Importar Excel" icon="pi pi-upload" @click="showImport = true" />
</DeskTableBar>
<DataTable :value="listed" data-key="id" paginator :rows="20" @row-dblclick="(e: { data: Invoice }) => open(e.data)">
<Column field="invoice_date" header="Fecha" />
<Column field="supplier_name" header="Proveedor" />
<Column field="supplier_rfc" header="RFC" />
<Column field="uuid" header="UUID" />
<Column field="folio" header="Folio" />
<Column field="total" header="Total">
<template #body="{ data }">{{ money(data.total) }}</template>
</Column>
<Column field="status" header="Estatus" />
</DataTable>
<Dialog v-model:visible="showImport" header="Importar facturas" modal :style="{ width: '28rem' }">
<Select v-model="companyId" :options="companies" option-label="name" option-value="id" placeholder="Empresa" class="w-full" />
<input class="mt-3" type="file" accept=".xlsx,.xls" @change="onFile" />
<template #footer>
<Button label="Cargar" icon="pi pi-check" :disabled="!file || !companyId" @click="upload" />
</template>
</Dialog>
<Dialog v-model:visible="showDetail" header="Clasificar factura" modal :style="{ width: '36rem' }">
<p v-if="detail">{{ detail.supplier_name }} · {{ detail.uuid || detail.folio }} · {{ detail.review_note }}</p>
<div v-for="line in lines" :key="line.id" class="mt-2">
<div>{{ line.description }} · {{ money(line.amount) }}</div>
<Select v-model="line.project_id" :options="projects" option-label="name" option-value="id" placeholder="Obra" class="w-full mt-1" />
<BudgetItemPicker v-if="line.project_id" v-model="line.budget_item_id" :project-id="line.project_id" class="mt-1" />
</div>
<template #footer>
<Button label="Clasificar" icon="pi pi-check" @click="classify" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
type Invoice = {
id: number;
invoice_date: string;
supplier_name: string;
supplier_rfc: string;
uuid: string;
folio: string;
total: number;
status: string;
review_note?: string;
};
type Line = { id: number; description: string; amount: number; project_id: number | null; budget_item_id: number | null };
const { api } = useApi();
const toast = useToast();
const tableQ = ref("");
const direction = ref("recibida");
const dirOpts = [{ label: "Recibidas", value: "recibida" }, { label: "Emitidas", value: "emitida" }];
const amountMin = ref<number | null>(null);
const amountMax = ref<number | null>(null);
const invoices = ref<Invoice[]>([]);
const companies = ref<{ id: number; name: string }[]>([]);
const projects = ref<{ id: number; name: string }[]>([]);
const showImport = ref(false);
const showDetail = ref(false);
const companyId = ref<number | null>(null);
const file = ref<File | null>(null);
const detail = ref<Invoice | null>(null);
const lines = ref<Line[]>([]);
const listed = computed(() => {
const q = tableQ.value.trim().toLowerCase();
return invoices.value.filter((i) => {
if (amountMin.value != null && Number(i.total) < amountMin.value) return false;
if (amountMax.value != null && Number(i.total) > amountMax.value) return false;
if (!q) return true;
return [i.supplier_name, i.supplier_rfc, i.uuid, i.folio].join(" ").toLowerCase().includes(q);
});
});
function money(n: number) {
return Number(n || 0).toLocaleString("es-MX", { style: "currency", currency: "MXN" });
}
async function load() {
const q = new URLSearchParams({ direction: direction.value });
const r = await api<{ invoices?: Invoice[]; data?: { invoices?: Invoice[] } }>(`/v1/invoices?${q}`);
invoices.value = r.invoices ?? r.data?.invoices ?? [];
}
function onFile(ev: Event) {
file.value = (ev.target as HTMLInputElement).files?.[0] ?? null;
}
async function upload() {
if (!file.value || !companyId.value) return;
const body = new FormData();
body.set("company_id", String(companyId.value));
body.set("file", file.value);
const r = await api<{ created?: number; review?: number; data?: { created?: number; review?: number } }>("/v1/invoices/import", { method: "POST", body });
const created = r.created ?? r.data?.created ?? 0;
const review = r.review ?? r.data?.review ?? 0;
toast.add({ severity: "success", summary: `${created} facturas, ${review} en revisión`, life: 2500 });
showImport.value = false;
await load();
}
async function open(row: Invoice) {
const r = await api<{ invoice?: Invoice; lines?: Line[]; data?: { invoice?: Invoice; lines?: Line[] } }>(`/v1/invoices/${row.id}`);
detail.value = r.invoice ?? r.data?.invoice ?? row;
lines.value = r.lines ?? r.data?.lines ?? [];
showDetail.value = true;
}
async function classify() {
if (!detail.value) return;
const line = lines.value[0];
await api(`/v1/invoices/${detail.value.id}/classify`, {
method: "POST",
json: { line_id: line?.id, project_id: line?.project_id, budget_item_id: line?.budget_item_id },
});
showDetail.value = false;
await load();
}
onMounted(async () => {
const [c, p] = await Promise.all([
api<{ companies?: { id: number; name: string }[] }>("/v1/companies"),
api<{ projects?: { id: number; name: string }[] }>("/v1/projects"),
]);
companies.value = c.companies ?? [];
projects.value = p.projects ?? [];
await load();
});
</script>

View file

@ -220,6 +220,61 @@
</template>
</Dialog>
<Dialog v-model:visible="showAdjust" header="Bono, descuento u horas" modal :style="{ width: '24rem' }">
<div class="loan-form">
<Select v-model="adjust.kind" :options="adjustKinds" option-label="label" option-value="value" class="w-full" />
<InputText v-model="adjust.concept" placeholder="Concepto" class="w-full" />
<InputNumber v-model="adjust.amount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<InputNumber v-model="adjust.hours" :min-fraction-digits="0" :max-fraction-digits="2" placeholder="Horas" class="w-full" />
<Textarea v-model="adjust.note" placeholder="Observación (obligatoria en descuento)" class="w-full" rows="2" />
<Button label="Guardar" @click="saveAdjust" />
</div>
</Dialog>
<Dialog v-model:visible="showHoliday" header="Día festivo" modal :style="{ width: '22rem' }">
<div class="loan-form">
<InputText v-model="holiday.holiday_date" placeholder="AAAA-MM-DD" class="w-full" />
<InputText v-model="holiday.name" placeholder="Nombre" class="w-full" />
<Button label="Guardar" @click="saveHoliday" />
</div>
</Dialog>
<Dialog v-model:visible="showDispersion" header="Dispersión" modal :style="{ width: '36rem' }">
<p>Neto semana {{ money(dispersionNet) }} · {{ dispersionStatus }}</p>
<DataTable :value="dispersionLines" size="small">
<Column field="last_name_p" header="Persona" />
<Column field="amount" header="Importe">
<template #body="{ data }">
<InputNumber v-model="data.amount" mode="currency" currency="MXN" locale="es-MX" :disabled="dispersionStatus === 'definitiva'" @blur="saveDispLine(data)" />
</template>
</Column>
</DataTable>
<div class="flex gap-2 mt-3">
<Button label="Corte semanal" :disabled="dispersionStatus === 'definitiva'" @click="cutDispersion" />
<Button label="Definitiva" :disabled="dispersionStatus !== 'corte'" @click="finalizeDispersion" />
<input type="file" accept="application/pdf,.pdf" @change="attachDispersion" />
</div>
</Dialog>
<Dialog v-model:visible="showSettlement" header="Finiquito o liquidación" modal :style="{ width: '24rem' }">
<div class="loan-form">
<Select v-model="settlement.kind" :options="[{ label: 'Finiquito', value: 'finiquito' }, { label: 'Liquidación', value: 'liquidacion' }]" option-label="label" option-value="value" class="w-full" />
<InputText v-model="settlement.termination_date" placeholder="Fecha de baja AAAA-MM-DD" class="w-full" />
<InputText v-model="settlement.concept" placeholder="Concepto" class="w-full" />
<InputNumber v-model="settlement.amount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<Textarea v-model="settlement.note" placeholder="Observación" class="w-full" rows="2" />
<Button label="Guardar y ver importe" @click="saveSettlement" />
<p v-if="settlementNet != null">Importe final {{ money(settlementNet) }}</p>
</div>
</Dialog>
<Dialog v-model:visible="showReport" header="Reporte de nómina" modal :style="{ width: '28rem' }">
<div class="loan-form">
<InputText v-model="reportFrom" placeholder="Desde AAAA-MM-DD" class="w-full" />
<InputText v-model="reportTo" placeholder="Hasta AAAA-MM-DD" class="w-full" />
<div class="flex gap-2">
<Button label="Excel" @click="downloadReport('xlsx')" />
<Button label="PDF" @click="downloadReport('pdf')" />
<Button label="IMSS Excel" @click="downloadImss" />
</div>
</div>
</Dialog>
<Dialog v-model:visible="showAddJornal" header="Agregar a jornal" modal :style="{ width: '22rem' }">
<Select v-model="jornalWorkerId" :options="allWorkers" option-label="label" option-value="id" placeholder="Persona" class="w-full" filter />
<template #footer>
@ -286,6 +341,26 @@ const assembleFrom = ref("");
const showDestajo = ref(false);
const showAdmin = ref(false);
const showAddJornal = ref(false);
const showAdjust = ref(false);
const showHoliday = ref(false);
const showDispersion = ref(false);
const showReport = ref(false);
const showSettlement = ref(false);
const settlement = reactive({ kind: "finiquito", termination_date: "", concept: "Sueldo pendiente", amount: 0, note: "" });
const adjustKinds = [
{ label: "Bono", value: "bono" },
{ label: "Descuento", value: "descuento" },
{ label: "Horas", value: "horas" },
];
const adjust = reactive({ kind: "bono", concept: "", amount: 0, hours: 0, note: "" });
const holiday = reactive({ holiday_date: "", name: "" });
const dispersionId = ref<number | null>(null);
const dispersionStatus = ref("abierta");
const dispersionNet = ref(0);
const dispersionLines = ref<{ id: number; last_name_p: string; amount: number }[]>([]);
const settlementNet = ref<number | null>(null);
const reportFrom = ref("");
const reportTo = ref("");
const selectedWorkerId = ref<number | null>(null);
const selectedWorkerName = ref("");
const jornalWorkerId = ref<number | null>(null);
@ -451,6 +526,11 @@ function syncChrome() {
},
{ key: "pay", label: "Pagar sábado", icon: "pi pi-wallet", disabled: paid.value, command: pay },
{ key: "csv", label: "CSV", icon: "pi pi-download", command: csv },
{ key: "ajuste", label: "Bono o descuento", icon: "pi pi-plus", disabled: paid.value, command: () => { showAdjust.value = true; } },
{ key: "festivo", label: "Festivo", icon: "pi pi-calendar", command: () => { showHoliday.value = true; } },
{ key: "dispersion", label: "Dispersión", icon: "pi pi-send", command: openDispersion },
{ key: "reporte", label: "Reporte", icon: "pi pi-chart-bar", command: () => { showReport.value = true; } },
{ key: "finiquito", label: "Finiquito", icon: "pi pi-file", disabled: !selectedWorkerId.value, command: () => { showSettlement.value = true; } },
],
},
{
@ -567,6 +647,74 @@ async function pay() {
});
}
async function saveSettlement() {
if (!selectedWorkerId.value) return;
const saved = await api<{ id?: number; data?: { id?: number } }>("/v1/settlements", {
method: "POST",
json: {
worker_id: selectedWorkerId.value,
project_id: projectId.value,
kind: settlement.kind,
termination_date: settlement.termination_date,
lines: [{ concept: settlement.concept, amount: settlement.amount, note: settlement.note }],
},
});
const id = saved.id ?? saved.data?.id;
if (!id) return;
const got = await api<{ settlement?: { net: number }; data?: { settlement?: { net: number } } }>(`/v1/settlements/${id}`);
settlementNet.value = Number(got.settlement?.net ?? got.data?.settlement?.net ?? 0);
}
async function saveAdjust() {
if (!week.value || !selectedWorkerId.value) return;
await api("/v1/payroll/adjustments", {
method: "POST",
json: { ...adjust, week_id: week.value.id, worker_id: selectedWorkerId.value, project_id: projectId.value },
});
showAdjust.value = false;
await load();
}
async function saveHoliday() {
await api("/v1/payroll/holidays", { method: "POST", json: holiday });
showHoliday.value = false;
await load();
}
async function openDispersion() {
if (!week.value) return;
const r = await api<{ dispersion?: { id: number; status: string; net: number }; lines?: { id: number; last_name_p: string; amount: number }[]; data?: { dispersion?: { id: number; status: string; net: number }; lines?: { id: number; last_name_p: string; amount: number }[] } }>(`/v1/payroll/weeks/${week.value.id}/dispersion`);
const d = r.dispersion ?? r.data?.dispersion;
dispersionId.value = d?.id ?? null;
dispersionStatus.value = d?.status ?? "abierta";
dispersionNet.value = Number(d?.net ?? 0);
dispersionLines.value = r.lines ?? r.data?.lines ?? [];
showDispersion.value = true;
}
async function cutDispersion() {
if (!dispersionId.value) return;
await api(`/v1/payroll/dispersions/${dispersionId.value}/cut`, { method: "POST" });
await openDispersion();
}
async function saveDispLine(row: { id: number; amount: number }) {
await api(`/v1/payroll/dispersion-lines/${row.id}`, { method: "PATCH", json: { amount: row.amount } });
}
async function finalizeDispersion() {
if (!dispersionId.value) return;
await api(`/v1/payroll/dispersions/${dispersionId.value}/finalize`, { method: "POST", json: {} });
await openDispersion();
}
async function attachDispersion(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file || !dispersionId.value) return;
const body = new FormData();
body.set("file", file);
await api(`/v1/payroll/dispersions/${dispersionId.value}/attachment`, { method: "POST", body });
toast.add({ severity: "success", summary: "Comprobante anexado", life: 1800 });
}
function downloadReport(kind: "xlsx" | "pdf") {
download(`/v1/payroll/report.${kind}?date_from=${reportFrom.value}&date_to=${reportTo.value}`, `nomina.${kind}`);
}
function downloadImss() {
download(`/v1/payroll/imss-quote.xlsx?date_from=${reportFrom.value}&date_to=${reportTo.value}`, "imss.xlsx");
}
function csv() {
if (!week.value) return;
download(`/v1/payroll/weeks/${week.value.id}/csv`, `nomina-${week.value.id}.csv`).then(() => {

View file

@ -25,6 +25,7 @@ export type Worker = {
risk_code: string;
work_type: "N" | "D";
daily_wage: number;
imss_daily_wage?: number | null;
needs_badge: number | boolean;
status: WorkerStatus;
pipeline_status: PipelineStatus;
@ -168,6 +169,7 @@ export type WorkerForm = {
risk_code: string;
work_type: "N" | "D";
daily_wage: number;
imss_daily_wage?: number | null;
project_id: number | null;
needs_badge: boolean;
};
@ -200,6 +202,7 @@ export function emptyWorkerForm(): WorkerForm {
risk_code: "rojo",
work_type: "N",
daily_wage: 0,
imss_daily_wage: null,
project_id: null,
needs_badge: true,
};