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