mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 15:33:17 +00:00
feat: facturas, ajustes de nómina, finiquitos, dispersión e IMSS
Reutiliza el catálogo de proveedores y agrega carga de facturas, conceptos de la semana, préstamos editables, finiquitos, dispersión, historial de personal, reportes y jornal de cotización. Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
parent
ea705c6c92
commit
caf40e29fb
29 changed files with 3153 additions and 4 deletions
|
|
@ -22,6 +22,7 @@ export function registerExpenseCatalogRoutes(app: App) {
|
||||||
const env = await callCoreFn(db, "core.fn_supplier_list", {
|
const env = await callCoreFn(db, "core.fn_supplier_list", {
|
||||||
tenant_id: tid(c),
|
tenant_id: tid(c),
|
||||||
status: c.req.query("status"),
|
status: c.req.query("status"),
|
||||||
|
q: c.req.query("q"),
|
||||||
}, { route: routeLabel(c) });
|
}, { route: routeLabel(c) });
|
||||||
return respondRpc(c, env);
|
return respondRpc(c, env);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.create", label: "Importar/generar programa", module: "work_program", verb: "create" },
|
||||||
{ code: "work_program.update", label: "Capturar avances", module: "work_program", verb: "update" },
|
{ 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: "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.view", label: "Ver gastos", module: "expenses", verb: "view" },
|
||||||
{ code: "expenses.create", label: "Capturar gastos", module: "expenses", verb: "create" },
|
{ code: "expenses.create", label: "Capturar gastos", module: "expenses", verb: "create" },
|
||||||
{ code: "expenses.update", label: "Editar gastos", module: "expenses", verb: "update" },
|
{ code: "expenses.update", label: "Editar gastos", module: "expenses", verb: "update" },
|
||||||
|
|
@ -80,8 +83,8 @@ export const LEGACY_TO_V2: Record<string, string[]> = {
|
||||||
manage_projects: ["projects.view", "projects.create", "projects.update"],
|
manage_projects: ["projects.view", "projects.create", "projects.update"],
|
||||||
manage_companies: ["companies.view", "companies.create", "companies.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_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"],
|
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"],
|
view_expenses: ["expenses.view", "cost_control.view", "invoices.view"],
|
||||||
manage_payroll: ["payroll.view", "payroll.create", "payroll.update", "payroll.delete"],
|
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_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"],
|
manage_users: ["users.view", "users.create", "users.update", "users.delete"],
|
||||||
|
|
|
||||||
109
api/invoices_http.ts
Normal file
109
api/invoices_http.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import type { AuthUser } from "./auth.ts";
|
||||||
|
import { tenantScope } from "./auth.ts";
|
||||||
|
import { requireCoreAuth } from "./scope.ts";
|
||||||
|
import { requirePermission } from "./permissions.ts";
|
||||||
|
import { callCoreFn } from "./rpc.ts";
|
||||||
|
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
|
||||||
|
import type { Db } from "./db.ts";
|
||||||
|
|
||||||
|
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
|
||||||
|
|
||||||
|
function tid(c: { get: (k: "user") => AuthUser }): number {
|
||||||
|
return tenantScope(c.get("user")) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: unknown): number {
|
||||||
|
const n = Number(String(v ?? "").replace(/[$,\s]/g, ""));
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapInvoiceRow(raw: Record<string, unknown>) {
|
||||||
|
const r: Record<string, string> = {};
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
r[k.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().trim()] = String(v ?? "").trim();
|
||||||
|
}
|
||||||
|
const pick = (...keys: string[]) => keys.map((k) => r[k]).find((v) => v) ?? "";
|
||||||
|
return {
|
||||||
|
direction: pick("direccion", "dirección", "tipo") || "recibida",
|
||||||
|
rfc: pick("rfc"),
|
||||||
|
supplier_name: pick("proveedor", "nombre"),
|
||||||
|
uuid: pick("uuid"),
|
||||||
|
serie: pick("serie"),
|
||||||
|
folio: pick("folio"),
|
||||||
|
invoice_date: pick("fecha", "invoice_date"),
|
||||||
|
description: pick("descripcion", "descripción", "concepto"),
|
||||||
|
quantity: num(pick("cantidad")),
|
||||||
|
unit: pick("unidad"),
|
||||||
|
amount: num(pick("importe", "subtotal")),
|
||||||
|
iva: num(pick("iva")),
|
||||||
|
total: num(pick("total")),
|
||||||
|
project_code: pick("obra", "clave obra", "proyecto"),
|
||||||
|
item_code: pick("clave concepto", "concepto presupuesto", "partida"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerInvoiceRoutes(app: App) {
|
||||||
|
app.get("/v1/invoices", ...requireCoreAuth, requirePermission("invoices.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn(db, "core.fn_invoice_list", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
direction: c.req.query("direction"),
|
||||||
|
supplier_id: c.req.query("supplier_id"),
|
||||||
|
rfc: c.req.query("rfc"),
|
||||||
|
uuid: c.req.query("uuid"),
|
||||||
|
folio: c.req.query("folio"),
|
||||||
|
q: c.req.query("q"),
|
||||||
|
amount_min: c.req.query("amount_min"),
|
||||||
|
amount_max: c.req.query("amount_max"),
|
||||||
|
date_from: c.req.query("date_from"),
|
||||||
|
date_to: c.req.query("date_to"),
|
||||||
|
project_id: c.req.query("project_id"),
|
||||||
|
classified: c.req.query("classified"),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
return respondRpc(c, env);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/invoices/:id", ...requireCoreAuth, requirePermission("invoices.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_invoice_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/invoices/import", ...requireCoreAuth, requirePermission("invoices.create"), async (c) => {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const companyId = Number(body.company_id);
|
||||||
|
const file = body.file;
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return respondApiError(c, "VALIDATION", "Adjunte el Excel de facturas", { route: routeLabel(c) });
|
||||||
|
}
|
||||||
|
const wb = XLSX.read(new Uint8Array(await file.arrayBuffer()), { type: "array", cellDates: true });
|
||||||
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
const raw = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: "", raw: false });
|
||||||
|
const rows = raw.map(mapInvoiceRow).map((row) => {
|
||||||
|
const date = row.invoice_date;
|
||||||
|
const iso = /^\d{4}-\d{2}-\d{2}/.test(date)
|
||||||
|
? date.slice(0, 10)
|
||||||
|
: /^\d{2}\/\d{2}\/\d{4}/.test(date)
|
||||||
|
? `${date.slice(6, 10)}-${date.slice(3, 5)}-${date.slice(0, 2)}`
|
||||||
|
: date;
|
||||||
|
return { ...row, invoice_date: iso };
|
||||||
|
});
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_invoice_import", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
company_id: companyId,
|
||||||
|
rows,
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/invoices/:id/classify", ...requireCoreAuth, requirePermission("invoices.update"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_invoice_classify", {
|
||||||
|
...body,
|
||||||
|
id: Number(c.req.param("id")),
|
||||||
|
tenant_id: tid(c),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
}
|
||||||
14
api/main.ts
14
api/main.ts
|
|
@ -68,6 +68,7 @@ import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pd
|
||||||
import { registerPayrollRoutes } from "./payroll_http.ts";
|
import { registerPayrollRoutes } from "./payroll_http.ts";
|
||||||
import { registerExpenseRoutes } from "./expenses_http.ts";
|
import { registerExpenseRoutes } from "./expenses_http.ts";
|
||||||
import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts";
|
import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts";
|
||||||
|
import { registerInvoiceRoutes } from "./invoices_http.ts";
|
||||||
import { registerWarehouseRoutes } from "./warehouse_http.ts";
|
import { registerWarehouseRoutes } from "./warehouse_http.ts";
|
||||||
import { registerCostControlRoutes } from "./cost_control_http.ts";
|
import { registerCostControlRoutes } from "./cost_control_http.ts";
|
||||||
import { registerWorkProgramRoutes } from "./work_program_http.ts";
|
import { registerWorkProgramRoutes } from "./work_program_http.ts";
|
||||||
|
|
@ -1101,12 +1102,20 @@ app.patch("/v1/workers/:id", ...requireCoreAuth, requirePermission("workers.upda
|
||||||
if (cf) {
|
if (cf) {
|
||||||
return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id });
|
return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id });
|
||||||
}
|
}
|
||||||
return respondRpc(c, await callCoreFn(
|
const updated = await callCoreFn(
|
||||||
db,
|
db,
|
||||||
"core.fn_worker_update",
|
"core.fn_worker_update",
|
||||||
{ id, tenant_id: tid, ...body },
|
{ id, tenant_id: tid, ...body },
|
||||||
{ route },
|
{ 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) => {
|
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);
|
registerPayrollRoutes(app);
|
||||||
registerExpenseRoutes(app);
|
registerExpenseRoutes(app);
|
||||||
registerExpenseCatalogRoutes(app);
|
registerExpenseCatalogRoutes(app);
|
||||||
|
registerInvoiceRoutes(app);
|
||||||
registerWarehouseRoutes(app);
|
registerWarehouseRoutes(app);
|
||||||
registerCostControlRoutes(app);
|
registerCostControlRoutes(app);
|
||||||
registerWorkProgramRoutes(app);
|
registerWorkProgramRoutes(app);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,11 @@ import { requirePermission } from "./permissions.ts";
|
||||||
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
|
import { denyUnlessProjectScope } from "./scope_enforcement.ts";
|
||||||
import { projectById, projectMustBe, type Db } from "./db.ts";
|
import { projectById, projectMustBe, type Db } from "./db.ts";
|
||||||
import { generateLoanReceiptPdf } from "./pdf.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 { fullName } from "./mx.ts";
|
||||||
import { callCoreFn } from "./rpc.ts";
|
import { callCoreFn } from "./rpc.ts";
|
||||||
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
|
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
|
||||||
|
|
@ -462,6 +467,223 @@ export function registerPayrollRoutes(app: App) {
|
||||||
return c.body(csv);
|
return c.body(csv);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/v1/payroll/adjustments", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_payroll_adjustment_list", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
week_id: c.req.query("week_id"),
|
||||||
|
worker_id: c.req.query("worker_id"),
|
||||||
|
project_id: c.req.query("project_id"),
|
||||||
|
kind: c.req.query("kind"),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/payroll/adjustments", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_payroll_adjustment_save", {
|
||||||
|
...body, tenant_id: tid(c),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/v1/payroll/adjustments/:id", ...requireCoreAuth, requirePermission("payroll.delete"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_payroll_adjustment_delete", {
|
||||||
|
id: Number(c.req.param("id")), tenant_id: tid(c),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/payroll/holidays", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_holiday_list", {
|
||||||
|
tenant_id: tid(c), date_from: c.req.query("date_from"), date_to: c.req.query("date_to"),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/payroll/holidays", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_holiday_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/v1/loans/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_loan_update", {
|
||||||
|
...body, id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/loans/:id/statement", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_loan_statement", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/settlements", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_settlement_list", {
|
||||||
|
tenant_id: tid(c), worker_id: c.req.query("worker_id"),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/settlements/:id", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_settlement_get", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/settlements", ...requireCoreAuth, requirePermission("payroll.create"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_settlement_save", { ...body, tenant_id: tid(c) }, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/settlements/:id/pay", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_settlement_pay", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/payroll/weeks/:id/dispersion", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_get", {
|
||||||
|
tenant_id: tid(c), week_id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/payroll/dispersions/:id/cut", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_cut", { id: Number(c.req.param("id")) }, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch("/v1/payroll/dispersion-lines/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_line_update", {
|
||||||
|
...body, line_id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/payroll/dispersions/:id/finalize", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
|
const body = await c.req.json<Record<string, unknown>>().catch(() => ({}));
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_finalize", {
|
||||||
|
...body, id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/v1/payroll/dispersions/:id/attachment", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
|
const id = Number(c.req.param("id"));
|
||||||
|
const form = await c.req.formData();
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return respondApiError(c, "VALIDATION", "Adjunte el comprobante de la dispersión", { route: routeLabel(c) });
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const { iv, cipher } = await encryptBytes(bytes);
|
||||||
|
const storageName = `${Date.now()}-${randomBytes(8).toString("hex")}`;
|
||||||
|
await putObject(dispersionDocKey(id, storageName), cipher);
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_dispersion_attachment_add", {
|
||||||
|
dispersion_id: id,
|
||||||
|
original_name: file.name,
|
||||||
|
mime: file.type || "application/pdf",
|
||||||
|
size_bytes: bytes.length,
|
||||||
|
sha256: "",
|
||||||
|
iv,
|
||||||
|
storage_name: storageName,
|
||||||
|
uploaded_by_name: c.get("user").display_name,
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/payroll/report.xlsx", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn<{ lines: Record<string, unknown>[]; payable_total: number }>(db, "core.fn_payroll_report", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
date_from: c.req.query("date_from"),
|
||||||
|
date_to: c.req.query("date_to"),
|
||||||
|
worker_id: c.req.query("worker_id"),
|
||||||
|
project_id: c.req.query("project_id"),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
if (!env.ok || !env.data) return respondRpc(c, env);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(env.data.lines ?? []), "Nomina");
|
||||||
|
const out = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array;
|
||||||
|
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
c.header("Content-Disposition", `attachment; filename="nomina.xlsx"`);
|
||||||
|
return c.body(out.buffer as ArrayBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/payroll/report.pdf", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn<{ lines: Record<string, unknown>[]; payable_total: number }>(db, "core.fn_payroll_report", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
date_from: c.req.query("date_from"),
|
||||||
|
date_to: c.req.query("date_to"),
|
||||||
|
worker_id: c.req.query("worker_id"),
|
||||||
|
project_id: c.req.query("project_id"),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
if (!env.ok || !env.data) return respondRpc(c, env);
|
||||||
|
const pdf = await PDFDocument.create();
|
||||||
|
const page = pdf.addPage([612, 792]);
|
||||||
|
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
||||||
|
page.drawText(`Nómina ${c.req.query("date_from")} a ${c.req.query("date_to")}`, { x: 40, y: 750, size: 14, font });
|
||||||
|
page.drawText(`Neto ${env.data.payable_total}`, { x: 40, y: 730, size: 11, font });
|
||||||
|
let y = 700;
|
||||||
|
for (const line of (env.data.lines ?? []).slice(0, 30)) {
|
||||||
|
page.drawText(`${line.first_name ?? ""} ${line.last_name_p ?? ""} ${line.payable_net ?? ""}`, { x: 40, y, size: 9, font });
|
||||||
|
y -= 14;
|
||||||
|
if (y < 40) break;
|
||||||
|
}
|
||||||
|
const bytes = await pdf.save();
|
||||||
|
c.header("Content-Type", "application/pdf");
|
||||||
|
c.header("Content-Disposition", `attachment; filename="nomina.pdf"`);
|
||||||
|
return c.body(bytes.buffer as ArrayBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/payroll/imss-quote.xlsx", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
const env = await callCoreFn<{ quotes: Record<string, unknown>[] }>(db, "core.fn_imss_quote", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
worker_id: c.req.query("worker_id"),
|
||||||
|
date_from: c.req.query("date_from"),
|
||||||
|
date_to: c.req.query("date_to"),
|
||||||
|
}, { route: routeLabel(c) });
|
||||||
|
if (!env.ok || !env.data) return respondRpc(c, env);
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(env.data.quotes ?? []), "IMSS");
|
||||||
|
const out = XLSX.write(wb, { type: "buffer", bookType: "xlsx" }) as Uint8Array;
|
||||||
|
c.header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
c.header("Content-Disposition", `attachment; filename="imss.xlsx"`);
|
||||||
|
return c.body(out.buffer as ArrayBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/payroll/report", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_payroll_report", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
date_from: c.req.query("date_from"),
|
||||||
|
date_to: c.req.query("date_to"),
|
||||||
|
worker_id: c.req.query("worker_id"),
|
||||||
|
project_id: c.req.query("project_id"),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/payroll/imss-quote", ...requireCoreAuth, requirePermission("payroll.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_imss_quote", {
|
||||||
|
tenant_id: tid(c),
|
||||||
|
worker_id: c.req.query("worker_id"),
|
||||||
|
date_from: c.req.query("date_from"),
|
||||||
|
date_to: c.req.query("date_to"),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/v1/workers/:id/movements", ...requireCoreAuth, requirePermission("workers.view"), async (c) => {
|
||||||
|
const db = c.get("db");
|
||||||
|
return respondRpc(c, await callCoreFn(db, "core.fn_worker_movement_list", {
|
||||||
|
worker_id: Number(c.req.param("id")),
|
||||||
|
}, { route: routeLabel(c) }));
|
||||||
|
});
|
||||||
|
|
||||||
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
app.patch("/v1/payroll/periods/:id", ...requireCoreAuth, requirePermission("payroll.update"), async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
const { status } = await c.req.json<{ status: string }>();
|
const { status } = await c.req.json<{ status: string }>();
|
||||||
|
|
|
||||||
219
api/pendientes_test.ts
Normal file
219
api/pendientes_test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { assert, assertEquals } from "jsr:@std/assert@1";
|
||||||
|
import { callCoreFn } from "./rpc.ts";
|
||||||
|
import { withTestDb } from "./test_helpers.ts";
|
||||||
|
import type { PgDb } from "./pg.ts";
|
||||||
|
|
||||||
|
const TENANT_ID = 999003;
|
||||||
|
|
||||||
|
async function seed(db: PgDb) {
|
||||||
|
await db.prepare(
|
||||||
|
`INSERT INTO companies (code, name, kind, tenant_id) VALUES ('PNDCO', 'Pend Co', 'principal', ?)`,
|
||||||
|
).run(TENANT_ID);
|
||||||
|
const companyId = await db.lastInsertId();
|
||||||
|
await db.prepare(
|
||||||
|
`INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id)
|
||||||
|
VALUES ('PND-1', 'Obra Pend', 'activo', 'arctec-dos-logos-fold', ?, ?)`,
|
||||||
|
).run(companyId, TENANT_ID);
|
||||||
|
const projectId = await db.lastInsertId();
|
||||||
|
await db.prepare(
|
||||||
|
`INSERT INTO budget_items (project_id, code, description, unit, quantity, unit_price, amount)
|
||||||
|
VALUES (?, 'MAT-1', 'Cemento', 'bulto', 1, 100, 100)`,
|
||||||
|
).run(projectId);
|
||||||
|
await db.prepare(
|
||||||
|
`INSERT INTO workers
|
||||||
|
(first_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
|
||||||
|
hire_type, company_id, position, risk_code, work_type, daily_wage, tenant_id, status)
|
||||||
|
VALUES ('Ana', 'Lopez', 'Diaz', 'CURPPEND0001', 'RFCPEND0001', 'NSSPEND0001', '9991111111',
|
||||||
|
'ana@example.com', 'Calle 1', 'PNDCO', ?, 'Ayudante', 'rojo', 'N', 400, ?, 'activo')`,
|
||||||
|
).run(companyId, TENANT_ID);
|
||||||
|
const workerId = await db.lastInsertId();
|
||||||
|
await db.prepare(
|
||||||
|
`INSERT INTO assignments (worker_id, project_id, active, start_date) VALUES (?, ?, true, DATE '2026-09-21')`,
|
||||||
|
).run(workerId, projectId);
|
||||||
|
return { companyId, projectId, workerId };
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("proveedor: RFC duplicado e inactivo fuera del listado activo", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const created = await callCoreFn(db, "core.fn_supplier_create", {
|
||||||
|
tenant_id: TENANT_ID, name: "Aceros", rfc: "ACE123456AB1", regimen_fiscal: "601", postal_code: "97000",
|
||||||
|
});
|
||||||
|
assertEquals(created.ok, true, JSON.stringify(created));
|
||||||
|
const dup = await callCoreFn(db, "core.fn_supplier_create", {
|
||||||
|
tenant_id: TENANT_ID, name: "Otro", rfc: "ACE123456AB1",
|
||||||
|
});
|
||||||
|
assertEquals(dup.ok, false);
|
||||||
|
const id = (created.data as { supplier: { id: number } }).supplier.id;
|
||||||
|
const off = await callCoreFn(db, "core.fn_supplier_update", { tenant_id: TENANT_ID, id, status: "inactivo" });
|
||||||
|
assertEquals(off.ok, true);
|
||||||
|
const listed = await callCoreFn(db, "core.fn_supplier_list", { tenant_id: TENANT_ID, status: "activo", q: "aceros" });
|
||||||
|
const rows = (listed.data as { suppliers: { name: string }[] }).suppliers;
|
||||||
|
assert(!rows.some((s) => s.name === "Aceros"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("facturas: RFC conocido, desconocido, UUID repetido y un solo gasto", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const fx = await seed(db);
|
||||||
|
await callCoreFn(db, "core.fn_supplier_create", { tenant_id: TENANT_ID, name: "Aceros", rfc: "ACE123456AB1" });
|
||||||
|
const known = await callCoreFn(db, "core.fn_invoice_import", {
|
||||||
|
tenant_id: TENANT_ID,
|
||||||
|
company_id: fx.companyId,
|
||||||
|
rows: [{
|
||||||
|
direction: "recibida", rfc: "ACE123456AB1", supplier_name: "Aceros", uuid: "UUID-1",
|
||||||
|
folio: "10", invoice_date: "2026-09-01", description: "Cemento", amount: 100, iva: 16, total: 116,
|
||||||
|
project_code: "PND-1", item_code: "MAT-1",
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assertEquals(known.ok, true, JSON.stringify(known));
|
||||||
|
const again = await callCoreFn(db, "core.fn_invoice_import", {
|
||||||
|
tenant_id: TENANT_ID,
|
||||||
|
company_id: fx.companyId,
|
||||||
|
rows: [{
|
||||||
|
direction: "recibida", rfc: "ACE123456AB1", uuid: "UUID-1", invoice_date: "2026-09-01", amount: 100,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assertEquals((again.data as { skipped: number }).skipped, 1);
|
||||||
|
const unknown = await callCoreFn(db, "core.fn_invoice_import", {
|
||||||
|
tenant_id: TENANT_ID,
|
||||||
|
company_id: fx.companyId,
|
||||||
|
rows: [{ direction: "recibida", rfc: "XXX123456AB1", uuid: "UUID-2", invoice_date: "2026-09-02", amount: 50, supplier_name: "Nuevo" }],
|
||||||
|
});
|
||||||
|
assertEquals((unknown.data as { review: number }).review, 1);
|
||||||
|
const expenses = await db.prepare(
|
||||||
|
`SELECT COUNT(*)::int AS n FROM expense_entries WHERE source = 'invoice' AND tenant_id = ?`,
|
||||||
|
).get(TENANT_ID) as { n: number };
|
||||||
|
assertEquals(expenses.n, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("nómina: bono, descuento, horas, festivo y semana pagada", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const fx = await seed(db);
|
||||||
|
const week = await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-21" });
|
||||||
|
assertEquals(week.ok, true, JSON.stringify(week));
|
||||||
|
const weekId = (week.data as { week_id: number }).week_id;
|
||||||
|
await callCoreFn(db, "core.fn_holiday_save", { tenant_id: TENANT_ID, holiday_date: "2026-09-21", name: "Puente" });
|
||||||
|
const bono = await callCoreFn(db, "core.fn_payroll_adjustment_save", {
|
||||||
|
tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, project_id: fx.projectId,
|
||||||
|
kind: "bono", concept: "Bono", amount: 100,
|
||||||
|
});
|
||||||
|
assertEquals(bono.ok, true, JSON.stringify(bono));
|
||||||
|
const missing = await callCoreFn(db, "core.fn_payroll_adjustment_save", {
|
||||||
|
tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, kind: "descuento", concept: "Falta", amount: 40,
|
||||||
|
});
|
||||||
|
assertEquals(missing.ok, false);
|
||||||
|
await callCoreFn(db, "core.fn_payroll_adjustment_save", {
|
||||||
|
tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, project_id: fx.projectId,
|
||||||
|
kind: "descuento", concept: "Herramienta", amount: 40, note: "Perdió herramienta",
|
||||||
|
});
|
||||||
|
await callCoreFn(db, "core.fn_payroll_adjustment_save", {
|
||||||
|
tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, project_id: fx.projectId,
|
||||||
|
kind: "horas", concept: "Extra", hours: 8,
|
||||||
|
});
|
||||||
|
const bundle = await callCoreFn(db, "core.fn_payroll_week_bundle", { week_id: weekId, tenant_id: TENANT_ID });
|
||||||
|
assertEquals(bundle.ok, true, JSON.stringify(bundle));
|
||||||
|
await db.prepare(`UPDATE payroll_weeks SET status = 'paid' WHERE id = ?`).run(weekId);
|
||||||
|
const blocked = await callCoreFn(db, "core.fn_payroll_adjustment_save", {
|
||||||
|
tenant_id: TENANT_ID, week_id: weekId, worker_id: fx.workerId, kind: "bono", concept: "Tarde", amount: 1,
|
||||||
|
});
|
||||||
|
assertEquals(blocked.ok, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("préstamo: edición sin pagos y saldo con pagos", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const fx = await seed(db);
|
||||||
|
const created = await callCoreFn(db, "core.fn_loan_create", {
|
||||||
|
tenant_id: TENANT_ID, worker_id: fx.workerId, delivered: 1000, commission_enabled: false,
|
||||||
|
});
|
||||||
|
assertEquals(created.ok, true, JSON.stringify(created));
|
||||||
|
const id = (created.data as { id: number }).id;
|
||||||
|
const edited = await callCoreFn(db, "core.fn_loan_update", { id, delivered: 800, commission_pct: 0, note: "Corregido" });
|
||||||
|
assertEquals(edited.ok, true, JSON.stringify(edited));
|
||||||
|
const week = await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-21" });
|
||||||
|
const weekId = (week.data as { week_id: number }).week_id;
|
||||||
|
await db.prepare(`INSERT INTO loan_payments (loan_id, week_id, amount) VALUES (?, ?, 200)`).run(id, weekId);
|
||||||
|
const statement = await callCoreFn(db, "core.fn_loan_statement", { id });
|
||||||
|
const loan = (statement.data as { loan: { balance: number; delivered: number } }).loan;
|
||||||
|
assertEquals(Number(loan.delivered), 800);
|
||||||
|
assertEquals(Number(loan.balance), 600);
|
||||||
|
const locked = await callCoreFn(db, "core.fn_loan_update", { id, delivered: 50 });
|
||||||
|
const after = await callCoreFn(db, "core.fn_loan_statement", { id });
|
||||||
|
assertEquals(Number((after.data as { loan: { delivered: number } }).loan.delivered), 800);
|
||||||
|
assertEquals(locked.ok, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("finiquito: el neto es líneas menos descuentos y préstamo", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const fx = await seed(db);
|
||||||
|
await callCoreFn(db, "core.fn_loan_create", {
|
||||||
|
tenant_id: TENANT_ID, worker_id: fx.workerId, delivered: 300, commission_enabled: false,
|
||||||
|
});
|
||||||
|
const saved = await callCoreFn(db, "core.fn_settlement_save", {
|
||||||
|
tenant_id: TENANT_ID, worker_id: fx.workerId, company_id: fx.companyId, project_id: fx.projectId,
|
||||||
|
kind: "finiquito", termination_date: "2026-09-24",
|
||||||
|
lines: [{ concept: "Sueldo", amount: 1000, note: "" }],
|
||||||
|
});
|
||||||
|
assertEquals(saved.ok, true, JSON.stringify(saved));
|
||||||
|
const id = (saved.data as { id: number }).id;
|
||||||
|
const got = await callCoreFn(db, "core.fn_settlement_get", { id });
|
||||||
|
const net = Number((got.data as { settlement: { net: number } }).settlement.net);
|
||||||
|
assertEquals(net, 700);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("dispersión: definitiva no se corrige", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const fx = await seed(db);
|
||||||
|
const week = await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-21" });
|
||||||
|
const weekId = (week.data as { week_id: number }).week_id;
|
||||||
|
const opened = await callCoreFn(db, "core.fn_dispersion_get", { tenant_id: TENANT_ID, week_id: weekId });
|
||||||
|
const id = (opened.data as { dispersion: { id: number } }).dispersion.id;
|
||||||
|
await callCoreFn(db, "core.fn_dispersion_cut", { id });
|
||||||
|
const fin = await callCoreFn(db, "core.fn_dispersion_finalize", { id });
|
||||||
|
assertEquals(fin.ok, true);
|
||||||
|
const line = await db.prepare(`SELECT id FROM payroll_dispersion_lines WHERE dispersion_id = ? LIMIT 1`).get(id) as { id: number } | undefined;
|
||||||
|
if (line) {
|
||||||
|
const upd = await callCoreFn(db, "core.fn_dispersion_line_update", { line_id: line.id, amount: 1 });
|
||||||
|
assertEquals(upd.ok, false);
|
||||||
|
}
|
||||||
|
assert(fx.workerId > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("historial: alta, baja y reingreso", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const fx = await seed(db);
|
||||||
|
const alta = await callCoreFn(db, "core.fn_worker_movement_list", { worker_id: fx.workerId });
|
||||||
|
const kinds = (alta.data as { movements: { kind: string }[] }).movements.map((m) => m.kind);
|
||||||
|
assert(kinds.includes("alta"));
|
||||||
|
await callCoreFn(db, "core.fn_worker_set_pipeline", { id: fx.workerId, tenant_id: TENANT_ID, pipeline_status: "baja" });
|
||||||
|
await callCoreFn(db, "core.fn_worker_set_pipeline", { id: fx.workerId, tenant_id: TENANT_ID, pipeline_status: "activo" });
|
||||||
|
const later = await callCoreFn(db, "core.fn_worker_movement_list", { worker_id: fx.workerId });
|
||||||
|
const all = (later.data as { movements: { kind: string }[] }).movements.map((m) => m.kind);
|
||||||
|
assert(all.includes("baja"));
|
||||||
|
assert(all.includes("reingreso"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("reporte mensual e IMSS con jornal distinto", async () => {
|
||||||
|
await withTestDb(async (db) => {
|
||||||
|
const fx = await seed(db);
|
||||||
|
await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-07" });
|
||||||
|
await callCoreFn(db, "core.fn_payroll_week_ensure", { tenant_id: TENANT_ID, week_start: "2026-09-14" });
|
||||||
|
const report = await callCoreFn(db, "core.fn_payroll_report", {
|
||||||
|
tenant_id: TENANT_ID, date_from: "2026-09-01", date_to: "2026-09-30", worker_id: fx.workerId,
|
||||||
|
});
|
||||||
|
assertEquals(report.ok, true, JSON.stringify(report));
|
||||||
|
await callCoreFn(db, "core.fn_worker_imss_wage_set", { id: fx.workerId, imss_daily_wage: 250 });
|
||||||
|
const quote = await callCoreFn(db, "core.fn_imss_quote", {
|
||||||
|
tenant_id: TENANT_ID, worker_id: fx.workerId, date_from: "2026-09-01", date_to: "2026-09-30",
|
||||||
|
});
|
||||||
|
const row = (quote.data as { quotes: { daily_wage: number; imss_daily_wage: number; used_real_wage: boolean }[] }).quotes[0];
|
||||||
|
assertEquals(Number(row.daily_wage), 400);
|
||||||
|
assertEquals(Number(row.imss_daily_wage), 250);
|
||||||
|
assertEquals(row.used_real_wage, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -194,6 +194,9 @@ export function loanReceiptPdfKey(loanId: number, weekId: number): string {
|
||||||
export function expenseDocKey(expenseId: number, storageName: string): string {
|
export function expenseDocKey(expenseId: number, storageName: string): string {
|
||||||
return `expenses/${expenseId}/${storageName}`;
|
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 {
|
export function projectLogoKey(projectId: number, side: "left" | "right", ext: string): string {
|
||||||
return `logos/${projectId}-${side}.${ext}`;
|
return `logos/${projectId}-${side}.${ext}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,5 +51,14 @@
|
||||||
<include file="changesets/039-fn-next-project-code-global.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/039-fn-next-project-code-global.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/040-fn-worker-document-delete.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/040-fn-worker-document-delete.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/041-fn-worker-document-store-replace.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/041-fn-worker-document-store-replace.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/042-suppliers-fiscal.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/043-invoices.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/044-payroll-adjustments.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/045-loan-update.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/046-settlements.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/047-dispersion.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/048-worker-movements.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/049-payroll-report.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/050-imss-wage.sql" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
160
db/core/changesets/042-suppliers-fiscal.sql
Normal file
160
db/core/changesets/042-suppliers-fiscal.sql
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · datos fiscales de proveedores y filtro de listado
|
||||||
|
|
||||||
|
--changeset panel:core-042a-supplier-fiscal-cols endDelimiter:; splitStatements:true
|
||||||
|
ALTER TABLE core.suppliers
|
||||||
|
ADD COLUMN IF NOT EXISTS regimen_fiscal TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
--changeset panel:core-042b-fn-supplier-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_supplier_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_status text := nullif(btrim(payload->>'status'), '');
|
||||||
|
v_q text := lower(coalesce(nullif(btrim(payload->>'q'), ''), ''));
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY lower(s.name), s.id), '[]'::jsonb)
|
||||||
|
INTO v_rows
|
||||||
|
FROM suppliers s
|
||||||
|
WHERE s.tenant_id = v_tid
|
||||||
|
AND (v_status IS NULL OR s.status = v_status)
|
||||||
|
AND (
|
||||||
|
v_q = ''
|
||||||
|
OR lower(s.name) LIKE '%' || v_q || '%'
|
||||||
|
OR lower(s.legal_name) LIKE '%' || v_q || '%'
|
||||||
|
OR lower(s.rfc) LIKE '%' || v_q || '%'
|
||||||
|
);
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('suppliers', v_rows, 'count', jsonb_array_length(v_rows)),
|
||||||
|
format('Catálogo de proveedores: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid),
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_supplier_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-042c-fn-supplier-create splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_supplier_create(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
|
||||||
|
v_legal text := coalesce(nullif(btrim(payload->>'legal_name'), ''), '');
|
||||||
|
v_rfc text := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g'));
|
||||||
|
v_email text := coalesce(nullif(btrim(payload->>'email'), ''), '');
|
||||||
|
v_phone text := coalesce(nullif(btrim(payload->>'phone'), ''), '');
|
||||||
|
v_address text := coalesce(nullif(btrim(payload->>'address'), ''), '');
|
||||||
|
v_city text := coalesce(nullif(btrim(payload->>'city'), ''), '');
|
||||||
|
v_state text := coalesce(nullif(btrim(payload->>'state'), ''), '');
|
||||||
|
v_zip text := coalesce(nullif(btrim(payload->>'postal_code'), ''), '');
|
||||||
|
v_regimen text := coalesce(nullif(btrim(payload->>'regimen_fiscal'), ''), '');
|
||||||
|
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
|
||||||
|
v_status text := coalesce(nullif(btrim(payload->>'status'), ''), 'activo');
|
||||||
|
v_id bigint;
|
||||||
|
v_row jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_name = '' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_supplier_create: el nombre del proveedor es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_create', 'field', 'name'));
|
||||||
|
END IF;
|
||||||
|
IF v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', format('fn_supplier_create: RFC %s no tiene 12 o 13 caracteres', v_rfc),
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_create', 'field', 'rfc'));
|
||||||
|
END IF;
|
||||||
|
IF v_status NOT IN ('activo', 'inactivo') THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_supplier_create: status debe ser activo o inactivo',
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_create', 'field', 'status'));
|
||||||
|
END IF;
|
||||||
|
INSERT INTO suppliers (
|
||||||
|
tenant_id, name, legal_name, rfc, email, phone, address, city, state, postal_code,
|
||||||
|
regimen_fiscal, notes, status
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_name, v_legal, v_rfc, v_email, v_phone, v_address, v_city, v_state, v_zip,
|
||||||
|
v_regimen, v_notes, v_status
|
||||||
|
) RETURNING id INTO v_id;
|
||||||
|
SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id;
|
||||||
|
RETURN core.rpc_created(
|
||||||
|
jsonb_build_object('supplier', v_row),
|
||||||
|
format('Proveedor %s registrado', v_name),
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_create', 'id', v_id, 'tenant_id', v_tid)
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN unique_violation THEN
|
||||||
|
RETURN core.rpc_err('CONFLICT', format('Ya existe un proveedor con RFC %s en este tenant', v_rfc),
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_create', 'rfc', v_rfc));
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_supplier_create', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-042d-fn-supplier-update splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_supplier_update(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_rfc text;
|
||||||
|
v_status text;
|
||||||
|
v_row jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_supplier_update: id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_update', 'field', 'id'));
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM suppliers WHERE id = v_id AND tenant_id = v_tid) THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_supplier_update: proveedor id=%s no encontrado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
v_rfc := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g'));
|
||||||
|
IF payload ? 'rfc' AND v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', format('fn_supplier_update: RFC %s no tiene 12 o 13 caracteres', v_rfc),
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_update', 'field', 'rfc'));
|
||||||
|
END IF;
|
||||||
|
v_status := nullif(btrim(payload->>'status'), '');
|
||||||
|
IF payload ? 'status' AND v_status NOT IN ('activo', 'inactivo') THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_supplier_update: status debe ser activo o inactivo',
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_update', 'field', 'status'));
|
||||||
|
END IF;
|
||||||
|
UPDATE suppliers SET
|
||||||
|
name = CASE WHEN payload ? 'name' THEN coalesce(nullif(btrim(payload->>'name'), ''), name) ELSE name END,
|
||||||
|
legal_name = CASE WHEN payload ? 'legal_name' THEN coalesce(nullif(btrim(payload->>'legal_name'), ''), '') ELSE legal_name END,
|
||||||
|
rfc = CASE WHEN payload ? 'rfc' THEN v_rfc ELSE rfc END,
|
||||||
|
email = CASE WHEN payload ? 'email' THEN coalesce(nullif(btrim(payload->>'email'), ''), '') ELSE email END,
|
||||||
|
phone = CASE WHEN payload ? 'phone' THEN coalesce(nullif(btrim(payload->>'phone'), ''), '') ELSE phone END,
|
||||||
|
address = CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE address END,
|
||||||
|
city = CASE WHEN payload ? 'city' THEN coalesce(nullif(btrim(payload->>'city'), ''), '') ELSE city END,
|
||||||
|
state = CASE WHEN payload ? 'state' THEN coalesce(nullif(btrim(payload->>'state'), ''), '') ELSE state END,
|
||||||
|
postal_code = CASE WHEN payload ? 'postal_code' THEN coalesce(nullif(btrim(payload->>'postal_code'), ''), '') ELSE postal_code END,
|
||||||
|
regimen_fiscal = CASE WHEN payload ? 'regimen_fiscal' THEN coalesce(nullif(btrim(payload->>'regimen_fiscal'), ''), '') ELSE regimen_fiscal END,
|
||||||
|
notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END,
|
||||||
|
status = CASE WHEN payload ? 'status' THEN v_status ELSE status END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_id AND tenant_id = v_tid;
|
||||||
|
SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('supplier', v_row),
|
||||||
|
format('Proveedor id=%s actualizado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN unique_violation THEN
|
||||||
|
RETURN core.rpc_err('CONFLICT', 'Ya existe otro proveedor con ese RFC en este tenant',
|
||||||
|
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id));
|
||||||
|
WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_supplier_update', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
427
db/core/changesets/043-invoices.sql
Normal file
427
db/core/changesets/043-invoices.sql
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · facturas recibidas y emitidas
|
||||||
|
|
||||||
|
--changeset panel:core-043a-expense-source-invoice endDelimiter:; splitStatements:true
|
||||||
|
ALTER TABLE core.expense_entries DROP CONSTRAINT IF EXISTS expense_entries_source_check;
|
||||||
|
ALTER TABLE core.expense_entries
|
||||||
|
ADD CONSTRAINT expense_entries_source_check
|
||||||
|
CHECK (source IN ('manual', 'payroll', 'destajo', 'invoice', 'settlement'));
|
||||||
|
|
||||||
|
--changeset panel:core-043b-invoices endDelimiter:; splitStatements:true
|
||||||
|
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='invoices'
|
||||||
|
CREATE TABLE core.invoices (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
direction TEXT NOT NULL CHECK (direction IN ('recibida', 'emitida')),
|
||||||
|
supplier_id BIGINT REFERENCES core.suppliers(id) ON DELETE SET NULL,
|
||||||
|
supplier_name TEXT NOT NULL DEFAULT '',
|
||||||
|
supplier_rfc TEXT NOT NULL DEFAULT '',
|
||||||
|
uuid TEXT NOT NULL DEFAULT '',
|
||||||
|
serie TEXT NOT NULL DEFAULT '',
|
||||||
|
folio TEXT NOT NULL DEFAULT '',
|
||||||
|
invoice_date DATE NOT NULL,
|
||||||
|
subtotal NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
iva_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'MXN',
|
||||||
|
status TEXT NOT NULL DEFAULT 'registrada' CHECK (status IN ('borrador', 'registrada', 'clasificada', 'anulada')),
|
||||||
|
review_note TEXT NOT NULL DEFAULT '',
|
||||||
|
company_id BIGINT REFERENCES core.companies(id),
|
||||||
|
expense_id BIGINT REFERENCES core.expense_entries(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX idx_core_invoices_uuid
|
||||||
|
ON core.invoices (tenant_id, uuid)
|
||||||
|
WHERE uuid <> '';
|
||||||
|
CREATE INDEX idx_core_invoices_tenant_date ON core.invoices (tenant_id, invoice_date DESC);
|
||||||
|
ALTER TABLE core.invoices ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation ON core.invoices
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.invoices TO panels_core_app;
|
||||||
|
|
||||||
|
CREATE TABLE core.invoice_lines (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
invoice_id BIGINT NOT NULL REFERENCES core.invoices(id) ON DELETE CASCADE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
quantity NUMERIC(14,4) NOT NULL DEFAULT 0,
|
||||||
|
unit TEXT NOT NULL DEFAULT '',
|
||||||
|
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
|
||||||
|
budget_item_id BIGINT REFERENCES core.budget_items(id) ON DELETE SET NULL,
|
||||||
|
project_code TEXT NOT NULL DEFAULT '',
|
||||||
|
item_code TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_core_invoice_lines_invoice ON core.invoice_lines (invoice_id);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.invoice_lines TO panels_core_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
|
||||||
|
|
||||||
|
--changeset panel:core-043c-fn-invoice-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_invoice_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_direction text := coalesce(nullif(btrim(payload->>'direction'), ''), 'recibida');
|
||||||
|
v_supplier_id bigint := NULLIF(payload->>'supplier_id', '')::bigint;
|
||||||
|
v_rfc text := upper(coalesce(nullif(btrim(payload->>'rfc'), ''), ''));
|
||||||
|
v_uuid text := upper(coalesce(nullif(btrim(payload->>'uuid'), ''), ''));
|
||||||
|
v_folio text := coalesce(nullif(btrim(payload->>'folio'), ''), '');
|
||||||
|
v_q text := lower(coalesce(nullif(btrim(payload->>'q'), ''), ''));
|
||||||
|
v_min numeric := NULLIF(payload->>'amount_min', '')::numeric;
|
||||||
|
v_max numeric := NULLIF(payload->>'amount_max', '')::numeric;
|
||||||
|
v_from date := NULLIF(payload->>'date_from', '')::date;
|
||||||
|
v_to date := NULLIF(payload->>'date_to', '')::date;
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_classified text := nullif(btrim(payload->>'classified'), '');
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.invoice_date DESC, x.id DESC), '[]'::jsonb)
|
||||||
|
INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT i.*,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM invoice_lines l
|
||||||
|
WHERE l.invoice_id = i.id AND l.budget_item_id IS NOT NULL
|
||||||
|
) AS has_concept
|
||||||
|
FROM invoices i
|
||||||
|
WHERE i.tenant_id = v_tid
|
||||||
|
AND i.direction = v_direction
|
||||||
|
AND i.status <> 'anulada'
|
||||||
|
AND (v_supplier_id IS NULL OR i.supplier_id = v_supplier_id)
|
||||||
|
AND (v_rfc = '' OR i.supplier_rfc = v_rfc)
|
||||||
|
AND (v_uuid = '' OR i.uuid = v_uuid)
|
||||||
|
AND (v_folio = '' OR i.folio ILIKE '%' || v_folio || '%')
|
||||||
|
AND (v_min IS NULL OR i.total >= v_min)
|
||||||
|
AND (v_max IS NULL OR i.total <= v_max)
|
||||||
|
AND (v_from IS NULL OR i.invoice_date >= v_from)
|
||||||
|
AND (v_to IS NULL OR i.invoice_date <= v_to)
|
||||||
|
AND (
|
||||||
|
v_q = ''
|
||||||
|
OR lower(i.supplier_name) LIKE '%' || v_q || '%'
|
||||||
|
OR lower(i.supplier_rfc) LIKE '%' || v_q || '%'
|
||||||
|
OR lower(i.uuid) LIKE '%' || v_q || '%'
|
||||||
|
OR lower(i.folio) LIKE '%' || v_q || '%'
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
v_project_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM invoice_lines l WHERE l.invoice_id = i.id AND l.project_id = v_project_id)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
v_classified IS NULL
|
||||||
|
OR (v_classified = 'si' AND i.status = 'clasificada')
|
||||||
|
OR (v_classified = 'no' AND i.status <> 'clasificada')
|
||||||
|
)
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('invoices', v_rows, 'count', jsonb_array_length(v_rows)),
|
||||||
|
format('Facturas %s: %s registro(s)', v_direction, jsonb_array_length(v_rows)),
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_list', 'tenant_id', v_tid)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_invoice_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-043d-fn-invoice-get splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_invoice_get(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_inv jsonb;
|
||||||
|
v_lines jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT to_jsonb(i) INTO v_inv FROM invoices i WHERE i.id = v_id;
|
||||||
|
IF v_inv IS NULL THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_invoice_get: factura id=%s no encontrada', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_get', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.id), '[]'::jsonb)
|
||||||
|
INTO v_lines FROM invoice_lines l WHERE l.invoice_id = v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('invoice', v_inv, 'lines', v_lines),
|
||||||
|
format('Factura id=%s cargada', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_get', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_invoice_get', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-043e-fn-invoice-import splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_invoice_import(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
|
||||||
|
v_row jsonb;
|
||||||
|
v_key text;
|
||||||
|
v_groups jsonb := '{}'::jsonb;
|
||||||
|
v_group jsonb;
|
||||||
|
v_invoice_id bigint;
|
||||||
|
v_created integer := 0;
|
||||||
|
v_review integer := 0;
|
||||||
|
v_skipped integer := 0;
|
||||||
|
v_direction text;
|
||||||
|
v_rfc text;
|
||||||
|
v_uuid text;
|
||||||
|
v_serie text;
|
||||||
|
v_folio text;
|
||||||
|
v_date date;
|
||||||
|
v_name text;
|
||||||
|
v_supplier_id bigint;
|
||||||
|
v_supplier_name text;
|
||||||
|
v_status text;
|
||||||
|
v_note text;
|
||||||
|
v_subtotal numeric;
|
||||||
|
v_iva numeric;
|
||||||
|
v_total numeric;
|
||||||
|
v_project_id bigint;
|
||||||
|
v_item_id bigint;
|
||||||
|
v_line jsonb;
|
||||||
|
v_all_classified boolean;
|
||||||
|
v_expense_id bigint;
|
||||||
|
BEGIN
|
||||||
|
IF v_company_id IS NULL OR NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_invoice_import: company_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_import', 'field', 'company_id'));
|
||||||
|
END IF;
|
||||||
|
FOR v_row IN SELECT value FROM jsonb_array_elements(COALESCE(payload->'rows', '[]'::jsonb))
|
||||||
|
LOOP
|
||||||
|
v_direction := lower(coalesce(nullif(btrim(v_row->>'direction'), ''), 'recibida'));
|
||||||
|
IF v_direction IN ('emitida', 'emitidas', 'ingreso') THEN
|
||||||
|
v_direction := 'emitida';
|
||||||
|
ELSE
|
||||||
|
v_direction := 'recibida';
|
||||||
|
END IF;
|
||||||
|
v_rfc := upper(regexp_replace(coalesce(v_row->>'rfc', ''), '\s+', '', 'g'));
|
||||||
|
v_uuid := upper(regexp_replace(coalesce(v_row->>'uuid', ''), '\s+', '', 'g'));
|
||||||
|
v_serie := coalesce(btrim(v_row->>'serie'), '');
|
||||||
|
v_folio := coalesce(btrim(v_row->>'folio'), '');
|
||||||
|
v_date := NULLIF(btrim(v_row->>'invoice_date'), '')::date;
|
||||||
|
IF v_date IS NULL THEN
|
||||||
|
v_skipped := v_skipped + 1;
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
v_key := v_direction || '|' || CASE
|
||||||
|
WHEN v_uuid <> '' THEN 'u:' || v_uuid
|
||||||
|
ELSE 'k:' || v_rfc || '|' || v_folio || '|' || v_date::text
|
||||||
|
END;
|
||||||
|
v_groups := jsonb_set(
|
||||||
|
v_groups,
|
||||||
|
ARRAY[v_key],
|
||||||
|
COALESCE(v_groups->v_key, '[]'::jsonb) || jsonb_build_array(v_row || jsonb_build_object('direction', v_direction, 'rfc', v_rfc, 'uuid', v_uuid)),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
FOR v_key, v_group IN SELECT key, value FROM jsonb_each(v_groups)
|
||||||
|
LOOP
|
||||||
|
v_row := v_group->0;
|
||||||
|
v_direction := v_row->>'direction';
|
||||||
|
v_rfc := v_row->>'rfc';
|
||||||
|
v_uuid := v_row->>'uuid';
|
||||||
|
v_serie := coalesce(v_row->>'serie', '');
|
||||||
|
v_folio := coalesce(v_row->>'folio', '');
|
||||||
|
v_date := (v_row->>'invoice_date')::date;
|
||||||
|
v_name := coalesce(nullif(btrim(v_row->>'supplier_name'), ''), '');
|
||||||
|
v_supplier_id := NULL;
|
||||||
|
v_supplier_name := v_name;
|
||||||
|
v_note := '';
|
||||||
|
v_status := 'registrada';
|
||||||
|
IF v_uuid <> '' AND EXISTS (
|
||||||
|
SELECT 1 FROM invoices WHERE tenant_id = v_tid AND uuid = v_uuid
|
||||||
|
) THEN
|
||||||
|
v_skipped := v_skipped + 1;
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
IF v_rfc <> '' THEN
|
||||||
|
SELECT id, name INTO v_supplier_id, v_supplier_name
|
||||||
|
FROM suppliers
|
||||||
|
WHERE tenant_id = v_tid AND rfc = v_rfc AND status = 'activo'
|
||||||
|
LIMIT 1;
|
||||||
|
IF v_supplier_id IS NULL THEN
|
||||||
|
v_status := 'borrador';
|
||||||
|
v_note := 'RFC sin proveedor en el catálogo';
|
||||||
|
v_review := v_review + 1;
|
||||||
|
IF v_supplier_name = '' THEN
|
||||||
|
v_supplier_name := v_rfc;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
v_subtotal := 0;
|
||||||
|
v_iva := 0;
|
||||||
|
v_total := 0;
|
||||||
|
FOR v_line IN SELECT value FROM jsonb_array_elements(v_group)
|
||||||
|
LOOP
|
||||||
|
v_subtotal := v_subtotal + COALESCE((v_line->>'amount')::numeric, 0);
|
||||||
|
v_iva := v_iva + COALESCE((v_line->>'iva')::numeric, 0);
|
||||||
|
v_total := v_total + COALESCE(NULLIF(v_line->>'total', '')::numeric, COALESCE((v_line->>'amount')::numeric, 0) + COALESCE((v_line->>'iva')::numeric, 0));
|
||||||
|
END LOOP;
|
||||||
|
INSERT INTO invoices (
|
||||||
|
tenant_id, direction, supplier_id, supplier_name, supplier_rfc, uuid, serie, folio,
|
||||||
|
invoice_date, subtotal, iva_amount, total, status, review_note, company_id
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_direction, v_supplier_id, coalesce(v_supplier_name, ''), v_rfc, v_uuid, v_serie, v_folio,
|
||||||
|
v_date, core.round_money(v_subtotal), core.round_money(v_iva), core.round_money(v_total),
|
||||||
|
v_status, v_note, v_company_id
|
||||||
|
) RETURNING id INTO v_invoice_id;
|
||||||
|
v_all_classified := v_direction = 'recibida' AND v_status = 'registrada';
|
||||||
|
FOR v_line IN SELECT value FROM jsonb_array_elements(v_group)
|
||||||
|
LOOP
|
||||||
|
v_project_id := NULL;
|
||||||
|
v_item_id := NULL;
|
||||||
|
IF coalesce(btrim(v_line->>'project_code'), '') <> '' THEN
|
||||||
|
SELECT id INTO v_project_id FROM projects
|
||||||
|
WHERE tenant_id = v_tid AND lower(code) = lower(btrim(v_line->>'project_code'))
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
IF v_project_id IS NOT NULL AND coalesce(btrim(v_line->>'item_code'), '') <> '' THEN
|
||||||
|
SELECT id INTO v_item_id FROM budget_items
|
||||||
|
WHERE project_id = v_project_id AND lower(code) = lower(btrim(v_line->>'item_code'))
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
IF v_project_id IS NULL OR v_item_id IS NULL THEN
|
||||||
|
v_all_classified := false;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO invoice_lines (
|
||||||
|
invoice_id, description, quantity, unit, amount, project_id, budget_item_id, project_code, item_code
|
||||||
|
) VALUES (
|
||||||
|
v_invoice_id,
|
||||||
|
coalesce(nullif(btrim(v_line->>'description'), ''), 'Partida'),
|
||||||
|
COALESCE((v_line->>'quantity')::numeric, 0),
|
||||||
|
coalesce(btrim(v_line->>'unit'), ''),
|
||||||
|
COALESCE((v_line->>'amount')::numeric, 0),
|
||||||
|
v_project_id, v_item_id,
|
||||||
|
coalesce(btrim(v_line->>'project_code'), ''),
|
||||||
|
coalesce(btrim(v_line->>'item_code'), '')
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
IF v_all_classified AND v_status = 'registrada' THEN
|
||||||
|
v_item_id := (SELECT budget_item_id FROM invoice_lines WHERE invoice_id = v_invoice_id AND budget_item_id IS NOT NULL ORDER BY id LIMIT 1);
|
||||||
|
v_project_id := (SELECT project_id FROM invoice_lines WHERE invoice_id = v_invoice_id AND project_id IS NOT NULL ORDER BY id LIMIT 1);
|
||||||
|
INSERT INTO expense_entries (
|
||||||
|
tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
|
||||||
|
supplier_id, supplier_name, supplier_rfc, budget_item_id,
|
||||||
|
subtotal, iva_amount, total, status, notes
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_company_id, v_project_id, v_date, 'invoice', v_invoice_id,
|
||||||
|
format('Factura %s %s', NULLIF(v_serie, ''), COALESCE(NULLIF(v_folio, ''), v_uuid)),
|
||||||
|
v_supplier_id, coalesce(v_supplier_name, ''), v_rfc, v_item_id,
|
||||||
|
core.round_money(v_subtotal), core.round_money(v_iva), core.round_money(v_total),
|
||||||
|
'confirmed', 'Generado al importar factura clasificada'
|
||||||
|
) RETURNING id INTO v_expense_id;
|
||||||
|
UPDATE invoices SET status = 'clasificada', expense_id = v_expense_id WHERE id = v_invoice_id;
|
||||||
|
END IF;
|
||||||
|
v_created := v_created + 1;
|
||||||
|
END LOOP;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('created', v_created, 'review', v_review, 'skipped', v_skipped),
|
||||||
|
format('Importación de facturas: %s creadas, %s en revisión, %s omitidas', v_created, v_review, v_skipped),
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_import', 'created', v_created)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_invoice_import', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-043f-fn-invoice-classify splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_invoice_classify(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._cost_tenant_id(payload);
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
|
||||||
|
v_inv invoices%ROWTYPE;
|
||||||
|
v_expense_id bigint;
|
||||||
|
v_subtotal numeric;
|
||||||
|
v_iva numeric;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_inv FROM invoices WHERE id = v_id AND tenant_id = v_tid;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_invoice_classify: factura id=%s no encontrada', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
IF v_inv.direction <> 'recibida' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_invoice_classify: solo se clasifican facturas recibidas',
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
IF v_inv.status = 'anulada' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_invoice_classify: la factura está anulada',
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
IF v_line_id IS NOT NULL THEN
|
||||||
|
UPDATE invoice_lines
|
||||||
|
SET project_id = COALESCE(v_project_id, project_id),
|
||||||
|
budget_item_id = COALESCE(v_item_id, budget_item_id)
|
||||||
|
WHERE id = v_line_id AND invoice_id = v_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE invoice_lines
|
||||||
|
SET project_id = COALESCE(v_project_id, project_id),
|
||||||
|
budget_item_id = COALESCE(v_item_id, budget_item_id)
|
||||||
|
WHERE invoice_id = v_id;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM invoice_lines WHERE invoice_id = v_id AND (project_id IS NULL OR budget_item_id IS NULL)
|
||||||
|
) OR v_inv.supplier_id IS NULL THEN
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('id', v_id, 'status', v_inv.status),
|
||||||
|
'Factura actualizada; falta proveedor, obra o concepto para generar el gasto',
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
SELECT project_id, budget_item_id INTO v_project_id, v_item_id
|
||||||
|
FROM invoice_lines WHERE invoice_id = v_id ORDER BY id LIMIT 1;
|
||||||
|
v_subtotal := v_inv.subtotal;
|
||||||
|
v_iva := v_inv.iva_amount;
|
||||||
|
IF v_inv.expense_id IS NULL THEN
|
||||||
|
INSERT INTO expense_entries (
|
||||||
|
tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
|
||||||
|
supplier_id, supplier_name, supplier_rfc, budget_item_id,
|
||||||
|
subtotal, iva_amount, total, status, notes
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_inv.company_id, v_project_id, v_inv.invoice_date, 'invoice', v_id,
|
||||||
|
format('Factura %s', COALESCE(NULLIF(v_inv.folio, ''), NULLIF(v_inv.uuid, ''), v_id::text)),
|
||||||
|
v_inv.supplier_id, v_inv.supplier_name, v_inv.supplier_rfc, v_item_id,
|
||||||
|
v_subtotal, v_iva, v_inv.total, 'confirmed', 'Clasificación de factura recibida'
|
||||||
|
) RETURNING id INTO v_expense_id;
|
||||||
|
UPDATE invoices SET status = 'clasificada', expense_id = v_expense_id WHERE id = v_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE expense_entries
|
||||||
|
SET project_id = v_project_id, budget_item_id = v_item_id
|
||||||
|
WHERE id = v_inv.expense_id AND status <> 'void';
|
||||||
|
v_expense_id := v_inv.expense_id;
|
||||||
|
UPDATE invoices SET status = 'clasificada' WHERE id = v_id;
|
||||||
|
END IF;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('id', v_id, 'status', 'clasificada', 'expense_id', v_expense_id),
|
||||||
|
format('Factura id=%s clasificada; gasto id=%s', v_id, v_expense_id),
|
||||||
|
jsonb_build_object('fn', 'fn_invoice_classify', 'id', v_id, 'expense_id', v_expense_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_invoice_classify', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-043g-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_invoice_list(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_invoice_get(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_invoice_import(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_invoice_classify(jsonb) TO panels_core_app;
|
||||||
715
db/core/changesets/044-payroll-adjustments.sql
Normal file
715
db/core/changesets/044-payroll-adjustments.sql
Normal file
|
|
@ -0,0 +1,715 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · bono, descuentos, horas y días festivos
|
||||||
|
|
||||||
|
--changeset panel:core-044a-payroll-adjustments endDelimiter:; splitStatements:true
|
||||||
|
CREATE TABLE IF NOT EXISTS core.holidays (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
holiday_date DATE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
UNIQUE (tenant_id, holiday_date)
|
||||||
|
);
|
||||||
|
ALTER TABLE core.holidays ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON core.holidays;
|
||||||
|
CREATE POLICY tenant_isolation ON core.holidays
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.holidays TO panels_core_app;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS core.payroll_adjustments (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
week_id BIGINT NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
|
||||||
|
worker_id BIGINT NOT NULL REFERENCES core.workers(id),
|
||||||
|
project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('descuento', 'bono', 'horas')),
|
||||||
|
concept TEXT NOT NULL,
|
||||||
|
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
hours NUMERIC(8,2) NOT NULL DEFAULT 0,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
adjustment_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_core_payroll_adjustments_week ON core.payroll_adjustments (week_id, worker_id);
|
||||||
|
ALTER TABLE core.payroll_adjustments ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON core.payroll_adjustments;
|
||||||
|
CREATE POLICY tenant_isolation ON core.payroll_adjustments
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_adjustments TO panels_core_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
|
||||||
|
|
||||||
|
--changeset panel:core-044b-fn-apply-adjustments splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._payroll_apply_adjustments(p_week_id bigint)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_adj record;
|
||||||
|
v_line_id bigint;
|
||||||
|
v_wage numeric;
|
||||||
|
v_extra numeric;
|
||||||
|
BEGIN
|
||||||
|
FOR v_adj IN
|
||||||
|
SELECT * FROM payroll_adjustments WHERE week_id = p_week_id ORDER BY id
|
||||||
|
LOOP
|
||||||
|
SELECT l.id INTO v_line_id
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE s.week_id = p_week_id
|
||||||
|
AND l.worker_id = v_adj.worker_id
|
||||||
|
AND (v_adj.project_id IS NULL OR s.project_id = v_adj.project_id OR l.project_id = v_adj.project_id)
|
||||||
|
ORDER BY CASE WHEN s.project_id IS NOT DISTINCT FROM v_adj.project_id THEN 0 ELSE 1 END, l.gross DESC, l.id
|
||||||
|
LIMIT 1;
|
||||||
|
IF v_line_id IS NULL THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
IF v_adj.kind = 'bono' THEN
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET gross = core.round_money(gross + v_adj.amount),
|
||||||
|
payable_net = core.round_money(payable_net + v_adj.amount)
|
||||||
|
WHERE id = v_line_id;
|
||||||
|
ELSIF v_adj.kind = 'descuento' THEN
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET discounts = core.round_money(discounts + v_adj.amount),
|
||||||
|
payable_net = core.round_money(payable_net - v_adj.amount)
|
||||||
|
WHERE id = v_line_id;
|
||||||
|
ELSIF v_adj.kind = 'horas' THEN
|
||||||
|
SELECT w.daily_wage INTO v_wage FROM workers w WHERE w.id = v_adj.worker_id;
|
||||||
|
v_extra := core.round_money(v_adj.hours * COALESCE(v_wage, 0) / 8);
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET gross = core.round_money(gross + v_extra),
|
||||||
|
payable_net = core.round_money(payable_net + v_extra)
|
||||||
|
WHERE id = v_line_id;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-044c-recalc-week splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._payroll_recalc_week(p_week_id bigint)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_week record;
|
||||||
|
v_sheet record;
|
||||||
|
v_worker record;
|
||||||
|
v_cut record;
|
||||||
|
v_line record;
|
||||||
|
v_loan record;
|
||||||
|
v_line_id bigint;
|
||||||
|
v_att_n integer;
|
||||||
|
v_holiday_n integer;
|
||||||
|
v_wage numeric;
|
||||||
|
v_gross numeric;
|
||||||
|
v_payable_qty numeric;
|
||||||
|
v_required_gross numeric;
|
||||||
|
v_keep bigint[];
|
||||||
|
v_stale record;
|
||||||
|
v_tot numeric;
|
||||||
|
v_worker_id bigint;
|
||||||
|
v_lines record;
|
||||||
|
v_gross_sum numeric;
|
||||||
|
v_remaining numeric;
|
||||||
|
v_placed boolean;
|
||||||
|
v_n integer;
|
||||||
|
v_is_last boolean;
|
||||||
|
v_want numeric;
|
||||||
|
v_take numeric;
|
||||||
|
v_label text;
|
||||||
|
v_host_id bigint;
|
||||||
|
v_host_gross numeric;
|
||||||
|
v_host_discounts numeric;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_week FROM payroll_weeks WHERE id = p_week_id;
|
||||||
|
IF NOT FOUND OR v_week.status = 'paid' THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET loan_id = NULL, loan_discount = 0, loan_label = NULL, discounts = 0
|
||||||
|
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = p_week_id);
|
||||||
|
|
||||||
|
FOR v_sheet IN SELECT * FROM payroll_sheets WHERE week_id = p_week_id LOOP
|
||||||
|
IF v_sheet.kind = 'obra' AND v_sheet.project_id IS NOT NULL THEN
|
||||||
|
FOR v_worker IN
|
||||||
|
SELECT w.id, w.daily_wage
|
||||||
|
FROM workers w
|
||||||
|
WHERE w.status = 'activo'
|
||||||
|
AND w.pipeline_status != 'baja'
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM assignments a
|
||||||
|
WHERE a.worker_id = w.id AND a.project_id = v_sheet.project_id
|
||||||
|
AND a.active = true AND w.work_type = 'N'
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM attendance att
|
||||||
|
WHERE att.worker_id = w.id AND att.project_id = v_sheet.project_id
|
||||||
|
AND att.work_date BETWEEN v_week.week_start AND v_week.week_end
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE l.worker_id = w.id AND s.project_id = v_sheet.project_id
|
||||||
|
AND s.kind = 'obra' AND s.week_id = p_week_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY w.last_name_p, w.first_name
|
||||||
|
LOOP
|
||||||
|
v_line_id := core._payroll_sheet_line(v_sheet.id, v_worker.id, NULL);
|
||||||
|
SELECT COUNT(*)::integer INTO v_att_n
|
||||||
|
FROM attendance
|
||||||
|
WHERE worker_id = v_worker.id
|
||||||
|
AND project_id = v_sheet.project_id
|
||||||
|
AND work_date BETWEEN v_week.week_start AND v_week.week_end
|
||||||
|
AND present = true;
|
||||||
|
SELECT COUNT(*)::integer INTO v_holiday_n
|
||||||
|
FROM holidays h
|
||||||
|
WHERE h.tenant_id = v_week.tenant_id
|
||||||
|
AND h.holiday_date BETWEEN v_week.week_start AND v_week.week_end
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM attendance att
|
||||||
|
WHERE att.worker_id = v_worker.id
|
||||||
|
AND att.project_id = v_sheet.project_id
|
||||||
|
AND att.work_date = h.holiday_date
|
||||||
|
);
|
||||||
|
SELECT daily_wage INTO v_wage FROM payroll_week_lines WHERE id = v_line_id;
|
||||||
|
IF v_week.status = 'assembled' AND COALESCE(v_wage, 0) > 0 THEN
|
||||||
|
NULL;
|
||||||
|
ELSE
|
||||||
|
v_wage := v_worker.daily_wage;
|
||||||
|
END IF;
|
||||||
|
v_gross := core.round_money((v_att_n + v_holiday_n) * v_wage);
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET days = v_att_n + v_holiday_n,
|
||||||
|
daily_wage = v_wage,
|
||||||
|
amount = v_gross,
|
||||||
|
gross = v_gross,
|
||||||
|
discounts = 0,
|
||||||
|
payable_net = v_gross
|
||||||
|
WHERE id = v_line_id;
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_sheet.kind = 'destajo' AND v_sheet.project_id IS NOT NULL THEN
|
||||||
|
SELECT id INTO v_line_id FROM destajo_periods WHERE week_id = p_week_id;
|
||||||
|
IF v_line_id IS NULL THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
v_keep := ARRAY[]::bigint[];
|
||||||
|
FOR v_cut IN
|
||||||
|
SELECT c.id, c.qty_planned, c.qty_actual, c.qty_extra,
|
||||||
|
j.worker_id, j.unit_price, j.unit_code, j.concepto
|
||||||
|
FROM destajo_cut_lines c
|
||||||
|
JOIN destajo_jobs j ON j.id = c.job_id
|
||||||
|
WHERE c.period_id = v_line_id
|
||||||
|
AND j.project_id = v_sheet.project_id
|
||||||
|
LOOP
|
||||||
|
v_line_id := core._payroll_sheet_line(v_sheet.id, v_cut.worker_id, v_cut.id);
|
||||||
|
v_keep := array_append(v_keep, v_line_id);
|
||||||
|
IF v_week.status = 'draft'
|
||||||
|
AND v_cut.qty_actual = 0 AND v_cut.qty_extra = 0 THEN
|
||||||
|
v_payable_qty := core.round_money(v_cut.qty_planned);
|
||||||
|
ELSE
|
||||||
|
v_payable_qty := core.round_money(v_cut.qty_actual + v_cut.qty_extra);
|
||||||
|
END IF;
|
||||||
|
v_gross := core.round_money(v_payable_qty * v_cut.unit_price);
|
||||||
|
v_required_gross := core.round_money(v_cut.qty_planned * v_cut.unit_price);
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET destajo_cut_line_id = v_cut.id,
|
||||||
|
qty_planned = v_cut.qty_planned,
|
||||||
|
qty_actual = v_cut.qty_actual,
|
||||||
|
qty_extra = v_cut.qty_extra,
|
||||||
|
unit_price = v_cut.unit_price,
|
||||||
|
unit_code = v_cut.unit_code,
|
||||||
|
concepto = v_cut.concepto,
|
||||||
|
amount = v_required_gross,
|
||||||
|
gross = v_gross,
|
||||||
|
payable_net = v_gross
|
||||||
|
WHERE id = v_line_id;
|
||||||
|
END LOOP;
|
||||||
|
FOR v_stale IN
|
||||||
|
SELECT id FROM payroll_week_lines WHERE sheet_id = v_sheet.id
|
||||||
|
LOOP
|
||||||
|
IF NOT (v_stale.id = ANY (v_keep)) THEN
|
||||||
|
DELETE FROM payroll_week_lines WHERE id = v_stale.id;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_sheet.kind = 'admin' THEN
|
||||||
|
FOR v_line IN SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id = v_sheet.id LOOP
|
||||||
|
v_gross := core.round_money(v_line.amount);
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET gross = v_gross,
|
||||||
|
payable_net = core.round_money(v_gross - v_line.discounts)
|
||||||
|
WHERE id = v_line.id;
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
PERFORM core._payroll_apply_adjustments(p_week_id);
|
||||||
|
|
||||||
|
FOR v_worker_id IN
|
||||||
|
SELECT DISTINCT l.worker_id
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE s.week_id = p_week_id
|
||||||
|
LOOP
|
||||||
|
SELECT core.round_money(COALESCE(SUM(l.gross - l.discounts), 0))
|
||||||
|
INTO v_gross_sum
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE s.week_id = p_week_id
|
||||||
|
AND l.worker_id = v_worker_id;
|
||||||
|
|
||||||
|
v_remaining := GREATEST(0, v_gross_sum);
|
||||||
|
v_placed := false;
|
||||||
|
|
||||||
|
FOR v_loan IN
|
||||||
|
SELECT *
|
||||||
|
FROM loans
|
||||||
|
WHERE worker_id = v_worker_id
|
||||||
|
AND balance > 0
|
||||||
|
AND first_due IS NOT NULL
|
||||||
|
AND first_due <= v_week.week_end
|
||||||
|
ORDER BY first_due, id
|
||||||
|
LOOP
|
||||||
|
EXIT WHEN v_remaining <= 0;
|
||||||
|
SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id;
|
||||||
|
v_is_last := v_n >= GREATEST(COALESCE(v_loan.installments_n, 1), 1);
|
||||||
|
IF v_is_last THEN
|
||||||
|
v_want := v_loan.balance;
|
||||||
|
ELSE
|
||||||
|
v_want := LEAST(COALESCE(NULLIF(v_loan.weekly_payment, 0), v_loan.balance), v_loan.balance);
|
||||||
|
END IF;
|
||||||
|
v_take := core.round_money(LEAST(v_want, v_remaining));
|
||||||
|
IF v_take <= 0 THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
v_remaining := core.round_money(v_remaining - v_take);
|
||||||
|
IF NOT v_placed THEN
|
||||||
|
SELECT l.id, l.gross, l.discounts
|
||||||
|
INTO v_host_id, v_host_gross, v_host_discounts
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE s.week_id = p_week_id
|
||||||
|
AND l.worker_id = v_worker_id
|
||||||
|
ORDER BY l.gross DESC, l.id
|
||||||
|
LIMIT 1;
|
||||||
|
v_label := core._payroll_loan_condition_label(
|
||||||
|
v_loan.delivered, v_loan.commission_pct, v_take, v_n, v_loan.installments_n
|
||||||
|
);
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET loan_id = v_loan.id,
|
||||||
|
loan_discount = v_take,
|
||||||
|
loan_label = v_label,
|
||||||
|
payable_net = core.round_money(v_host_gross - v_host_discounts - v_take)
|
||||||
|
WHERE id = v_host_id;
|
||||||
|
v_placed := true;
|
||||||
|
END IF;
|
||||||
|
EXIT;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF NOT v_placed THEN
|
||||||
|
UPDATE payroll_week_lines l
|
||||||
|
SET payable_net = core.round_money(l.gross - l.discounts)
|
||||||
|
FROM payroll_sheets s
|
||||||
|
WHERE s.id = l.sheet_id
|
||||||
|
AND s.week_id = p_week_id
|
||||||
|
AND l.worker_id = v_worker_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE payroll_week_lines l
|
||||||
|
SET payable_net = core.round_money(l.gross - l.discounts)
|
||||||
|
FROM payroll_sheets s
|
||||||
|
WHERE s.id = l.sheet_id
|
||||||
|
AND s.week_id = p_week_id
|
||||||
|
AND l.worker_id = v_worker_id
|
||||||
|
AND l.id <> v_host_id;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
SELECT COALESCE(SUM(l.payable_net), 0) INTO v_tot
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE s.week_id = p_week_id;
|
||||||
|
|
||||||
|
UPDATE payroll_weeks
|
||||||
|
SET payable_net = core.round_money(v_tot)
|
||||||
|
WHERE id = p_week_id;
|
||||||
|
|
||||||
|
IF v_week.status = 'draft' THEN
|
||||||
|
UPDATE payroll_weeks
|
||||||
|
SET required_net = core.round_money(v_tot)
|
||||||
|
WHERE id = p_week_id;
|
||||||
|
UPDATE payroll_week_lines
|
||||||
|
SET required_net = payable_net
|
||||||
|
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = p_week_id);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core._payroll_ensure_cut_for_open_jobs(p_week_id bigint)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_period record;
|
||||||
|
v_job record;
|
||||||
|
BEGIN
|
||||||
|
SELECT id, tenant_id INTO v_period FROM destajo_periods WHERE week_id = p_week_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
FOR v_job IN
|
||||||
|
SELECT id FROM destajo_jobs WHERE tenant_id = v_period.tenant_id AND status = 'open'
|
||||||
|
LOOP
|
||||||
|
INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
|
||||||
|
VALUES (v_period.id, v_job.id, 0, 0, 0)
|
||||||
|
ON CONFLICT (period_id, job_id) DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core._payroll_ensure_week(p_tenant_id integer, p_week_start date)
|
||||||
|
RETURNS bigint
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_week_start date;
|
||||||
|
v_week_end date;
|
||||||
|
v_week_id bigint;
|
||||||
|
v_bounds record;
|
||||||
|
v_period_id bigint;
|
||||||
|
v_proj record;
|
||||||
|
BEGIN
|
||||||
|
PERFORM core._payroll_seed_defaults(p_tenant_id);
|
||||||
|
SELECT wc.week_start, wc.week_end
|
||||||
|
INTO v_week_start, v_week_end
|
||||||
|
FROM core._payroll_week_containing(p_week_start) wc;
|
||||||
|
|
||||||
|
SELECT id INTO v_week_id
|
||||||
|
FROM payroll_weeks
|
||||||
|
WHERE tenant_id = p_tenant_id AND week_start = v_week_start;
|
||||||
|
|
||||||
|
IF v_week_id IS NULL THEN
|
||||||
|
INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
|
||||||
|
VALUES (v_week_start, v_week_end, 'draft', p_tenant_id)
|
||||||
|
RETURNING id INTO v_week_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT * INTO v_bounds FROM core._payroll_destajo_period_bounds(v_week_start);
|
||||||
|
SELECT id INTO v_period_id
|
||||||
|
FROM destajo_periods
|
||||||
|
WHERE tenant_id = p_tenant_id AND period_end = v_bounds.period_end;
|
||||||
|
IF v_period_id IS NULL THEN
|
||||||
|
INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
|
||||||
|
VALUES (v_bounds.period_start, v_bounds.period_end, v_week_id, p_tenant_id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
FOR v_proj IN
|
||||||
|
SELECT id FROM projects
|
||||||
|
WHERE status IN ('activo', 'pausado')
|
||||||
|
AND (p_tenant_id = 0 OR tenant_id = p_tenant_id OR tenant_id IS NULL)
|
||||||
|
ORDER BY name
|
||||||
|
LOOP
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM payroll_sheets
|
||||||
|
WHERE week_id = v_week_id AND kind = 'obra' AND COALESCE(project_id, 0) = v_proj.id
|
||||||
|
) THEN
|
||||||
|
INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'obra', v_proj.id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM payroll_sheets
|
||||||
|
WHERE week_id = v_week_id AND kind = 'destajo' AND COALESCE(project_id, 0) = v_proj.id
|
||||||
|
) THEN
|
||||||
|
INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'destajo', v_proj.id);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM payroll_sheets
|
||||||
|
WHERE week_id = v_week_id AND kind = 'admin' AND project_id IS NULL
|
||||||
|
) THEN
|
||||||
|
INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'admin', NULL);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN v_week_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core._payroll_first_due_for_loan(
|
||||||
|
p_delivered numeric,
|
||||||
|
p_small_max numeric,
|
||||||
|
p_plan text,
|
||||||
|
p_grant_iso date,
|
||||||
|
p_week_assembled boolean,
|
||||||
|
p_weekday_mon0 integer
|
||||||
|
)
|
||||||
|
RETURNS date
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
IMMUTABLE
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p_delivered <= p_small_max AND p_plan = 'single' THEN
|
||||||
|
RETURN core._payroll_next_saturday_after_week(p_grant_iso);
|
||||||
|
END IF;
|
||||||
|
IF p_week_assembled AND p_weekday_mon0 >= 4 THEN
|
||||||
|
RETURN core._payroll_next_saturday_after_week(p_grant_iso);
|
||||||
|
END IF;
|
||||||
|
RETURN core._payroll_this_saturday(p_grant_iso);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core._payroll_prefill_thu_sat(
|
||||||
|
p_project_id bigint,
|
||||||
|
p_week_start date,
|
||||||
|
p_worker_id bigint
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_offset integer;
|
||||||
|
v_day date;
|
||||||
|
v_other record;
|
||||||
|
v_existing record;
|
||||||
|
BEGIN
|
||||||
|
FOR v_offset IN SELECT unnest(ARRAY[3, 4, 5]) LOOP
|
||||||
|
v_day := core._payroll_add_days(p_week_start, v_offset);
|
||||||
|
SELECT a.project_id, p.name AS project_name
|
||||||
|
INTO v_other
|
||||||
|
FROM attendance a
|
||||||
|
JOIN projects p ON p.id = a.project_id
|
||||||
|
WHERE a.worker_id = p_worker_id
|
||||||
|
AND a.work_date = v_day
|
||||||
|
AND a.present = true
|
||||||
|
AND a.project_id <> p_project_id
|
||||||
|
LIMIT 1;
|
||||||
|
IF FOUND THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
SELECT id, present INTO v_existing
|
||||||
|
FROM attendance
|
||||||
|
WHERE worker_id = p_worker_id
|
||||||
|
AND project_id = p_project_id
|
||||||
|
AND work_date = v_day;
|
||||||
|
IF FOUND THEN
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
INSERT INTO attendance (worker_id, project_id, work_date, present)
|
||||||
|
VALUES (p_worker_id, p_project_id, v_day, true);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-044d-fn-adjustments splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_save(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
|
||||||
|
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_kind text := nullif(btrim(payload->>'kind'), '');
|
||||||
|
v_concept text := coalesce(nullif(btrim(payload->>'concept'), ''), '');
|
||||||
|
v_note text := coalesce(nullif(btrim(payload->>'note'), ''), '');
|
||||||
|
v_amount numeric := COALESCE((payload->>'amount')::numeric, 0);
|
||||||
|
v_hours numeric := COALESCE((payload->>'hours')::numeric, 0);
|
||||||
|
v_date date := COALESCE(NULLIF(payload->>'adjustment_date', '')::date, CURRENT_DATE);
|
||||||
|
v_status text;
|
||||||
|
BEGIN
|
||||||
|
IF v_week_id IS NULL OR v_worker_id IS NULL OR v_kind IS NULL OR v_concept = '' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: semana, persona, tipo y concepto son obligatorios',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_save'));
|
||||||
|
END IF;
|
||||||
|
IF v_kind NOT IN ('descuento', 'bono', 'horas') THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: tipo inválido',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'kind', v_kind));
|
||||||
|
END IF;
|
||||||
|
IF v_kind = 'descuento' AND v_note = '' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: el descuento requiere observación',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'field', 'note'));
|
||||||
|
END IF;
|
||||||
|
SELECT status INTO v_status FROM payroll_weeks WHERE id = v_week_id AND tenant_id = v_tid;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_payroll_adjustment_save: semana id=%s no encontrada', v_week_id),
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'week_id', v_week_id));
|
||||||
|
END IF;
|
||||||
|
IF v_status = 'paid' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_payroll_adjustment_save: la semana ya está pagada',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'week_id', v_week_id));
|
||||||
|
END IF;
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
INSERT INTO payroll_adjustments (
|
||||||
|
tenant_id, week_id, worker_id, project_id, kind, concept, amount, hours, note, adjustment_date
|
||||||
|
) VALUES (
|
||||||
|
v_tid, v_week_id, v_worker_id, v_project_id, v_kind, v_concept, v_amount, v_hours, v_note, v_date
|
||||||
|
) RETURNING id INTO v_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE payroll_adjustments SET
|
||||||
|
project_id = v_project_id, kind = v_kind, concept = v_concept,
|
||||||
|
amount = v_amount, hours = v_hours, note = v_note, adjustment_date = v_date
|
||||||
|
WHERE id = v_id AND tenant_id = v_tid AND week_id = v_week_id;
|
||||||
|
END IF;
|
||||||
|
PERFORM core._payroll_recalc_week(v_week_id);
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('id', v_id),
|
||||||
|
format('Ajuste de nómina %s guardado', v_kind),
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_save', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_payroll_adjustment_save', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
|
||||||
|
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_kind text := nullif(btrim(payload->>'kind'), '');
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.adjustment_date DESC, x.id DESC), '[]'::jsonb)
|
||||||
|
INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT a.*, w.first_name, w.last_name_p, p.name AS project_name
|
||||||
|
FROM payroll_adjustments a
|
||||||
|
JOIN workers w ON w.id = a.worker_id
|
||||||
|
LEFT JOIN projects p ON p.id = a.project_id
|
||||||
|
WHERE a.tenant_id = v_tid
|
||||||
|
AND (v_week_id IS NULL OR a.week_id = v_week_id)
|
||||||
|
AND (v_worker_id IS NULL OR a.worker_id = v_worker_id)
|
||||||
|
AND (v_project_id IS NULL OR a.project_id = v_project_id)
|
||||||
|
AND (v_kind IS NULL OR a.kind = v_kind)
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('adjustments', v_rows, 'count', jsonb_array_length(v_rows)),
|
||||||
|
format('Ajustes de nómina: %s', jsonb_array_length(v_rows)),
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_list')
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_payroll_adjustment_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_payroll_adjustment_delete(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_week_id bigint;
|
||||||
|
v_status text;
|
||||||
|
BEGIN
|
||||||
|
SELECT a.week_id, wk.status INTO v_week_id, v_status
|
||||||
|
FROM payroll_adjustments a
|
||||||
|
JOIN payroll_weeks wk ON wk.id = a.week_id
|
||||||
|
WHERE a.id = v_id AND a.tenant_id = v_tid;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Ajuste no encontrado',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
IF v_status = 'paid' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'La semana ya está pagada',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'week_id', v_week_id));
|
||||||
|
END IF;
|
||||||
|
DELETE FROM payroll_adjustments WHERE id = v_id;
|
||||||
|
PERFORM core._payroll_recalc_week(v_week_id);
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('id', v_id), 'Ajuste eliminado',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_adjustment_delete', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_payroll_adjustment_delete', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_holiday_save(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_date date := NULLIF(payload->>'holiday_date', '')::date;
|
||||||
|
v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
|
||||||
|
v_id bigint;
|
||||||
|
v_week record;
|
||||||
|
BEGIN
|
||||||
|
IF v_date IS NULL OR v_name = '' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_holiday_save: fecha y nombre son obligatorios',
|
||||||
|
jsonb_build_object('fn', 'fn_holiday_save'));
|
||||||
|
END IF;
|
||||||
|
INSERT INTO holidays (tenant_id, holiday_date, name)
|
||||||
|
VALUES (v_tid, v_date, v_name)
|
||||||
|
ON CONFLICT (tenant_id, holiday_date) DO UPDATE SET name = EXCLUDED.name
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
FOR v_week IN
|
||||||
|
SELECT id FROM payroll_weeks
|
||||||
|
WHERE tenant_id = v_tid AND status <> 'paid'
|
||||||
|
AND v_date BETWEEN week_start AND week_end
|
||||||
|
LOOP
|
||||||
|
PERFORM core._payroll_recalc_week(v_week.id);
|
||||||
|
END LOOP;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('id', v_id), format('Festivo %s registrado', v_name),
|
||||||
|
jsonb_build_object('fn', 'fn_holiday_save', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_holiday_save', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_holiday_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_from date := NULLIF(payload->>'date_from', '')::date;
|
||||||
|
v_to date := NULLIF(payload->>'date_to', '')::date;
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(h) ORDER BY h.holiday_date), '[]'::jsonb)
|
||||||
|
INTO v_rows
|
||||||
|
FROM holidays h
|
||||||
|
WHERE h.tenant_id = v_tid
|
||||||
|
AND (v_from IS NULL OR h.holiday_date >= v_from)
|
||||||
|
AND (v_to IS NULL OR h.holiday_date <= v_to);
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('holidays', v_rows), 'Festivos cargados',
|
||||||
|
jsonb_build_object('fn', 'fn_holiday_list'));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_holiday_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-044e-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core._payroll_apply_adjustments(bigint) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_save(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_list(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_payroll_adjustment_delete(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_holiday_save(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_holiday_list(jsonb) TO panels_core_app;
|
||||||
|
|
||||||
114
db/core/changesets/045-loan-update.sql
Normal file
114
db/core/changesets/045-loan-update.sql
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · edición de préstamos y estado de cuenta
|
||||||
|
|
||||||
|
--changeset panel:core-045a-fn-loan-update splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_loan_update(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_loan loans%ROWTYPE;
|
||||||
|
v_paid numeric;
|
||||||
|
v_n integer;
|
||||||
|
v_delivered numeric;
|
||||||
|
v_pct numeric;
|
||||||
|
v_comm numeric;
|
||||||
|
v_balance numeric;
|
||||||
|
v_weekly numeric;
|
||||||
|
v_note text;
|
||||||
|
BEGIN
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_loan_update: id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_loan_update', 'field', 'id'));
|
||||||
|
END IF;
|
||||||
|
SELECT * INTO v_loan FROM loans WHERE id = v_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', format('fn_loan_update: préstamo id=%s no encontrado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_loan_update', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(SUM(amount), 0), COUNT(*)::integer INTO v_paid, v_n FROM loan_payments WHERE loan_id = v_id;
|
||||||
|
v_note := CASE WHEN payload ? 'note' THEN nullif(btrim(payload->>'note'), '') ELSE v_loan.note END;
|
||||||
|
IF v_n > 0 THEN
|
||||||
|
v_weekly := COALESCE((payload->>'weekly_payment')::numeric, v_loan.weekly_payment);
|
||||||
|
UPDATE loans SET note = v_note, weekly_payment = core.round_money(v_weekly) WHERE id = v_id;
|
||||||
|
ELSE
|
||||||
|
v_delivered := core.round_money(COALESCE((payload->>'delivered')::numeric, v_loan.delivered));
|
||||||
|
IF v_delivered <= 0 THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_loan_update: el monto entregado debe ser mayor a cero',
|
||||||
|
jsonb_build_object('fn', 'fn_loan_update', 'field', 'delivered'));
|
||||||
|
END IF;
|
||||||
|
v_pct := COALESCE((payload->>'commission_pct')::numeric, v_loan.commission_pct);
|
||||||
|
v_comm := core._payroll_commission_amount(v_delivered, v_pct);
|
||||||
|
v_balance := core.round_money(v_delivered + v_comm);
|
||||||
|
v_weekly := COALESCE((payload->>'weekly_payment')::numeric, core.round_money(v_balance / GREATEST(v_loan.installments_n, 1)));
|
||||||
|
UPDATE loans SET
|
||||||
|
amount = v_delivered,
|
||||||
|
delivered = v_delivered,
|
||||||
|
commission_pct = v_pct,
|
||||||
|
commission_amount = v_comm,
|
||||||
|
balance = v_balance,
|
||||||
|
weekly_payment = core.round_money(v_weekly),
|
||||||
|
note = v_note,
|
||||||
|
plan = COALESCE(NULLIF(btrim(payload->>'plan'), ''), plan),
|
||||||
|
installments_n = COALESCE(NULLIF(payload->>'installments_n', '')::integer, installments_n),
|
||||||
|
first_due = COALESCE(NULLIF(payload->>'first_due', '')::date, first_due)
|
||||||
|
WHERE id = v_id;
|
||||||
|
END IF;
|
||||||
|
UPDATE loans SET balance = core.round_money(delivered + commission_amount - v_paid) WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('id', v_id),
|
||||||
|
format('Préstamo id=%s actualizado', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_loan_update', 'id', v_id, 'payments', v_n)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_loan_update', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-045b-fn-loan-statement splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_loan_statement(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_loan jsonb;
|
||||||
|
v_payments jsonb;
|
||||||
|
v_paid numeric;
|
||||||
|
BEGIN
|
||||||
|
SELECT to_jsonb(l) INTO v_loan FROM loans l WHERE l.id = v_id;
|
||||||
|
IF v_loan IS NULL THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Préstamo no encontrado',
|
||||||
|
jsonb_build_object('fn', 'fn_loan_statement', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(SUM(amount), 0) INTO v_paid FROM loan_payments WHERE loan_id = v_id;
|
||||||
|
UPDATE loans SET balance = core.round_money(
|
||||||
|
(v_loan->>'delivered')::numeric + (v_loan->>'commission_amount')::numeric - v_paid
|
||||||
|
) WHERE id = v_id;
|
||||||
|
SELECT to_jsonb(l) INTO v_loan FROM loans l WHERE l.id = v_id;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.created_at, x.id), '[]'::jsonb)
|
||||||
|
INTO v_payments
|
||||||
|
FROM (
|
||||||
|
SELECT p.id, p.amount, p.label, p.installment_n, p.created_at, wk.week_start, wk.week_end
|
||||||
|
FROM loan_payments p
|
||||||
|
JOIN payroll_weeks wk ON wk.id = p.week_id
|
||||||
|
WHERE p.loan_id = v_id
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('loan', v_loan, 'payments', v_payments, 'paid', v_paid),
|
||||||
|
format('Estado de cuenta del préstamo id=%s', v_id),
|
||||||
|
jsonb_build_object('fn', 'fn_loan_statement', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_loan_statement', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-045c-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_loan_update(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_loan_statement(jsonb) TO panels_core_app;
|
||||||
230
db/core/changesets/046-settlements.sql
Normal file
230
db/core/changesets/046-settlements.sql
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · finiquitos y liquidaciones
|
||||||
|
|
||||||
|
--changeset panel:core-046a-tables endDelimiter:; splitStatements:true
|
||||||
|
CREATE TABLE IF NOT EXISTS core.settlements (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
worker_id BIGINT NOT NULL REFERENCES core.workers(id),
|
||||||
|
project_id BIGINT REFERENCES core.projects(id) ON DELETE SET NULL,
|
||||||
|
company_id BIGINT REFERENCES core.companies(id),
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('finiquito', 'liquidacion')),
|
||||||
|
termination_date DATE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'borrador' CHECK (status IN ('borrador', 'pagado')),
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
lines_total NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
pending_discounts NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
loan_balance NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
net NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
expense_id BIGINT REFERENCES core.expense_entries(id) ON DELETE SET NULL,
|
||||||
|
paid_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_core_settlements_worker ON core.settlements (tenant_id, worker_id);
|
||||||
|
ALTER TABLE core.settlements ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON core.settlements;
|
||||||
|
CREATE POLICY tenant_isolation ON core.settlements
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS core.settlement_lines (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
settlement_id BIGINT NOT NULL REFERENCES core.settlements(id) ON DELETE CASCADE,
|
||||||
|
concept TEXT NOT NULL,
|
||||||
|
base_amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
note TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.settlements TO panels_core_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.settlement_lines TO panels_core_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
|
||||||
|
|
||||||
|
--changeset panel:core-046b-fn-settlement splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core._settlement_recalc(p_id bigint)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_s settlements%ROWTYPE;
|
||||||
|
v_lines numeric;
|
||||||
|
v_disc numeric;
|
||||||
|
v_loans numeric;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_s FROM settlements WHERE id = p_id;
|
||||||
|
SELECT COALESCE(SUM(amount), 0) INTO v_lines FROM settlement_lines WHERE settlement_id = p_id;
|
||||||
|
SELECT COALESCE(SUM(a.amount), 0) INTO v_disc
|
||||||
|
FROM payroll_adjustments a
|
||||||
|
JOIN payroll_weeks w ON w.id = a.week_id
|
||||||
|
WHERE a.worker_id = v_s.worker_id
|
||||||
|
AND a.tenant_id = v_s.tenant_id
|
||||||
|
AND a.kind = 'descuento'
|
||||||
|
AND w.status <> 'paid';
|
||||||
|
SELECT COALESCE(SUM(balance), 0) INTO v_loans FROM loans WHERE worker_id = v_s.worker_id AND balance > 0;
|
||||||
|
UPDATE settlements SET
|
||||||
|
lines_total = core.round_money(v_lines),
|
||||||
|
pending_discounts = core.round_money(v_disc),
|
||||||
|
loan_balance = core.round_money(v_loans),
|
||||||
|
net = core.round_money(v_lines - v_disc - v_loans)
|
||||||
|
WHERE id = p_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_settlement_save(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
|
||||||
|
v_kind text := coalesce(nullif(btrim(payload->>'kind'), ''), 'finiquito');
|
||||||
|
v_date date := NULLIF(payload->>'termination_date', '')::date;
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
|
||||||
|
BEGIN
|
||||||
|
IF v_kind NOT IN ('finiquito', 'liquidacion') OR v_worker_id IS NULL OR v_date IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_settlement_save: persona, tipo y fecha de baja son obligatorios',
|
||||||
|
jsonb_build_object('fn', 'fn_settlement_save'));
|
||||||
|
END IF;
|
||||||
|
IF v_id IS NULL THEN
|
||||||
|
INSERT INTO settlements (tenant_id, worker_id, project_id, company_id, kind, termination_date, notes)
|
||||||
|
VALUES (v_tid, v_worker_id, v_project_id, v_company_id, v_kind, v_date, coalesce(payload->>'notes', ''))
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
ELSE
|
||||||
|
UPDATE settlements SET
|
||||||
|
project_id = v_project_id,
|
||||||
|
company_id = v_company_id,
|
||||||
|
kind = v_kind,
|
||||||
|
termination_date = v_date,
|
||||||
|
notes = coalesce(payload->>'notes', notes)
|
||||||
|
WHERE id = v_id AND tenant_id = v_tid AND status = 'borrador';
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_settlement_save: el finiquito no está en borrador',
|
||||||
|
jsonb_build_object('fn', 'fn_settlement_save', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
IF jsonb_typeof(payload->'lines') = 'array' THEN
|
||||||
|
DELETE FROM settlement_lines WHERE settlement_id = v_id;
|
||||||
|
INSERT INTO settlement_lines (settlement_id, concept, base_amount, amount, note)
|
||||||
|
SELECT v_id,
|
||||||
|
coalesce(nullif(btrim(l->>'concept'), ''), 'Concepto'),
|
||||||
|
COALESCE((l->>'base_amount')::numeric, 0),
|
||||||
|
COALESCE((l->>'amount')::numeric, 0),
|
||||||
|
coalesce(l->>'note', '')
|
||||||
|
FROM jsonb_array_elements(payload->'lines') l;
|
||||||
|
END IF;
|
||||||
|
PERFORM core._settlement_recalc(v_id);
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('id', v_id), 'Finiquito guardado',
|
||||||
|
jsonb_build_object('fn', 'fn_settlement_save', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_settlement_save', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_settlement_get(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_row jsonb;
|
||||||
|
v_lines jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM core._settlement_recalc(v_id);
|
||||||
|
SELECT to_jsonb(s) INTO v_row FROM settlements s WHERE s.id = v_id;
|
||||||
|
IF v_row IS NULL THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Finiquito no encontrado',
|
||||||
|
jsonb_build_object('fn', 'fn_settlement_get', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.id), '[]'::jsonb)
|
||||||
|
INTO v_lines FROM settlement_lines l WHERE l.settlement_id = v_id;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('settlement', v_row, 'lines', v_lines),
|
||||||
|
'Finiquito cargado', jsonb_build_object('fn', 'fn_settlement_get', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_settlement_get', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_settlement_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.termination_date DESC, x.id DESC), '[]'::jsonb)
|
||||||
|
INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT s.*, w.first_name, w.last_name_p
|
||||||
|
FROM settlements s
|
||||||
|
JOIN workers w ON w.id = s.worker_id
|
||||||
|
WHERE s.tenant_id = v_tid
|
||||||
|
AND (v_worker_id IS NULL OR s.worker_id = v_worker_id)
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('settlements', v_rows), 'Finiquitos listados',
|
||||||
|
jsonb_build_object('fn', 'fn_settlement_list'));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_settlement_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_settlement_pay(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_s settlements%ROWTYPE;
|
||||||
|
v_company_id bigint;
|
||||||
|
v_expense_id bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_s FROM settlements WHERE id = NULLIF(payload->>'id', '')::bigint;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Finiquito no encontrado', jsonb_build_object('fn', 'fn_settlement_pay'));
|
||||||
|
END IF;
|
||||||
|
IF v_s.status = 'pagado' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'El finiquito ya está pagado', jsonb_build_object('fn', 'fn_settlement_pay', 'id', v_s.id));
|
||||||
|
END IF;
|
||||||
|
PERFORM core._settlement_recalc(v_s.id);
|
||||||
|
SELECT * INTO v_s FROM settlements WHERE id = v_s.id;
|
||||||
|
v_company_id := COALESCE(v_s.company_id, (SELECT company_id FROM projects WHERE id = v_s.project_id), (SELECT company_id FROM workers WHERE id = v_s.worker_id));
|
||||||
|
IF v_company_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'Indique la empresa para registrar el gasto del finiquito',
|
||||||
|
jsonb_build_object('fn', 'fn_settlement_pay', 'field', 'company_id'));
|
||||||
|
END IF;
|
||||||
|
INSERT INTO expense_entries (
|
||||||
|
tenant_id, company_id, project_id, expense_date, source, source_ref_id, description,
|
||||||
|
subtotal, iva_amount, total, iva_rate, status, notes
|
||||||
|
) VALUES (
|
||||||
|
v_s.tenant_id, v_company_id, v_s.project_id, v_s.termination_date, 'settlement', v_s.id,
|
||||||
|
format('%s trabajador %s', v_s.kind, v_s.worker_id),
|
||||||
|
v_s.net, 0, v_s.net, 0, 'confirmed', 'Pago de finiquito o liquidación'
|
||||||
|
) RETURNING id INTO v_expense_id;
|
||||||
|
UPDATE settlements SET status = 'pagado', paid_at = now(), expense_id = v_expense_id, company_id = v_company_id
|
||||||
|
WHERE id = v_s.id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('id', v_s.id, 'net', v_s.net, 'expense_id', v_expense_id),
|
||||||
|
format('Finiquito id=%s pagado por %s', v_s.id, v_s.net),
|
||||||
|
jsonb_build_object('fn', 'fn_settlement_pay', 'id', v_s.id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_settlement_pay', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-046c-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core._settlement_recalc(bigint) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_settlement_save(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_settlement_get(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_settlement_list(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_settlement_pay(jsonb) TO panels_core_app;
|
||||||
239
db/core/changesets/047-dispersion.sql
Normal file
239
db/core/changesets/047-dispersion.sql
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · dispersión de nómina
|
||||||
|
|
||||||
|
--changeset panel:core-047a-tables endDelimiter:; splitStatements:true
|
||||||
|
CREATE TABLE IF NOT EXISTS core.payroll_dispersions (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER NOT NULL,
|
||||||
|
week_id BIGINT NOT NULL REFERENCES core.payroll_weeks(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'abierta' CHECK (status IN ('abierta', 'corte', 'definitiva')),
|
||||||
|
net NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (week_id)
|
||||||
|
);
|
||||||
|
ALTER TABLE core.payroll_dispersions ENABLE ROW LEVEL SECURITY;
|
||||||
|
DROP POLICY IF EXISTS tenant_isolation ON core.payroll_dispersions;
|
||||||
|
CREATE POLICY tenant_isolation ON core.payroll_dispersions
|
||||||
|
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
|
||||||
|
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS core.payroll_dispersion_lines (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
dispersion_id BIGINT NOT NULL REFERENCES core.payroll_dispersions(id) ON DELETE CASCADE,
|
||||||
|
worker_id BIGINT NOT NULL REFERENCES core.workers(id),
|
||||||
|
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
UNIQUE (dispersion_id, worker_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS core.payroll_dispersion_attachments (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
dispersion_id BIGINT NOT NULL REFERENCES core.payroll_dispersions(id) ON DELETE CASCADE,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
mime TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
sha256 TEXT NOT NULL,
|
||||||
|
iv TEXT NOT NULL,
|
||||||
|
storage_name TEXT NOT NULL,
|
||||||
|
uploaded_by_name TEXT NOT NULL DEFAULT '',
|
||||||
|
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersions TO panels_core_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersion_lines TO panels_core_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.payroll_dispersion_attachments TO panels_core_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
|
||||||
|
|
||||||
|
--changeset panel:core-047b-fn-dispersion splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_dispersion_get(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
|
||||||
|
v_id bigint;
|
||||||
|
v_row jsonb;
|
||||||
|
v_lines jsonb;
|
||||||
|
v_files jsonb;
|
||||||
|
v_net numeric;
|
||||||
|
BEGIN
|
||||||
|
IF v_week_id IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_dispersion_get: week_id es obligatorio',
|
||||||
|
jsonb_build_object('fn', 'fn_dispersion_get'));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(SUM(l.payable_net), 0) INTO v_net
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE s.week_id = v_week_id;
|
||||||
|
INSERT INTO payroll_dispersions (tenant_id, week_id, net)
|
||||||
|
VALUES (v_tid, v_week_id, core.round_money(v_net))
|
||||||
|
ON CONFLICT (week_id) DO UPDATE SET updated_at = now()
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
SELECT to_jsonb(d) INTO v_row FROM payroll_dispersions d WHERE d.id = v_id;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.last_name_p), '[]'::jsonb) INTO v_lines
|
||||||
|
FROM (
|
||||||
|
SELECT dl.*, w.first_name, w.last_name_p
|
||||||
|
FROM payroll_dispersion_lines dl
|
||||||
|
JOIN workers w ON w.id = dl.worker_id
|
||||||
|
WHERE dl.dispersion_id = v_id
|
||||||
|
) x;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.uploaded_at), '[]'::jsonb) INTO v_files
|
||||||
|
FROM payroll_dispersion_attachments a WHERE a.dispersion_id = v_id;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('dispersion', v_row, 'lines', v_lines, 'attachments', v_files, 'week_net', v_net),
|
||||||
|
'Dispersión cargada', jsonb_build_object('fn', 'fn_dispersion_get', 'id', v_id)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_dispersion_get', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_dispersion_cut(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_d payroll_dispersions%ROWTYPE;
|
||||||
|
v_week_status text;
|
||||||
|
BEGIN
|
||||||
|
SELECT * INTO v_d FROM payroll_dispersions WHERE id = v_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
IF v_d.status = 'definitiva' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'La dispersión ya es definitiva', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_d.week_id;
|
||||||
|
DELETE FROM payroll_dispersion_lines WHERE dispersion_id = v_id;
|
||||||
|
INSERT INTO payroll_dispersion_lines (dispersion_id, worker_id, amount)
|
||||||
|
SELECT v_id, l.worker_id, core.round_money(SUM(l.payable_net))
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
WHERE s.week_id = v_d.week_id
|
||||||
|
GROUP BY l.worker_id;
|
||||||
|
UPDATE payroll_dispersions SET
|
||||||
|
status = 'corte',
|
||||||
|
net = (SELECT COALESCE(SUM(amount), 0) FROM payroll_dispersion_lines WHERE dispersion_id = v_id),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'week_status', v_week_status),
|
||||||
|
'Corte semanal de dispersión aplicado', jsonb_build_object('fn', 'fn_dispersion_cut', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_dispersion_cut', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_dispersion_line_update(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
|
||||||
|
v_amount numeric := (payload->>'amount')::numeric;
|
||||||
|
v_status text;
|
||||||
|
v_week_status text;
|
||||||
|
v_disp_id bigint;
|
||||||
|
BEGIN
|
||||||
|
SELECT d.status, w.status, d.id INTO v_status, v_week_status, v_disp_id
|
||||||
|
FROM payroll_dispersion_lines l
|
||||||
|
JOIN payroll_dispersions d ON d.id = l.dispersion_id
|
||||||
|
JOIN payroll_weeks w ON w.id = d.week_id
|
||||||
|
WHERE l.id = v_line_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Línea de dispersión no encontrada',
|
||||||
|
jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
|
||||||
|
END IF;
|
||||||
|
IF v_status = 'definitiva' OR v_week_status = 'paid' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'La dispersión ya no se puede corregir',
|
||||||
|
jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
|
||||||
|
END IF;
|
||||||
|
UPDATE payroll_dispersion_lines
|
||||||
|
SET amount = core.round_money(COALESCE(v_amount, amount)),
|
||||||
|
note = CASE WHEN payload ? 'note' THEN coalesce(payload->>'note', '') ELSE note END
|
||||||
|
WHERE id = v_line_id;
|
||||||
|
UPDATE payroll_dispersions
|
||||||
|
SET net = (SELECT COALESCE(SUM(amount), 0) FROM payroll_dispersion_lines WHERE dispersion_id = v_disp_id),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = v_disp_id;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('id', v_line_id), 'Línea de dispersión actualizada',
|
||||||
|
jsonb_build_object('fn', 'fn_dispersion_line_update', 'line_id', v_line_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_dispersion_line_update', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_dispersion_finalize(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_status text;
|
||||||
|
BEGIN
|
||||||
|
SELECT status INTO v_status FROM payroll_dispersions WHERE id = v_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada', jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
IF v_status = 'abierta' THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'Aplique el corte semanal antes de dejar la dispersión definitiva',
|
||||||
|
jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
UPDATE payroll_dispersions SET status = 'definitiva', note = coalesce(payload->>'note', note), updated_at = now()
|
||||||
|
WHERE id = v_id;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'status', 'definitiva'),
|
||||||
|
'Dispersión definitiva', jsonb_build_object('fn', 'fn_dispersion_finalize', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_dispersion_finalize', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_dispersion_attachment_add(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'dispersion_id', '')::bigint;
|
||||||
|
v_att_id bigint;
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM payroll_dispersions WHERE id = v_id) THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Dispersión no encontrada',
|
||||||
|
jsonb_build_object('fn', 'fn_dispersion_attachment_add', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
INSERT INTO payroll_dispersion_attachments (
|
||||||
|
dispersion_id, original_name, mime, size_bytes, sha256, iv, storage_name, uploaded_by_name
|
||||||
|
) VALUES (
|
||||||
|
v_id,
|
||||||
|
coalesce(payload->>'original_name', 'comprobante'),
|
||||||
|
coalesce(payload->>'mime', 'application/pdf'),
|
||||||
|
COALESCE((payload->>'size_bytes')::bigint, 0),
|
||||||
|
coalesce(payload->>'sha256', ''),
|
||||||
|
coalesce(payload->>'iv', ''),
|
||||||
|
coalesce(payload->>'storage_name', ''),
|
||||||
|
coalesce(payload->>'uploaded_by_name', '')
|
||||||
|
) RETURNING id INTO v_att_id;
|
||||||
|
RETURN core.rpc_created(jsonb_build_object('id', v_att_id), 'Comprobante de dispersión anexado',
|
||||||
|
jsonb_build_object('fn', 'fn_dispersion_attachment_add', 'id', v_att_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_dispersion_attachment_add', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-047c-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_dispersion_get(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_dispersion_cut(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_dispersion_line_update(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_dispersion_finalize(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_dispersion_attachment_add(jsonb) TO panels_core_app;
|
||||||
104
db/core/changesets/048-worker-movements.sql
Normal file
104
db/core/changesets/048-worker-movements.sql
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · historial de alta, baja y reingreso
|
||||||
|
|
||||||
|
--changeset panel:core-048a-table endDelimiter:; splitStatements:true
|
||||||
|
ALTER TABLE core.workers ADD COLUMN IF NOT EXISTS imss_daily_wage NUMERIC(12,2);
|
||||||
|
CREATE TABLE IF NOT EXISTS core.worker_movements (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
tenant_id INTEGER,
|
||||||
|
worker_id BIGINT NOT NULL REFERENCES core.workers(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('alta', 'baja', 'reingreso', 'alta_imss', 'baja_imss', 'modificacion_salario')),
|
||||||
|
movement_date DATE NOT NULL,
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
actor_name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_core_worker_movements_worker ON core.worker_movements (worker_id, movement_date, id);
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON core.worker_movements TO panels_core_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
|
||||||
|
|
||||||
|
--changeset panel:core-048b-backfill endDelimiter:; splitStatements:true
|
||||||
|
INSERT INTO core.worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
SELECT w.tenant_id, w.id, 'alta', COALESCE(w.created_at::date, CURRENT_DATE), 'Alta de padrón'
|
||||||
|
FROM core.workers w
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM core.worker_movements m WHERE m.worker_id = w.id AND m.kind = 'alta'
|
||||||
|
);
|
||||||
|
INSERT INTO core.worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
SELECT w.tenant_id, w.id, 'reingreso', w.last_rehire_at, 'Reingreso'
|
||||||
|
FROM core.workers w
|
||||||
|
WHERE w.last_rehire_at IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM core.worker_movements m WHERE m.worker_id = w.id AND m.kind = 'reingreso' AND m.movement_date = w.last_rehire_at
|
||||||
|
);
|
||||||
|
INSERT INTO core.worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
SELECT w.tenant_id, w.id, 'baja', COALESCE(w.updated_at::date, CURRENT_DATE), 'Baja de padrón'
|
||||||
|
FROM core.workers w
|
||||||
|
WHERE w.status = 'baja'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM core.worker_movements m WHERE m.worker_id = w.id AND m.kind = 'baja');
|
||||||
|
|
||||||
|
--changeset panel:core-048c-trigger splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.trg_worker_movement()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
VALUES (NEW.tenant_id, NEW.id, 'alta', COALESCE(NEW.created_at::date, CURRENT_DATE), 'Alta de padrón');
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
IF OLD.status IS DISTINCT FROM 'baja' AND NEW.status = 'baja' THEN
|
||||||
|
INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
VALUES (NEW.tenant_id, NEW.id, 'baja', CURRENT_DATE, 'Baja de padrón');
|
||||||
|
ELSIF OLD.status = 'baja' AND NEW.status = 'activo' THEN
|
||||||
|
INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
VALUES (NEW.tenant_id, NEW.id, 'reingreso', COALESCE(NEW.last_rehire_at, CURRENT_DATE), 'Reingreso');
|
||||||
|
END IF;
|
||||||
|
IF OLD.imss_status IS DISTINCT FROM 'alta' AND NEW.imss_status = 'alta' THEN
|
||||||
|
INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
VALUES (NEW.tenant_id, NEW.id, 'alta_imss', COALESCE(NEW.imss_alta_at, CURRENT_DATE), 'Alta IMSS');
|
||||||
|
ELSIF OLD.imss_status IS DISTINCT FROM 'baja_imss' AND NEW.imss_status = 'baja_imss' THEN
|
||||||
|
INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
VALUES (NEW.tenant_id, NEW.id, 'baja_imss', COALESCE(NEW.imss_baja_at, CURRENT_DATE), 'Baja IMSS');
|
||||||
|
END IF;
|
||||||
|
IF NEW.imss_daily_wage IS NOT NULL AND NEW.imss_daily_wage IS DISTINCT FROM OLD.imss_daily_wage THEN
|
||||||
|
INSERT INTO worker_movements (tenant_id, worker_id, kind, movement_date, note)
|
||||||
|
VALUES (NEW.tenant_id, NEW.id, 'modificacion_salario', CURRENT_DATE,
|
||||||
|
format('Jornal IMSS %s', NEW.imss_daily_wage));
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_worker_movement ON core.workers;
|
||||||
|
CREATE TRIGGER trg_worker_movement
|
||||||
|
AFTER INSERT OR UPDATE OF status, imss_status, imss_daily_wage ON core.workers
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION core.trg_worker_movement();
|
||||||
|
|
||||||
|
--changeset panel:core-048d-fn-list splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_worker_movement_list(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(m) ORDER BY m.movement_date, m.id), '[]'::jsonb)
|
||||||
|
INTO v_rows
|
||||||
|
FROM worker_movements m
|
||||||
|
WHERE m.worker_id = v_worker_id;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('movements', v_rows), 'Historial de movimientos',
|
||||||
|
jsonb_build_object('fn', 'fn_worker_movement_list', 'worker_id', v_worker_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_worker_movement_list', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-048e-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.trg_worker_movement() TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_worker_movement_list(jsonb) TO panels_core_app;
|
||||||
81
db/core/changesets/049-payroll-report.sql
Normal file
81
db/core/changesets/049-payroll-report.sql
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · reporte de nómina por periodo
|
||||||
|
|
||||||
|
--changeset panel:core-049a-fn-payroll-report splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_payroll_report(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_from date := NULLIF(payload->>'date_from', '')::date;
|
||||||
|
v_to date := NULLIF(payload->>'date_to', '')::date;
|
||||||
|
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
|
||||||
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||||
|
v_lines jsonb;
|
||||||
|
v_adjustments jsonb;
|
||||||
|
v_loans jsonb;
|
||||||
|
v_settlements jsonb;
|
||||||
|
v_total numeric;
|
||||||
|
BEGIN
|
||||||
|
IF v_from IS NULL OR v_to IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_payroll_report: date_from y date_to son obligatorios',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_report'));
|
||||||
|
END IF;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.week_start, x.last_name_p), '[]'::jsonb),
|
||||||
|
COALESCE(SUM(x.payable_net), 0)
|
||||||
|
INTO v_lines, v_total
|
||||||
|
FROM (
|
||||||
|
SELECT l.id, l.worker_id, w.first_name, w.last_name_p, s.kind, s.project_id, p.name AS project_name,
|
||||||
|
wk.week_start, wk.week_end, l.days, l.daily_wage, l.gross, l.discounts, l.loan_discount, l.payable_net
|
||||||
|
FROM payroll_week_lines l
|
||||||
|
JOIN payroll_sheets s ON s.id = l.sheet_id
|
||||||
|
JOIN payroll_weeks wk ON wk.id = s.week_id
|
||||||
|
JOIN workers w ON w.id = l.worker_id
|
||||||
|
LEFT JOIN projects p ON p.id = COALESCE(s.project_id, l.project_id)
|
||||||
|
WHERE wk.tenant_id = v_tid
|
||||||
|
AND wk.week_start BETWEEN v_from AND v_to
|
||||||
|
AND (v_worker_id IS NULL OR l.worker_id = v_worker_id)
|
||||||
|
AND (v_project_id IS NULL OR s.project_id = v_project_id OR l.project_id = v_project_id)
|
||||||
|
) x;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.adjustment_date), '[]'::jsonb) INTO v_adjustments
|
||||||
|
FROM payroll_adjustments a
|
||||||
|
JOIN payroll_weeks wk ON wk.id = a.week_id
|
||||||
|
WHERE a.tenant_id = v_tid
|
||||||
|
AND wk.week_start BETWEEN v_from AND v_to
|
||||||
|
AND (v_worker_id IS NULL OR a.worker_id = v_worker_id)
|
||||||
|
AND (v_project_id IS NULL OR a.project_id = v_project_id);
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.created_at), '[]'::jsonb) INTO v_loans
|
||||||
|
FROM (
|
||||||
|
SELECT lp.amount, lp.label, lp.loan_id, l.worker_id, wk.week_start
|
||||||
|
FROM loan_payments lp
|
||||||
|
JOIN loans l ON l.id = lp.loan_id
|
||||||
|
JOIN payroll_weeks wk ON wk.id = lp.week_id
|
||||||
|
WHERE wk.tenant_id = v_tid
|
||||||
|
AND wk.week_start BETWEEN v_from AND v_to
|
||||||
|
AND (v_worker_id IS NULL OR l.worker_id = v_worker_id)
|
||||||
|
) p;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY s.termination_date), '[]'::jsonb) INTO v_settlements
|
||||||
|
FROM settlements s
|
||||||
|
WHERE s.tenant_id = v_tid
|
||||||
|
AND s.status = 'pagado'
|
||||||
|
AND s.termination_date BETWEEN v_from AND v_to
|
||||||
|
AND (v_worker_id IS NULL OR s.worker_id = v_worker_id)
|
||||||
|
AND (v_project_id IS NULL OR s.project_id = v_project_id);
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object(
|
||||||
|
'lines', v_lines, 'adjustments', v_adjustments, 'loan_payments', v_loans,
|
||||||
|
'settlements', v_settlements, 'payable_total', core.round_money(v_total)
|
||||||
|
),
|
||||||
|
format('Reporte de nómina %s a %s', v_from, v_to),
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_report', 'date_from', v_from, 'date_to', v_to)
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_payroll_report', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-049b-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_payroll_report(jsonb) TO panels_core_app;
|
||||||
129
db/core/changesets/050-imss-wage.sql
Normal file
129
db/core/changesets/050-imss-wage.sql
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · core · jornal IMSS y consulta de cuotas
|
||||||
|
|
||||||
|
--changeset panel:core-050a-columns endDelimiter:; splitStatements:true
|
||||||
|
ALTER TABLE core.workers ADD COLUMN IF NOT EXISTS imss_daily_wage NUMERIC(12,2);
|
||||||
|
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_enfermedad NUMERIC(8,6) NOT NULL DEFAULT 0.020000;
|
||||||
|
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_invalidez NUMERIC(8,6) NOT NULL DEFAULT 0.017500;
|
||||||
|
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_retiro NUMERIC(8,6) NOT NULL DEFAULT 0.020000;
|
||||||
|
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_cesantia NUMERIC(8,6) NOT NULL DEFAULT 0.031500;
|
||||||
|
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_infonavit NUMERIC(8,6) NOT NULL DEFAULT 0.050000;
|
||||||
|
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_riesgo NUMERIC(8,6) NOT NULL DEFAULT 0.005433;
|
||||||
|
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS settlement_aguinaldo_days NUMERIC(6,2) NOT NULL DEFAULT 15;
|
||||||
|
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS settlement_prima_pct NUMERIC(6,2) NOT NULL DEFAULT 25;
|
||||||
|
|
||||||
|
--changeset panel:core-050b-fn-settings-save splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_payroll_settings_save(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_cur payroll_settings%ROWTYPE;
|
||||||
|
v_settings jsonb;
|
||||||
|
BEGIN
|
||||||
|
PERFORM core._payroll_seed_defaults(v_tid);
|
||||||
|
SELECT * INTO v_cur FROM payroll_settings WHERE tenant_id = v_tid;
|
||||||
|
UPDATE payroll_settings SET
|
||||||
|
loan_commission_enabled = COALESCE((payload->>'loan_commission_enabled')::boolean, v_cur.loan_commission_enabled),
|
||||||
|
loan_commission_pct = COALESCE((payload->>'loan_commission_pct')::numeric, v_cur.loan_commission_pct, 0),
|
||||||
|
loan_small_max = COALESCE((payload->>'loan_small_max')::numeric, v_cur.loan_small_max, 0),
|
||||||
|
imss_rate_enfermedad = COALESCE((payload->>'imss_rate_enfermedad')::numeric, v_cur.imss_rate_enfermedad),
|
||||||
|
imss_rate_invalidez = COALESCE((payload->>'imss_rate_invalidez')::numeric, v_cur.imss_rate_invalidez),
|
||||||
|
imss_rate_retiro = COALESCE((payload->>'imss_rate_retiro')::numeric, v_cur.imss_rate_retiro),
|
||||||
|
imss_rate_cesantia = COALESCE((payload->>'imss_rate_cesantia')::numeric, v_cur.imss_rate_cesantia),
|
||||||
|
imss_rate_infonavit = COALESCE((payload->>'imss_rate_infonavit')::numeric, v_cur.imss_rate_infonavit),
|
||||||
|
imss_rate_riesgo = COALESCE((payload->>'imss_rate_riesgo')::numeric, v_cur.imss_rate_riesgo),
|
||||||
|
settlement_aguinaldo_days = COALESCE((payload->>'settlement_aguinaldo_days')::numeric, v_cur.settlement_aguinaldo_days),
|
||||||
|
settlement_prima_pct = COALESCE((payload->>'settlement_prima_pct')::numeric, v_cur.settlement_prima_pct)
|
||||||
|
WHERE tenant_id = v_tid;
|
||||||
|
SELECT to_jsonb(s) INTO v_settings FROM payroll_settings s WHERE tenant_id = v_tid;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('settings', v_settings), 'Configuración de nómina guardada',
|
||||||
|
jsonb_build_object('fn', 'fn_payroll_settings_save', 'tenant_id', v_tid));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_payroll_settings_save', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-050c-fn-imss-quote splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_imss_quote(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_tid integer := core._payroll_tenant_id(payload);
|
||||||
|
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
|
||||||
|
v_from date := NULLIF(payload->>'date_from', '')::date;
|
||||||
|
v_to date := NULLIF(payload->>'date_to', '')::date;
|
||||||
|
v_settings payroll_settings%ROWTYPE;
|
||||||
|
v_rows jsonb;
|
||||||
|
BEGIN
|
||||||
|
IF v_from IS NULL OR v_to IS NULL THEN
|
||||||
|
RETURN core.rpc_err('VALIDATION', 'fn_imss_quote: indique el periodo',
|
||||||
|
jsonb_build_object('fn', 'fn_imss_quote'));
|
||||||
|
END IF;
|
||||||
|
PERFORM core._payroll_seed_defaults(v_tid);
|
||||||
|
SELECT * INTO v_settings FROM payroll_settings WHERE tenant_id = v_tid;
|
||||||
|
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.last_name_p), '[]'::jsonb) INTO v_rows
|
||||||
|
FROM (
|
||||||
|
SELECT w.id AS worker_id, w.first_name, w.last_name_p, w.nss, w.daily_wage,
|
||||||
|
w.imss_daily_wage,
|
||||||
|
COALESCE(w.imss_daily_wage, w.daily_wage) AS wage_used,
|
||||||
|
(w.imss_daily_wage IS NULL) AS used_real_wage,
|
||||||
|
COALESCE(d.days, 0) AS days,
|
||||||
|
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage)) AS sbc,
|
||||||
|
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_enfermedad) AS cuota_enfermedad,
|
||||||
|
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_invalidez) AS cuota_invalidez,
|
||||||
|
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_retiro) AS cuota_retiro,
|
||||||
|
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_cesantia) AS cuota_cesantia,
|
||||||
|
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_infonavit) AS cuota_infonavit,
|
||||||
|
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_riesgo) AS cuota_riesgo
|
||||||
|
FROM workers w
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT worker_id, COUNT(*)::numeric AS days
|
||||||
|
FROM attendance
|
||||||
|
WHERE present = true AND work_date BETWEEN v_from AND v_to
|
||||||
|
GROUP BY worker_id
|
||||||
|
) d ON d.worker_id = w.id
|
||||||
|
WHERE w.tenant_id = v_tid
|
||||||
|
AND (v_worker_id IS NULL OR w.id = v_worker_id)
|
||||||
|
) x;
|
||||||
|
RETURN core.rpc_ok(
|
||||||
|
jsonb_build_object('quotes', v_rows, 'rates', to_jsonb(v_settings)),
|
||||||
|
'Consulta de cuotas IMSS',
|
||||||
|
jsonb_build_object('fn', 'fn_imss_quote')
|
||||||
|
);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_imss_quote', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-050d-fn-imss-wage splitStatements:false
|
||||||
|
CREATE OR REPLACE FUNCTION core.fn_worker_imss_wage_set(payload jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY INVOKER
|
||||||
|
SET search_path = core
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||||
|
v_wage numeric := NULLIF(payload->>'imss_daily_wage', '')::numeric;
|
||||||
|
BEGIN
|
||||||
|
UPDATE workers SET imss_daily_wage = v_wage, updated_at = now() WHERE id = v_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RETURN core.rpc_err('NOT_FOUND', 'Trabajador no encontrado', jsonb_build_object('fn', 'fn_worker_imss_wage_set', 'id', v_id));
|
||||||
|
END IF;
|
||||||
|
RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'imss_daily_wage', v_wage), 'Jornal IMSS actualizado',
|
||||||
|
jsonb_build_object('fn', 'fn_worker_imss_wage_set', 'id', v_id));
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN core.rpc_from_exception('fn_worker_imss_wage_set', SQLSTATE, SQLERRM);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
--changeset panel:core-050e-grants endDelimiter:; splitStatements:true
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_imss_quote(jsonb) TO panels_core_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION core.fn_worker_imss_wage_set(jsonb) TO panels_core_app;
|
||||||
|
|
@ -12,5 +12,6 @@
|
||||||
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/004-rpc-users-permissions.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/005-iam-v2-roles-crud.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/005-iam-v2-roles-crud.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/006-documents-delete-permission.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/006-documents-delete-permission.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/007-invoices-permission.sql" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
29
db/iam/changesets/007-invoices-permission.sql
Normal file
29
db/iam/changesets/007-invoices-permission.sql
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
--liquibase formatted sql
|
||||||
|
-- PANELS · iam · permisos de facturas
|
||||||
|
|
||||||
|
--changeset panel:iam-007a-invoices endDelimiter:; splitStatements:true
|
||||||
|
INSERT INTO iam.permissions (code, label, module, verb, perm_group) VALUES
|
||||||
|
('invoices.view', 'Ver facturas', 'invoices', 'view', NULL),
|
||||||
|
('invoices.create', 'Importar facturas', 'invoices', 'create', NULL),
|
||||||
|
('invoices.update', 'Clasificar facturas', 'invoices', 'update', NULL)
|
||||||
|
ON CONFLICT (code) DO UPDATE SET
|
||||||
|
label = EXCLUDED.label,
|
||||||
|
module = EXCLUDED.module,
|
||||||
|
verb = EXCLUDED.verb,
|
||||||
|
perm_group = EXCLUDED.perm_group;
|
||||||
|
|
||||||
|
INSERT INTO iam._legacy_perm_map (legacy_code, v2_code) VALUES
|
||||||
|
('manage_expenses', 'invoices.view'),
|
||||||
|
('manage_expenses', 'invoices.create'),
|
||||||
|
('manage_expenses', 'invoices.update'),
|
||||||
|
('view_expenses', 'invoices.view')
|
||||||
|
ON CONFLICT (legacy_code, v2_code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO iam.role_permissions (role_id, permission_code)
|
||||||
|
SELECT sr.id, p.code
|
||||||
|
FROM iam.roles sr
|
||||||
|
JOIN iam.permissions p ON p.code IN ('invoices.view', 'invoices.create', 'invoices.update')
|
||||||
|
WHERE sr.code = 'tenant_admin'
|
||||||
|
AND sr.is_system
|
||||||
|
AND sr.tenant_id IS NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
@ -15,6 +15,15 @@
|
||||||
<FormField label="RFC">
|
<FormField label="RFC">
|
||||||
<InputText v-model="form.rfc" class="w-full identifier-input" maxlength="13" placeholder="XAXX010101000" />
|
<InputText v-model="form.rfc" class="w-full identifier-input" maxlength="13" placeholder="XAXX010101000" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField label="Régimen fiscal">
|
||||||
|
<InputText v-model="form.regimen_fiscal" class="w-full identifier-input" maxlength="3" placeholder="601" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="C.P. fiscal">
|
||||||
|
<InputText v-model="form.postal_code" class="w-full identifier-input" maxlength="5" />
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Estatus">
|
||||||
|
<Select v-model="form.status" :options="statusOpts" option-label="label" option-value="value" class="w-full" />
|
||||||
|
</FormField>
|
||||||
<FormField label="Teléfono">
|
<FormField label="Teléfono">
|
||||||
<InputText v-model="form.phone" class="w-full" />
|
<InputText v-model="form.phone" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -54,6 +63,7 @@ export type Supplier = {
|
||||||
city?: string;
|
city?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
postal_code?: string;
|
postal_code?: string;
|
||||||
|
regimen_fiscal?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -81,9 +91,17 @@ const empty = () => ({
|
||||||
phone: "",
|
phone: "",
|
||||||
address: "",
|
address: "",
|
||||||
city: "",
|
city: "",
|
||||||
|
postal_code: "",
|
||||||
|
regimen_fiscal: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
|
status: "activo",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statusOpts = [
|
||||||
|
{ label: "Activo", value: "activo" },
|
||||||
|
{ label: "Inactivo", value: "inactivo" },
|
||||||
|
];
|
||||||
|
|
||||||
const form = reactive(empty());
|
const form = reactive(empty());
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,11 @@
|
||||||
class="w-full mt-2"
|
class="w-full mt-2"
|
||||||
@click="registerLoan"
|
@click="registerLoan"
|
||||||
/>
|
/>
|
||||||
|
<DataTable :value="movements" size="small" class="mt-3" empty-message="Sin movimientos">
|
||||||
|
<Column field="movement_date" header="Fecha" />
|
||||||
|
<Column field="kind" header="Movimiento" />
|
||||||
|
<Column field="note" header="Nota" />
|
||||||
|
</DataTable>
|
||||||
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos">
|
<DataTable :value="data.loans" size="small" class="mt-3" empty-message="Sin préstamos">
|
||||||
<Column header="Entregado"><template #body="{ data: row }">{{ money(Number(row.delivered || row.amount)) }}</template></Column>
|
<Column header="Entregado"><template #body="{ data: row }">{{ money(Number(row.delivered || row.amount)) }}</template></Column>
|
||||||
<Column header="Saldo"><template #body="{ data: row }">{{ money(Number(row.balance)) }}</template></Column>
|
<Column header="Saldo"><template #body="{ data: row }">{{ money(Number(row.balance)) }}</template></Column>
|
||||||
|
|
@ -162,9 +167,17 @@
|
||||||
<Column header="">
|
<Column header="">
|
||||||
<template #body="{ data: row }">
|
<template #body="{ data: row }">
|
||||||
<Button icon="pi pi-file-pdf" text rounded size="small" aria-label="Recibo" @click="downloadLoan(row.id)" />
|
<Button icon="pi pi-file-pdf" text rounded size="small" aria-label="Recibo" @click="downloadLoan(row.id)" />
|
||||||
|
<Button icon="pi pi-list" text rounded size="small" aria-label="Estado de cuenta" @click="loadStatement(row.id)" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
<DataTable v-if="statementPayments.length" :value="statementPayments" size="small" class="mt-2">
|
||||||
|
<Column field="week_end" header="Semana" />
|
||||||
|
<Column field="amount" header="Descuento">
|
||||||
|
<template #body="{ data: row }">{{ money(Number(row.amount)) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="label" header="Etiqueta" />
|
||||||
|
</DataTable>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
@ -235,6 +248,12 @@ const requiredDone = computed(() => requiredItems.value.filter((item) => item.pr
|
||||||
const activeAssignments = computed(() => data.value?.assignments.filter((assignment) => assignment.active).length || 0);
|
const activeAssignments = computed(() => data.value?.assignments.filter((assignment) => assignment.active).length || 0);
|
||||||
const activeProjectNames = computed(() => data.value?.assignments.filter((assignment) => assignment.active).map((assignment) => assignment.project_name).join(", ") || "Sin asignar");
|
const activeProjectNames = computed(() => data.value?.assignments.filter((assignment) => assignment.active).map((assignment) => assignment.project_name).join(", ") || "Sin asignar");
|
||||||
const loanBalance = computed(() => data.value?.loans.reduce((sum, loan) => sum + Math.max(0, Number(loan.balance)), 0) || 0);
|
const loanBalance = computed(() => data.value?.loans.reduce((sum, loan) => sum + Math.max(0, Number(loan.balance)), 0) || 0);
|
||||||
|
const movements = ref<{ movement_date: string; kind: string; note: string }[]>([]);
|
||||||
|
const statementPayments = ref<{ week_end?: string; amount: number; label?: string }[]>([]);
|
||||||
|
async function loadStatement(id: number) {
|
||||||
|
const r = await api<{ payments?: { week_end?: string; amount: number; label?: string }[]; data?: { payments?: { week_end?: string; amount: number; label?: string }[] } }>(`/v1/loans/${id}/statement`);
|
||||||
|
statementPayments.value = r.payments ?? r.data?.payments ?? [];
|
||||||
|
}
|
||||||
const imssStatusLabel = computed(() => {
|
const imssStatusLabel = computed(() => {
|
||||||
const status = data.value?.worker.imss_status;
|
const status = data.value?.worker.imss_status;
|
||||||
if (status === "alta") return "Alta IMSS";
|
if (status === "alta") return "Alta IMSS";
|
||||||
|
|
@ -288,6 +307,8 @@ async function load() {
|
||||||
api<{ companies: CompanyOption[] }>("/v1/companies"),
|
api<{ companies: CompanyOption[] }>("/v1/companies"),
|
||||||
]);
|
]);
|
||||||
data.value = detail;
|
data.value = detail;
|
||||||
|
const mov = await api<{ movements?: { movement_date: string; kind: string; note: string }[]; data?: { movements?: { movement_date: string; kind: string; note: string }[] } }>(`/v1/workers/${props.workerId}/movements`);
|
||||||
|
movements.value = mov.movements ?? mov.data?.movements ?? [];
|
||||||
projects.value = projectResponse.projects;
|
projects.value = projectResponse.projects;
|
||||||
companies.value = companyResponse.companies.filter((c) => c.status === "activo");
|
companies.value = companyResponse.companies.filter((c) => c.status === "activo");
|
||||||
photoOk.value = true;
|
photoOk.value = true;
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Tipo de trabajo"><SelectButton v-model="form.work_type" :options="workTypes" option-label="label" option-value="value" :allow-empty="false" /></FormField>
|
<FormField label="Tipo de trabajo"><SelectButton v-model="form.work_type" :options="workTypes" option-label="label" option-value="value" :allow-empty="false" /></FormField>
|
||||||
<FormField label="Jornal diario" required :error="fieldErr.daily_wage"><InputNumber v-model="form.daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
|
<FormField label="Jornal diario" required :error="fieldErr.daily_wage"><InputNumber v-model="form.daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
|
||||||
|
<FormField label="Jornal IMSS"><InputNumber v-model="form.imss_daily_wage" mode="currency" currency="MXN" locale="es-MX" :min="0" /></FormField>
|
||||||
<FormField label="Gafete">
|
<FormField label="Gafete">
|
||||||
<SelectButton
|
<SelectButton
|
||||||
:model-value="form.needs_badge ? 'si' : 'no'"
|
:model-value="form.needs_badge ? 'si' : 'no'"
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ const MODULE_META: Record<string, { label: string; description: string }> = {
|
||||||
label: "Control de costos",
|
label: "Control de costos",
|
||||||
description: "Comparativo programado vs real y tableros de costo.",
|
description: "Comparativo programado vs real y tableros de costo.",
|
||||||
},
|
},
|
||||||
|
invoices: {
|
||||||
|
label: "Facturas",
|
||||||
|
description: "Comprobantes recibidos y emitidos, clasificación a la obra.",
|
||||||
|
},
|
||||||
expenses: {
|
expenses: {
|
||||||
label: "Gastos",
|
label: "Gastos",
|
||||||
description: "Captura y seguimiento de egresos por obra.",
|
description: "Captura y seguimiento de egresos por obra.",
|
||||||
|
|
@ -125,6 +129,7 @@ const MODULE_ORDER = [
|
||||||
"work_program",
|
"work_program",
|
||||||
"budget",
|
"budget",
|
||||||
"cost_control",
|
"cost_control",
|
||||||
|
"invoices",
|
||||||
"expenses",
|
"expenses",
|
||||||
"workers",
|
"workers",
|
||||||
"kanban",
|
"kanban",
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,7 @@ const menu = computed<NavItem[]>(() => {
|
||||||
if (obraItems.length) items.push({ label: "Obra", icon: "pi pi-building", items: obraItems });
|
if (obraItems.length) items.push({ label: "Obra", icon: "pi pi-building", items: obraItems });
|
||||||
|
|
||||||
const opItems = [
|
const opItems = [
|
||||||
|
navIf("Facturas", "pi pi-file", "/facturas", "invoices.view"),
|
||||||
navIf("Gastos", "pi pi-money-bill", "/gastos", "expenses.view"),
|
navIf("Gastos", "pi pi-money-bill", "/gastos", "expenses.view"),
|
||||||
navIf("Almacén", "pi pi-box", "/almacen", "warehouse.view"),
|
navIf("Almacén", "pi pi-box", "/almacen", "warehouse.view"),
|
||||||
navIf("Kanban", "pi pi-th-large", "/kanban", "kanban.view"),
|
navIf("Kanban", "pi pi-th-large", "/kanban", "kanban.view"),
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const ROUTE_PERMISSIONS: Record<string, string> = {
|
||||||
"/presupuesto": "budget.view",
|
"/presupuesto": "budget.view",
|
||||||
"/programa-obra": "work_program.view",
|
"/programa-obra": "work_program.view",
|
||||||
"/control-presupuesto": "cost_control.view",
|
"/control-presupuesto": "cost_control.view",
|
||||||
|
"/facturas": "invoices.view",
|
||||||
"/gastos": "expenses.view",
|
"/gastos": "expenses.view",
|
||||||
"/almacen": "warehouse.view",
|
"/almacen": "warehouse.view",
|
||||||
"/kanban": "kanban.view",
|
"/kanban": "kanban.view",
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,14 @@
|
||||||
<Column field="rfc" header="RFC">
|
<Column field="rfc" header="RFC">
|
||||||
<template #body="{ data }"><span class="identifier">{{ data.rfc || "—" }}</span></template>
|
<template #body="{ data }"><span class="identifier">{{ data.rfc || "—" }}</span></template>
|
||||||
</Column>
|
</Column>
|
||||||
|
<Column field="regimen_fiscal" header="Régimen">
|
||||||
|
<template #body="{ data }">{{ data.regimen_fiscal || "—" }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Estatus">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.status === 'inactivo' ? 'Inactivo' : 'Activo'" :severity="data.status === 'inactivo' ? 'secondary' : 'success'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
<Column field="phone" header="Teléfono">
|
<Column field="phone" header="Teléfono">
|
||||||
<template #body="{ data }">{{ data.phone || "—" }}</template>
|
<template #body="{ data }">{{ data.phone || "—" }}</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
||||||
138
web-panel/pages/facturas.vue
Normal file
138
web-panel/pages/facturas.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<DeskTableBar v-model="tableQ" placeholder="Proveedor, RFC, UUID o folio">
|
||||||
|
<SelectButton v-model="direction" :options="dirOpts" option-label="label" option-value="value" :allow-empty="false" @change="load" />
|
||||||
|
<InputNumber v-model="amountMin" placeholder="Monto mín" />
|
||||||
|
<InputNumber v-model="amountMax" placeholder="Monto máx" />
|
||||||
|
<Button label="Importar Excel" icon="pi pi-upload" @click="showImport = true" />
|
||||||
|
</DeskTableBar>
|
||||||
|
<DataTable :value="listed" data-key="id" paginator :rows="20" @row-dblclick="(e: { data: Invoice }) => open(e.data)">
|
||||||
|
<Column field="invoice_date" header="Fecha" />
|
||||||
|
<Column field="supplier_name" header="Proveedor" />
|
||||||
|
<Column field="supplier_rfc" header="RFC" />
|
||||||
|
<Column field="uuid" header="UUID" />
|
||||||
|
<Column field="folio" header="Folio" />
|
||||||
|
<Column field="total" header="Total">
|
||||||
|
<template #body="{ data }">{{ money(data.total) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="status" header="Estatus" />
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="showImport" header="Importar facturas" modal :style="{ width: '28rem' }">
|
||||||
|
<Select v-model="companyId" :options="companies" option-label="name" option-value="id" placeholder="Empresa" class="w-full" />
|
||||||
|
<input class="mt-3" type="file" accept=".xlsx,.xls" @change="onFile" />
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Cargar" icon="pi pi-check" :disabled="!file || !companyId" @click="upload" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="showDetail" header="Clasificar factura" modal :style="{ width: '36rem' }">
|
||||||
|
<p v-if="detail">{{ detail.supplier_name }} · {{ detail.uuid || detail.folio }} · {{ detail.review_note }}</p>
|
||||||
|
<div v-for="line in lines" :key="line.id" class="mt-2">
|
||||||
|
<div>{{ line.description }} · {{ money(line.amount) }}</div>
|
||||||
|
<Select v-model="line.project_id" :options="projects" option-label="name" option-value="id" placeholder="Obra" class="w-full mt-1" />
|
||||||
|
<BudgetItemPicker v-if="line.project_id" v-model="line.budget_item_id" :project-id="line.project_id" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Clasificar" icon="pi pi-check" @click="classify" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type Invoice = {
|
||||||
|
id: number;
|
||||||
|
invoice_date: string;
|
||||||
|
supplier_name: string;
|
||||||
|
supplier_rfc: string;
|
||||||
|
uuid: string;
|
||||||
|
folio: string;
|
||||||
|
total: number;
|
||||||
|
status: string;
|
||||||
|
review_note?: string;
|
||||||
|
};
|
||||||
|
type Line = { id: number; description: string; amount: number; project_id: number | null; budget_item_id: number | null };
|
||||||
|
|
||||||
|
const { api } = useApi();
|
||||||
|
const toast = useToast();
|
||||||
|
const tableQ = ref("");
|
||||||
|
const direction = ref("recibida");
|
||||||
|
const dirOpts = [{ label: "Recibidas", value: "recibida" }, { label: "Emitidas", value: "emitida" }];
|
||||||
|
const amountMin = ref<number | null>(null);
|
||||||
|
const amountMax = ref<number | null>(null);
|
||||||
|
const invoices = ref<Invoice[]>([]);
|
||||||
|
const companies = ref<{ id: number; name: string }[]>([]);
|
||||||
|
const projects = ref<{ id: number; name: string }[]>([]);
|
||||||
|
const showImport = ref(false);
|
||||||
|
const showDetail = ref(false);
|
||||||
|
const companyId = ref<number | null>(null);
|
||||||
|
const file = ref<File | null>(null);
|
||||||
|
const detail = ref<Invoice | null>(null);
|
||||||
|
const lines = ref<Line[]>([]);
|
||||||
|
|
||||||
|
const listed = computed(() => {
|
||||||
|
const q = tableQ.value.trim().toLowerCase();
|
||||||
|
return invoices.value.filter((i) => {
|
||||||
|
if (amountMin.value != null && Number(i.total) < amountMin.value) return false;
|
||||||
|
if (amountMax.value != null && Number(i.total) > amountMax.value) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return [i.supplier_name, i.supplier_rfc, i.uuid, i.folio].join(" ").toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function money(n: number) {
|
||||||
|
return Number(n || 0).toLocaleString("es-MX", { style: "currency", currency: "MXN" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const q = new URLSearchParams({ direction: direction.value });
|
||||||
|
const r = await api<{ invoices?: Invoice[]; data?: { invoices?: Invoice[] } }>(`/v1/invoices?${q}`);
|
||||||
|
invoices.value = r.invoices ?? r.data?.invoices ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFile(ev: Event) {
|
||||||
|
file.value = (ev.target as HTMLInputElement).files?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() {
|
||||||
|
if (!file.value || !companyId.value) return;
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("company_id", String(companyId.value));
|
||||||
|
body.set("file", file.value);
|
||||||
|
const r = await api<{ created?: number; review?: number; data?: { created?: number; review?: number } }>("/v1/invoices/import", { method: "POST", body });
|
||||||
|
const created = r.created ?? r.data?.created ?? 0;
|
||||||
|
const review = r.review ?? r.data?.review ?? 0;
|
||||||
|
toast.add({ severity: "success", summary: `${created} facturas, ${review} en revisión`, life: 2500 });
|
||||||
|
showImport.value = false;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(row: Invoice) {
|
||||||
|
const r = await api<{ invoice?: Invoice; lines?: Line[]; data?: { invoice?: Invoice; lines?: Line[] } }>(`/v1/invoices/${row.id}`);
|
||||||
|
detail.value = r.invoice ?? r.data?.invoice ?? row;
|
||||||
|
lines.value = r.lines ?? r.data?.lines ?? [];
|
||||||
|
showDetail.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function classify() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
const line = lines.value[0];
|
||||||
|
await api(`/v1/invoices/${detail.value.id}/classify`, {
|
||||||
|
method: "POST",
|
||||||
|
json: { line_id: line?.id, project_id: line?.project_id, budget_item_id: line?.budget_item_id },
|
||||||
|
});
|
||||||
|
showDetail.value = false;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [c, p] = await Promise.all([
|
||||||
|
api<{ companies?: { id: number; name: string }[] }>("/v1/companies"),
|
||||||
|
api<{ projects?: { id: number; name: string }[] }>("/v1/projects"),
|
||||||
|
]);
|
||||||
|
companies.value = c.companies ?? [];
|
||||||
|
projects.value = p.projects ?? [];
|
||||||
|
await load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -220,6 +220,61 @@
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog v-model:visible="showAdjust" header="Bono, descuento u horas" modal :style="{ width: '24rem' }">
|
||||||
|
<div class="loan-form">
|
||||||
|
<Select v-model="adjust.kind" :options="adjustKinds" option-label="label" option-value="value" class="w-full" />
|
||||||
|
<InputText v-model="adjust.concept" placeholder="Concepto" class="w-full" />
|
||||||
|
<InputNumber v-model="adjust.amount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
|
<InputNumber v-model="adjust.hours" :min-fraction-digits="0" :max-fraction-digits="2" placeholder="Horas" class="w-full" />
|
||||||
|
<Textarea v-model="adjust.note" placeholder="Observación (obligatoria en descuento)" class="w-full" rows="2" />
|
||||||
|
<Button label="Guardar" @click="saveAdjust" />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog v-model:visible="showHoliday" header="Día festivo" modal :style="{ width: '22rem' }">
|
||||||
|
<div class="loan-form">
|
||||||
|
<InputText v-model="holiday.holiday_date" placeholder="AAAA-MM-DD" class="w-full" />
|
||||||
|
<InputText v-model="holiday.name" placeholder="Nombre" class="w-full" />
|
||||||
|
<Button label="Guardar" @click="saveHoliday" />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog v-model:visible="showDispersion" header="Dispersión" modal :style="{ width: '36rem' }">
|
||||||
|
<p>Neto semana {{ money(dispersionNet) }} · {{ dispersionStatus }}</p>
|
||||||
|
<DataTable :value="dispersionLines" size="small">
|
||||||
|
<Column field="last_name_p" header="Persona" />
|
||||||
|
<Column field="amount" header="Importe">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<InputNumber v-model="data.amount" mode="currency" currency="MXN" locale="es-MX" :disabled="dispersionStatus === 'definitiva'" @blur="saveDispLine(data)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
<div class="flex gap-2 mt-3">
|
||||||
|
<Button label="Corte semanal" :disabled="dispersionStatus === 'definitiva'" @click="cutDispersion" />
|
||||||
|
<Button label="Definitiva" :disabled="dispersionStatus !== 'corte'" @click="finalizeDispersion" />
|
||||||
|
<input type="file" accept="application/pdf,.pdf" @change="attachDispersion" />
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog v-model:visible="showSettlement" header="Finiquito o liquidación" modal :style="{ width: '24rem' }">
|
||||||
|
<div class="loan-form">
|
||||||
|
<Select v-model="settlement.kind" :options="[{ label: 'Finiquito', value: 'finiquito' }, { label: 'Liquidación', value: 'liquidacion' }]" option-label="label" option-value="value" class="w-full" />
|
||||||
|
<InputText v-model="settlement.termination_date" placeholder="Fecha de baja AAAA-MM-DD" class="w-full" />
|
||||||
|
<InputText v-model="settlement.concept" placeholder="Concepto" class="w-full" />
|
||||||
|
<InputNumber v-model="settlement.amount" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
|
<Textarea v-model="settlement.note" placeholder="Observación" class="w-full" rows="2" />
|
||||||
|
<Button label="Guardar y ver importe" @click="saveSettlement" />
|
||||||
|
<p v-if="settlementNet != null">Importe final {{ money(settlementNet) }}</p>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog v-model:visible="showReport" header="Reporte de nómina" modal :style="{ width: '28rem' }">
|
||||||
|
<div class="loan-form">
|
||||||
|
<InputText v-model="reportFrom" placeholder="Desde AAAA-MM-DD" class="w-full" />
|
||||||
|
<InputText v-model="reportTo" placeholder="Hasta AAAA-MM-DD" class="w-full" />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button label="Excel" @click="downloadReport('xlsx')" />
|
||||||
|
<Button label="PDF" @click="downloadReport('pdf')" />
|
||||||
|
<Button label="IMSS Excel" @click="downloadImss" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
<Dialog v-model:visible="showAddJornal" header="Agregar a jornal" modal :style="{ width: '22rem' }">
|
<Dialog v-model:visible="showAddJornal" header="Agregar a jornal" modal :style="{ width: '22rem' }">
|
||||||
<Select v-model="jornalWorkerId" :options="allWorkers" option-label="label" option-value="id" placeholder="Persona" class="w-full" filter />
|
<Select v-model="jornalWorkerId" :options="allWorkers" option-label="label" option-value="id" placeholder="Persona" class="w-full" filter />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
@ -286,6 +341,26 @@ const assembleFrom = ref("");
|
||||||
const showDestajo = ref(false);
|
const showDestajo = ref(false);
|
||||||
const showAdmin = ref(false);
|
const showAdmin = ref(false);
|
||||||
const showAddJornal = ref(false);
|
const showAddJornal = ref(false);
|
||||||
|
const showAdjust = ref(false);
|
||||||
|
const showHoliday = ref(false);
|
||||||
|
const showDispersion = ref(false);
|
||||||
|
const showReport = ref(false);
|
||||||
|
const showSettlement = ref(false);
|
||||||
|
const settlement = reactive({ kind: "finiquito", termination_date: "", concept: "Sueldo pendiente", amount: 0, note: "" });
|
||||||
|
const adjustKinds = [
|
||||||
|
{ label: "Bono", value: "bono" },
|
||||||
|
{ label: "Descuento", value: "descuento" },
|
||||||
|
{ label: "Horas", value: "horas" },
|
||||||
|
];
|
||||||
|
const adjust = reactive({ kind: "bono", concept: "", amount: 0, hours: 0, note: "" });
|
||||||
|
const holiday = reactive({ holiday_date: "", name: "" });
|
||||||
|
const dispersionId = ref<number | null>(null);
|
||||||
|
const dispersionStatus = ref("abierta");
|
||||||
|
const dispersionNet = ref(0);
|
||||||
|
const dispersionLines = ref<{ id: number; last_name_p: string; amount: number }[]>([]);
|
||||||
|
const settlementNet = ref<number | null>(null);
|
||||||
|
const reportFrom = ref("");
|
||||||
|
const reportTo = ref("");
|
||||||
const selectedWorkerId = ref<number | null>(null);
|
const selectedWorkerId = ref<number | null>(null);
|
||||||
const selectedWorkerName = ref("");
|
const selectedWorkerName = ref("");
|
||||||
const jornalWorkerId = ref<number | null>(null);
|
const jornalWorkerId = ref<number | null>(null);
|
||||||
|
|
@ -451,6 +526,11 @@ function syncChrome() {
|
||||||
},
|
},
|
||||||
{ key: "pay", label: "Pagar sábado", icon: "pi pi-wallet", disabled: paid.value, command: pay },
|
{ key: "pay", label: "Pagar sábado", icon: "pi pi-wallet", disabled: paid.value, command: pay },
|
||||||
{ key: "csv", label: "CSV", icon: "pi pi-download", command: csv },
|
{ key: "csv", label: "CSV", icon: "pi pi-download", command: csv },
|
||||||
|
{ key: "ajuste", label: "Bono o descuento", icon: "pi pi-plus", disabled: paid.value, command: () => { showAdjust.value = true; } },
|
||||||
|
{ key: "festivo", label: "Festivo", icon: "pi pi-calendar", command: () => { showHoliday.value = true; } },
|
||||||
|
{ key: "dispersion", label: "Dispersión", icon: "pi pi-send", command: openDispersion },
|
||||||
|
{ key: "reporte", label: "Reporte", icon: "pi pi-chart-bar", command: () => { showReport.value = true; } },
|
||||||
|
{ key: "finiquito", label: "Finiquito", icon: "pi pi-file", disabled: !selectedWorkerId.value, command: () => { showSettlement.value = true; } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -567,6 +647,74 @@ async function pay() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveSettlement() {
|
||||||
|
if (!selectedWorkerId.value) return;
|
||||||
|
const saved = await api<{ id?: number; data?: { id?: number } }>("/v1/settlements", {
|
||||||
|
method: "POST",
|
||||||
|
json: {
|
||||||
|
worker_id: selectedWorkerId.value,
|
||||||
|
project_id: projectId.value,
|
||||||
|
kind: settlement.kind,
|
||||||
|
termination_date: settlement.termination_date,
|
||||||
|
lines: [{ concept: settlement.concept, amount: settlement.amount, note: settlement.note }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = saved.id ?? saved.data?.id;
|
||||||
|
if (!id) return;
|
||||||
|
const got = await api<{ settlement?: { net: number }; data?: { settlement?: { net: number } } }>(`/v1/settlements/${id}`);
|
||||||
|
settlementNet.value = Number(got.settlement?.net ?? got.data?.settlement?.net ?? 0);
|
||||||
|
}
|
||||||
|
async function saveAdjust() {
|
||||||
|
if (!week.value || !selectedWorkerId.value) return;
|
||||||
|
await api("/v1/payroll/adjustments", {
|
||||||
|
method: "POST",
|
||||||
|
json: { ...adjust, week_id: week.value.id, worker_id: selectedWorkerId.value, project_id: projectId.value },
|
||||||
|
});
|
||||||
|
showAdjust.value = false;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
async function saveHoliday() {
|
||||||
|
await api("/v1/payroll/holidays", { method: "POST", json: holiday });
|
||||||
|
showHoliday.value = false;
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
async function openDispersion() {
|
||||||
|
if (!week.value) return;
|
||||||
|
const r = await api<{ dispersion?: { id: number; status: string; net: number }; lines?: { id: number; last_name_p: string; amount: number }[]; data?: { dispersion?: { id: number; status: string; net: number }; lines?: { id: number; last_name_p: string; amount: number }[] } }>(`/v1/payroll/weeks/${week.value.id}/dispersion`);
|
||||||
|
const d = r.dispersion ?? r.data?.dispersion;
|
||||||
|
dispersionId.value = d?.id ?? null;
|
||||||
|
dispersionStatus.value = d?.status ?? "abierta";
|
||||||
|
dispersionNet.value = Number(d?.net ?? 0);
|
||||||
|
dispersionLines.value = r.lines ?? r.data?.lines ?? [];
|
||||||
|
showDispersion.value = true;
|
||||||
|
}
|
||||||
|
async function cutDispersion() {
|
||||||
|
if (!dispersionId.value) return;
|
||||||
|
await api(`/v1/payroll/dispersions/${dispersionId.value}/cut`, { method: "POST" });
|
||||||
|
await openDispersion();
|
||||||
|
}
|
||||||
|
async function saveDispLine(row: { id: number; amount: number }) {
|
||||||
|
await api(`/v1/payroll/dispersion-lines/${row.id}`, { method: "PATCH", json: { amount: row.amount } });
|
||||||
|
}
|
||||||
|
async function finalizeDispersion() {
|
||||||
|
if (!dispersionId.value) return;
|
||||||
|
await api(`/v1/payroll/dispersions/${dispersionId.value}/finalize`, { method: "POST", json: {} });
|
||||||
|
await openDispersion();
|
||||||
|
}
|
||||||
|
async function attachDispersion(ev: Event) {
|
||||||
|
const file = (ev.target as HTMLInputElement).files?.[0];
|
||||||
|
if (!file || !dispersionId.value) return;
|
||||||
|
const body = new FormData();
|
||||||
|
body.set("file", file);
|
||||||
|
await api(`/v1/payroll/dispersions/${dispersionId.value}/attachment`, { method: "POST", body });
|
||||||
|
toast.add({ severity: "success", summary: "Comprobante anexado", life: 1800 });
|
||||||
|
}
|
||||||
|
function downloadReport(kind: "xlsx" | "pdf") {
|
||||||
|
download(`/v1/payroll/report.${kind}?date_from=${reportFrom.value}&date_to=${reportTo.value}`, `nomina.${kind}`);
|
||||||
|
}
|
||||||
|
function downloadImss() {
|
||||||
|
download(`/v1/payroll/imss-quote.xlsx?date_from=${reportFrom.value}&date_to=${reportTo.value}`, "imss.xlsx");
|
||||||
|
}
|
||||||
function csv() {
|
function csv() {
|
||||||
if (!week.value) return;
|
if (!week.value) return;
|
||||||
download(`/v1/payroll/weeks/${week.value.id}/csv`, `nomina-${week.value.id}.csv`).then(() => {
|
download(`/v1/payroll/weeks/${week.value.id}/csv`, `nomina-${week.value.id}.csv`).then(() => {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export type Worker = {
|
||||||
risk_code: string;
|
risk_code: string;
|
||||||
work_type: "N" | "D";
|
work_type: "N" | "D";
|
||||||
daily_wage: number;
|
daily_wage: number;
|
||||||
|
imss_daily_wage?: number | null;
|
||||||
needs_badge: number | boolean;
|
needs_badge: number | boolean;
|
||||||
status: WorkerStatus;
|
status: WorkerStatus;
|
||||||
pipeline_status: PipelineStatus;
|
pipeline_status: PipelineStatus;
|
||||||
|
|
@ -168,6 +169,7 @@ export type WorkerForm = {
|
||||||
risk_code: string;
|
risk_code: string;
|
||||||
work_type: "N" | "D";
|
work_type: "N" | "D";
|
||||||
daily_wage: number;
|
daily_wage: number;
|
||||||
|
imss_daily_wage?: number | null;
|
||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
needs_badge: boolean;
|
needs_badge: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -200,6 +202,7 @@ export function emptyWorkerForm(): WorkerForm {
|
||||||
risk_code: "rojo",
|
risk_code: "rojo",
|
||||||
work_type: "N",
|
work_type: "N",
|
||||||
daily_wage: 0,
|
daily_wage: 0,
|
||||||
|
imss_daily_wage: null,
|
||||||
project_id: null,
|
project_id: null,
|
||||||
needs_badge: true,
|
needs_badge: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue