Refactor payroll_http and excel to use core RPC functions

- Replace db.prepare() in payroll_http with callCoreFn for attendance,
  loans, loan receipts, and legacy payroll period routes
- Use respondRpc/respondApiError for standardized HTTP responses
- Add fn_loan_list/get, fn_loan_payment_get, and fn_payroll_period_* RPCs
  to 013-rpc-payroll.sql with grants
- Refactor excel storeDocument to fn_worker_document_store (S3 stays in TS)
- Refactor importExcel and upsertWorker to fn_workers_import_batch

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-03 23:57:36 +00:00
parent d2be5c5b76
commit 8717c00a83
No known key found for this signature in database
7 changed files with 1106 additions and 232 deletions

View file

@ -3,9 +3,10 @@ import type { Db } from "./db.ts";
import { encryptBytes } from "./docs_crypto.ts"; import { encryptBytes } from "./docs_crypto.ts";
import { sha256Hex } from "./crypto.ts"; import { sha256Hex } from "./crypto.ts";
import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts";
import { refreshPipeline, lastInsertId } from "./db.ts"; import { refreshPipeline } from "./db.ts";
import { resolveCompany } from "./companies.ts"; import { resolveCompany } from "./companies.ts";
import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts"; import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts";
import { callCoreFn, RpcCallError } from "./rpc.ts";
export const IMPORT_COLUMNS = [ export const IMPORT_COLUMNS = [
"NOMBRE", "NOMBRE",
@ -214,82 +215,34 @@ export async function storeDocument(
imss_baja_at?: string | null; imss_baja_at?: string | null;
} = {}, } = {},
) { ) {
const allowed = await db.prepare("SELECT code FROM document_types WHERE code = ?").get(type);
if (!allowed) throw new Error("Tipo de documento no válido");
const { iv, cipher } = await encryptBytes(bytes); const { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes); const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`; const storage = `${crypto.randomUUID()}.enc`;
await putObject(workerDocKey(workerId, storage), cipher); await putObject(workerDocKey(workerId, storage), cipher);
await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run(
workerId,
type,
);
const issuedAt = meta.issued_at || null; const issuedAt = meta.issued_at || null;
const expiresAt = meta.expires_at || null; const expiresAt = meta.expires_at || null;
const imssCompanyId = meta.imss_company_id || null; const imssCompanyId = meta.imss_company_id || null;
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null); const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null);
const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null);
const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt;
await db.prepare( const env = await callCoreFn(db, "core.fn_worker_document_store", {
`INSERT INTO documents worker_id: workerId,
(worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, type_code: type,
parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by_id, uploaded_by_name) original_name: filename,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`,
).run(
workerId,
type,
filename,
mime, mime,
bytes.byteLength, size_bytes: bytes.byteLength,
hash, sha256: hash,
iv, iv,
storage, storage_name: storage,
issuedAt || movementDate, uploaded_by_id: userId,
expiresAt, issued_at: issuedAt,
imssCompanyId, expires_at: expiresAt,
movementDate, imss_company_id: imssCompanyId,
userId, imss_alta_at: imssAltaAt,
); imss_baja_at: imssBajaAt,
});
if (type === "alta_imss") { if (!env.ok) throw new RpcCallError(env);
const companyId = imssCompanyId ||
(await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined)
?.company_id ||
null;
const company = companyId
? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as
| { id: number; code: string }
| undefined
: undefined;
if (!company) throw new Error("Empresa patrón no válida");
await db.prepare(
`UPDATE workers SET
imss_status = 'alta',
imss_company_id = ?,
imss_alta_at = ?,
imss_baja_at = NULL,
company_id = ?,
hire_type = ?,
updated_at = now()
WHERE id = ?`,
).run(company.id, imssAltaAt, company.id, company.code, workerId);
}
if (type === "baja_imss") {
await db.prepare(
`UPDATE workers SET
imss_status = 'baja_imss',
imss_baja_at = ?,
imss_company_id = NULL,
company_id = NULL,
hire_type = '',
updated_at = now()
WHERE id = ?`,
).run(imssBajaAt, workerId);
}
await refreshPipeline(db, workerId); await refreshPipeline(db, workerId);
} }
@ -357,79 +310,78 @@ async function fetchPhoto(url: string): Promise<Uint8Array | null> {
} }
} }
export type ImportReport = {
inserted: number;
existed: number;
errors: number;
photos: number;
existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[];
error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[];
};
type ImportBatchResult = {
inserted: number;
existed: number;
errors: number;
existed_rows: ImportReport["existed_rows"];
error_rows: ImportReport["error_rows"];
};
function workerToImportRow(
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
sheet: string,
row: number,
projectId: number | null,
) {
return {
sheet,
row,
first_name: input.first_name,
middle_name: input.middle_name,
last_name_p: input.last_name_p,
last_name_m: input.last_name_m,
curp: input.curp,
rfc: input.rfc,
nss: input.nss,
phone: input.phone,
email: input.email,
address: input.address,
blood_type: input.blood_type,
hire_type: input.hire_type,
company_id: input.company_id,
position: input.position,
risk_code: input.risk_code,
work_type: input.work_type,
daily_wage: input.daily_wage,
needs_badge: input.needs_badge,
status: input.status,
project_id: input.status === "activo" ? projectId : null,
tenant_id: input.tenant_id ?? 1,
};
}
async function upsertWorker( async function upsertWorker(
db: Db, db: Db,
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null }, input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
projectId: number | null, projectId: number | null,
) { ) {
const env = await callCoreFn<ImportBatchResult>(db, "core.fn_workers_import_batch", {
tenant_id: input.tenant_id ?? 1,
project_id: projectId,
rows: [workerToImportRow(input, "IMPORT", 1, projectId)],
});
if (!env.ok) throw new RpcCallError(env);
const data = env.data!;
const errorRow = data.error_rows?.[0];
if (errorRow?.messages?.length) throw new Error(errorRow.messages[0]);
const existing = await findExisting(db, input.curp, input.rfc, input.nss); const existing = await findExisting(db, input.curp, input.rfc, input.nss);
if (existing) { if (!existing) throw new Error("No se pudo guardar el trabajador");
await db.prepare( await refreshPipeline(db, existing.id, input.tenant_id ?? null);
`UPDATE workers SET return {
first_name=?, middle_name=?, last_name_p=?, last_name_m=?, id: existing.id,
curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, action: (data.inserted ?? 0) > 0 ? "inserted" as const : "existed" as const,
hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?, matched: data.existed_rows?.[0]?.matched ?? null,
needs_badge=?, status=?, updated_at=now() };
WHERE id=?`,
).run(
input.first_name,
input.middle_name,
input.last_name_p,
input.last_name_m,
input.curp,
input.rfc,
input.nss,
input.phone,
input.email,
input.address,
input.blood_type,
input.hire_type,
input.company_id,
input.tenant_id ?? null,
input.position,
input.risk_code,
input.work_type,
input.daily_wage,
input.needs_badge,
input.status,
existing.id,
);
if (projectId) await assign(db, existing.id, projectId);
await refreshPipeline(db, existing.id, input.tenant_id ?? null);
const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS";
return { id: existing.id, action: "existed" as const, matched };
}
await db.prepare(
`INSERT INTO workers
(first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
).run(
input.first_name,
input.middle_name,
input.last_name_p,
input.last_name_m,
input.curp,
input.rfc,
input.nss,
input.phone,
input.email,
input.address,
input.blood_type,
input.hire_type,
input.company_id,
input.position,
input.risk_code,
input.work_type,
input.daily_wage,
input.needs_badge,
input.status,
input.tenant_id ?? null,
);
const id = await lastInsertId(db);
if (projectId) await assign(db, id, projectId);
await refreshPipeline(db, id, input.tenant_id ?? null);
return { id, action: "inserted" as const, matched: null as string | null };
} }
async function assign(db: Db, workerId: number, projectId: number) { async function assign(db: Db, workerId: number, projectId: number) {
@ -443,15 +395,6 @@ async function assign(db: Db, workerId: number, projectId: number) {
).run(workerId, projectId); ).run(workerId, projectId);
} }
export type ImportReport = {
inserted: number;
existed: number;
errors: number;
photos: number;
existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[];
error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[];
};
export async function importExcel( export async function importExcel(
db: Db, db: Db,
bytes: Uint8Array, bytes: Uint8Array,
@ -496,6 +439,9 @@ export async function importExcel(
return report; return report;
} }
const batchRows: ReturnType<typeof workerToImportRow>[] = [];
const photoJobs: { curp: string; rfc: string; nss: string; url: string; tenant_id: number }[] = [];
for (const job of jobs) { for (const job of jobs) {
for (const { row, data } of job.rows) { for (const { row, data } of job.rows) {
if (isEmptyRow(data)) continue; if (isEmptyRow(data)) continue;
@ -539,28 +485,41 @@ export async function importExcel(
company_id: company!.id, company_id: company!.id,
tenant_id: company!.tenant_id ?? 1, tenant_id: company!.tenant_id ?? 1,
}; };
const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null); batchRows.push(workerToImportRow(n, job.sheet, row, n.status === "activo" ? projectId : null));
if (res.action === "inserted") report.inserted++;
else {
report.existed++;
report.existed_rows.push({
sheet: job.sheet,
row,
nombre,
curp: n.curp,
matched: res.matched || "CURP",
});
}
const url = data["URL FOTO"]; const url = data["URL FOTO"];
if (url) { if (url) {
const photo = await fetchPhoto(url); photoJobs.push({ curp: n.curp, rfc: n.rfc, nss: n.nss, url, tenant_id: n.tenant_id });
if (photo) {
await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId);
report.photos++;
}
} }
} }
} }
if (batchRows.length > 0) {
const env = await callCoreFn<ImportBatchResult>(db, "core.fn_workers_import_batch", {
project_id: projectId,
rows: batchRows,
});
if (!env.ok) throw new RpcCallError(env);
const data = env.data!;
report.inserted = data.inserted ?? 0;
report.existed = data.existed ?? 0;
report.errors += data.errors ?? 0;
report.existed_rows.push(...(data.existed_rows ?? []));
report.error_rows.push(...(data.error_rows ?? []));
for (const row of batchRows) {
const worker = await findExisting(db, row.curp, row.rfc, row.nss);
if (worker) await refreshPipeline(db, worker.id, row.tenant_id ?? null);
}
}
for (const photo of photoJobs) {
const worker = await findExisting(db, photo.curp, photo.rfc, photo.nss);
if (!worker) continue;
const photoBytes = await fetchPhoto(photo.url);
if (photoBytes) {
await storeDocument(db, worker.id, "foto", "foto-import.jpg", "image/jpeg", photoBytes, userId);
report.photos++;
}
}
return report; return report;
} }

130
api/http_errors.ts Normal file
View file

@ -0,0 +1,130 @@
import type { Context } from "hono";
export type RpcCode =
| "OK"
| "CREATED"
| "VALIDATION"
| "UNAUTHORIZED"
| "FORBIDDEN"
| "NOT_FOUND"
| "CONFLICT"
| "INTERNAL"
| "NETWORK";
export type ResponseLayer = "db" | "api" | "front";
export type RpcEnvelope<T = unknown> = {
ok: boolean;
code: RpcCode | string;
layer: ResponseLayer;
message: string;
context?: Record<string, unknown> | null;
data?: T | null;
errors?: Record<string, string> | string[] | null;
};
export type ApiResponse<T = unknown> = RpcEnvelope<T> & { status: number };
const GENERIC_MESSAGES = new Set([
"ok", "error", "operación exitosa", "operacion exitosa", "algo salió mal",
"algo salio mal", "error interno", "error interno del servidor",
]);
export function isGenericMessage(message: string): boolean {
return GENERIC_MESSAGES.has(message.trim().toLowerCase());
}
export function mapRpcToStatus(code: string): number {
switch (code) {
case "OK":
return 200;
case "CREATED":
return 201;
case "VALIDATION":
return 400;
case "UNAUTHORIZED":
return 401;
case "FORBIDDEN":
return 403;
case "NOT_FOUND":
return 404;
case "CONFLICT":
return 409;
case "NETWORK":
return 503;
case "INTERNAL":
default:
return 500;
}
}
export function respondRpc<T>(c: Context, envelope: RpcEnvelope<T>) {
const status = mapRpcToStatus(String(envelope.code));
const body: ApiResponse<T> = { ...envelope, status };
return c.json(body, status as 200 | 201 | 400 | 401 | 403 | 404 | 409 | 500 | 503);
}
export function respondApiError(
c: Context,
code: RpcCode,
message: string,
context?: Record<string, unknown>,
errors?: Record<string, string> | string[] | null,
) {
if (isGenericMessage(message)) {
throw new Error(`API error message is too generic: ${message}`);
}
const status = mapRpcToStatus(code);
const body: ApiResponse = {
ok: false,
code,
status,
layer: "api",
message,
context: context ?? {},
data: null,
errors: errors ?? null,
};
return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 500 | 503);
}
export function enrichInfraError(
fn: string,
route: string,
cause: string,
detail?: string,
): { message: string; context: Record<string, unknown> } {
const message = detail
? `Falló la llamada a ${fn} en ${route}: ${cause} — ${detail}`
: `Falló la llamada a ${fn} en ${route}: ${cause}`;
return {
message,
context: { fn, route, cause },
};
}
export function routeLabel(c: Context): string {
return `${c.req.method} ${c.req.path}`;
}
export function onAppError(err: unknown, c: Context) {
console.error(err);
if (err && typeof err === "object" && (err as RpcEnvelope).layer) {
const env = err as RpcEnvelope;
return respondRpc(c, env);
}
const message = err instanceof Error
? `Error no controlado en ${routeLabel(c)}: ${err.message}`
: `Error no controlado en ${routeLabel(c)}`;
const status = 500;
return c.json({
ok: false,
code: "INTERNAL",
status,
layer: "api",
message,
context: { route: routeLabel(c) },
data: null,
errors: null,
}, status);
}

View file

@ -2,9 +2,11 @@ import type { Context, Hono } from "hono";
import type { AuthUser } from "./auth.ts"; import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts"; import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts"; import { requireCoreAuth } from "./scope.ts";
import { lastInsertId, 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 { fullName } from "./mx.ts"; import { fullName } from "./mx.ts";
import { callCoreFn } from "./rpc.ts";
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
import { import {
addAdminLine, addAdminLine,
addJornalWorker, addJornalWorker,
@ -47,13 +49,13 @@ export function registerPayrollRoutes(app: App) {
const from = c.req.query("from") ?? ""; const from = c.req.query("from") ?? "";
const to = c.req.query("to") ?? ""; const to = c.req.query("to") ?? "";
const db = c.get("db"); const db = c.get("db");
const rows = await db.prepare( const env = await callCoreFn<{ attendance: unknown[] }>(
`SELECT a.*, w.first_name, w.last_name_p FROM attendance a db,
JOIN workers w ON w.id=a.worker_id "core.fn_attendance_list",
WHERE a.project_id=? AND a.work_date BETWEEN ? AND ? { project_id: projectId, from, to },
ORDER BY a.work_date, w.last_name_p`, { route: routeLabel(c) },
).all(projectId, from, to); );
return c.json({ attendance: rows }); return respondRpc(c, env);
}); });
app.put("/v1/attendance", ...requireCoreAuth, async (c) => { app.put("/v1/attendance", ...requireCoreAuth, async (c) => {
@ -69,7 +71,14 @@ export function registerPayrollRoutes(app: App) {
["activo", "pausado"], ["activo", "pausado"],
"No se registra asistencia en un proyecto concluido o cancelado", "No se registra asistencia en un proyecto concluido o cancelado",
); );
if (blocked) return c.json({ error: blocked.error }, blocked.status); if (blocked) {
return respondApiError(
c,
blocked.status === 404 ? "NOT_FOUND" : "VALIDATION",
blocked.error,
{ route: routeLabel(c), project_id: body.project_id },
);
}
const result = await setAttendance(db, tid(c), body); const result = await setAttendance(db, tid(c), body);
if ("error" in result) { if ("error" in result) {
return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400); return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400);
@ -114,11 +123,13 @@ export function registerPayrollRoutes(app: App) {
app.get("/v1/loans", ...requireCoreAuth, async (c) => { app.get("/v1/loans", ...requireCoreAuth, async (c) => {
const db = c.get("db"); const db = c.get("db");
const workerId = c.req.query("worker_id"); const workerId = c.req.query("worker_id");
const sql = workerId const env = await callCoreFn<{ loans: unknown[] }>(
? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC" db,
: "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC"; "core.fn_loan_list",
const loans = workerId ? await db.prepare(sql).all(Number(workerId)) : await db.prepare(sql).all(); workerId ? { worker_id: Number(workerId) } : {},
return c.json({ loans }); { route: routeLabel(c) },
);
return respondRpc(c, env);
}); });
app.post("/v1/loans", ...requireCoreAuth, async (c) => { app.post("/v1/loans", ...requireCoreAuth, async (c) => {
@ -154,10 +165,7 @@ export function registerPayrollRoutes(app: App) {
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => { app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const loan = await db.prepare( const env = await callCoreFn<{ loan: {
`SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m
FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`,
).get(id) as {
delivered: number; delivered: number;
commission_pct: number; commission_pct: number;
commission_amount: number; commission_amount: number;
@ -172,8 +180,14 @@ export function registerPayrollRoutes(app: App) {
last_name_p: string; last_name_p: string;
last_name_m: string; last_name_m: string;
note: string | null; note: string | null;
} | undefined; } }>(
if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404); db,
"core.fn_loan_get",
{ id },
{ route: routeLabel(c) },
);
if (!env.ok) return respondRpc(c, env);
const loan = env.data!.loan;
const bytes = await generateLoanReceiptPdf({ const bytes = await generateLoanReceiptPdf({
title: "Recibo de aceptación de préstamo", title: "Recibo de aceptación de préstamo",
subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`, subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`,
@ -202,14 +216,7 @@ export function registerPayrollRoutes(app: App) {
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => { app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const pay = await db.prepare( const env = await callCoreFn<{ payment: {
`SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, wk.week_end
FROM loan_payments p
JOIN loans l ON l.id=p.loan_id
JOIN workers w ON w.id=l.worker_id
JOIN payroll_weeks wk ON wk.id=p.week_id
WHERE p.id=?`,
).get(id) as {
amount: number; amount: number;
installment_n: number; installment_n: number;
label: string; label: string;
@ -220,8 +227,14 @@ export function registerPayrollRoutes(app: App) {
last_name_p: string; last_name_p: string;
last_name_m: string; last_name_m: string;
week_end: string; week_end: string;
} | undefined; } }>(
if (!pay) return c.json({ error: "Recibo no encontrado" }, 404); db,
"core.fn_loan_payment_get",
{ id },
{ route: routeLabel(c) },
);
if (!env.ok) return respondRpc(c, env);
const pay = env.data!.payment;
const bytes = await generateLoanReceiptPdf({ const bytes = await generateLoanReceiptPdf({
title: "Recibo de pago de préstamo", title: "Recibo de pago de préstamo",
subtitle: `Descuento del sábado ${pay.week_end}`, subtitle: `Descuento del sábado ${pay.week_end}`,
@ -380,65 +393,63 @@ export function registerPayrollRoutes(app: App) {
["activo", "pausado"], ["activo", "pausado"],
"No se genera nómina de un proyecto concluido o cancelado", "No se genera nómina de un proyecto concluido o cancelado",
); );
if (blocked) return c.json({ error: blocked.error }, blocked.status); if (blocked) {
await db.prepare( return respondApiError(
"INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')", c,
).run(body.project_id, body.week_start, body.week_end); blocked.status === 404 ? "NOT_FOUND" : "VALIDATION",
const periodId = await lastInsertId(db); blocked.error,
const workers = await db.prepare( { route: routeLabel(c), project_id: body.project_id },
`SELECT w.id, w.daily_wage FROM workers w );
JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=true
WHERE w.status='activo'`,
).all(body.project_id) as { id: number; daily_wage: number }[];
const ins = db.prepare(
`INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
);
for (const w of workers) {
const att = await db.prepare(
`SELECT COUNT(*) AS n FROM attendance
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`,
).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number };
const days = Number(att.n);
const gross = days * w.daily_wage;
const extra = Number(body.extra_discounts?.[w.id] ?? 0);
const net = gross - extra;
await ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net);
} }
return c.json({ id: periodId }); const env = await callCoreFn<{ id: number }>(
db,
"core.fn_payroll_period_create",
{
project_id: body.project_id,
week_start: body.week_start,
week_end: body.week_end,
extra_discounts: body.extra_discounts ?? {},
},
{ route: routeLabel(c) },
);
return respondRpc(c, env);
}); });
app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => { app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
const db = c.get("db"); const db = c.get("db");
const projectId = c.req.query("project_id"); const projectId = c.req.query("project_id");
const sql = projectId const env = await callCoreFn<{ periods: unknown[] }>(
? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC` db,
: `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`; "core.fn_payroll_period_list",
const periods = projectId ? await db.prepare(sql).all(Number(projectId)) : await db.prepare(sql).all(); projectId ? { project_id: Number(projectId) } : {},
return c.json({ periods }); { route: routeLabel(c) },
);
return respondRpc(c, env);
}); });
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => { app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const period = await db.prepare( const env = await callCoreFn<{ period: unknown; lines: unknown[] }>(
`SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`, db,
).get(id); "core.fn_payroll_period_get",
if (!period) return c.json({ error: "Periodo no encontrado" }, 404); { id },
const lines = await db.prepare( { route: routeLabel(c) },
`SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l );
JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`, return respondRpc(c, env);
).all(id);
return c.json({ period, lines });
}); });
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => { app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id")); const id = Number(c.req.param("id"));
const db = c.get("db"); const db = c.get("db");
const lines = await db.prepare( const env = await callCoreFn<{ period: unknown; lines: Array<Record<string, unknown>> }>(
`SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net db,
FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`, "core.fn_payroll_period_get",
).all(id) as Record<string, unknown>[]; { id },
{ route: routeLabel(c) },
);
if (!env.ok) return respondRpc(c, env);
const lines = env.data?.lines ?? [];
const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto"; const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto";
const rows = lines.map((l) => const rows = lines.map((l) =>
[l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",") [l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",")
@ -453,7 +464,12 @@ export function registerPayrollRoutes(app: App) {
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 }>();
const db = c.get("db"); const db = c.get("db");
await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); const env = await callCoreFn(
return c.json({ ok: true }); db,
"core.fn_payroll_period_patch",
{ id, status },
{ route: routeLabel(c) },
);
return respondRpc(c, env);
}); });
} }

125
api/rpc.ts Normal file
View file

@ -0,0 +1,125 @@
import type { Db } from "./db.ts";
import type { RpcEnvelope } from "./http_errors.ts";
import { enrichInfraError } from "./http_errors.ts";
const DEADLOCK = "40P01";
export class RpcCallError extends Error {
constructor(
readonly envelope: RpcEnvelope,
) {
super(envelope.message);
this.name = "RpcCallError";
}
}
function isEnvelope(value: unknown): value is RpcEnvelope {
if (!value || typeof value !== "object") return false;
const o = value as Record<string, unknown>;
return typeof o.ok === "boolean"
&& typeof o.code === "string"
&& typeof o.message === "string"
&& typeof o.layer === "string";
}
function postgresCode(e: unknown): string | undefined {
if (!e || typeof e !== "object") return undefined;
return (e as { code?: string }).code;
}
function postgresMessage(e: unknown): string {
if (e instanceof Error) return e.message;
return String(e);
}
async function invokeOnce<T>(
db: Db,
fn: string,
payload: Record<string, unknown>,
): Promise<RpcEnvelope<T>> {
const row = await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get(
JSON.stringify(payload),
) as { result: unknown } | undefined;
const raw = row?.result;
if (typeof raw === "string") {
try {
const parsed = JSON.parse(raw) as RpcEnvelope<T>;
if (isEnvelope(parsed)) return parsed;
} catch {
/* fall through */
}
}
if (isEnvelope(raw)) return raw as RpcEnvelope<T>;
throw new Error(`La función ${fn} no devolvió un envelope RPC válido`);
}
export async function callCoreFn<T = unknown>(
db: Db,
fn: string,
payload: Record<string, unknown> = {},
opts: { route?: string; retries?: number } = {},
): Promise<RpcEnvelope<T>> {
const route = opts.route ?? fn;
const retries = opts.retries ?? 1;
let lastErr: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const envelope = await invokeOnce<T>(db, fn, payload);
if (!envelope.ok) return envelope;
return envelope;
} catch (e) {
lastErr = e;
const code = postgresCode(e);
if (code === DEADLOCK && attempt < retries) continue;
const infra = enrichInfraError(
fn,
route,
code ?? "exception",
postgresMessage(e),
);
return {
ok: false,
code: code === DEADLOCK ? "CONFLICT" : "INTERNAL",
layer: "api",
message: infra.message,
context: { ...infra.context, sqlstate: code ?? null },
data: null,
errors: null,
};
}
}
const infra = enrichInfraError(fn, route, "exception", postgresMessage(lastErr));
return {
ok: false,
code: "INTERNAL",
layer: "api",
message: infra.message,
context: infra.context,
data: null,
errors: null,
};
}
export async function callCoreFnData<T>(
db: Db,
fn: string,
payload: Record<string, unknown> = {},
opts: { route?: string } = {},
): Promise<T> {
const envelope = await callCoreFn<T>(db, fn, payload, opts);
if (!envelope.ok) throw new RpcCallError(envelope);
return envelope.data as T;
}
export function unwrapRpc<T>(
envelope: RpcEnvelope<T>,
): { data?: T; error?: string; code?: string; status?: number } {
if (envelope.ok) return { data: envelope.data as T };
return {
error: envelope.message,
code: String(envelope.code),
status: envelope.code === "NOT_FOUND" ? 404
: envelope.code === "CONFLICT" ? 409
: 400,
};
}

View file

@ -0,0 +1,147 @@
--liquibase formatted sql
-- PANELS · core · RPC infrastructure (envelope helpers)
--changeset panel:core-006a-rpc-generic-messages endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.rpc_generic_messages()
RETURNS text[]
LANGUAGE sql
IMMUTABLE
AS $$
SELECT ARRAY[
'OK', 'Error', 'ERROR', 'Operación exitosa', 'Operacion exitosa',
'Algo salió mal', 'Algo salio mal', 'Error interno', 'Error interno del servidor'
]::text[];
$$;
--changeset panel:core-006b-rpc-assert-message endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.rpc_assert_message(p_message text)
RETURNS void
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
IF p_message IS NULL OR btrim(p_message) = '' THEN
RAISE EXCEPTION 'RPC message must not be empty';
END IF;
IF p_message = ANY (core.rpc_generic_messages()) THEN
RAISE EXCEPTION 'RPC message is too generic: %', p_message;
END IF;
END;
$$;
--changeset panel:core-006c-rpc-ok endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.rpc_ok(
p_data jsonb,
p_message text,
p_ctx jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
PERFORM core.rpc_assert_message(p_message);
RETURN jsonb_build_object(
'ok', true,
'code', 'OK',
'layer', 'db',
'message', p_message,
'context', COALESCE(p_ctx, '{}'::jsonb),
'data', p_data,
'errors', NULL
);
END;
$$;
--changeset panel:core-006d-rpc-created endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.rpc_created(
p_data jsonb,
p_message text,
p_ctx jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
PERFORM core.rpc_assert_message(p_message);
RETURN jsonb_build_object(
'ok', true,
'code', 'CREATED',
'layer', 'db',
'message', p_message,
'context', COALESCE(p_ctx, '{}'::jsonb),
'data', p_data,
'errors', NULL
);
END;
$$;
--changeset panel:core-006e-rpc-err endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.rpc_err(
p_code text,
p_message text,
p_ctx jsonb DEFAULT '{}'::jsonb,
p_errors jsonb DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
AS $$
BEGIN
PERFORM core.rpc_assert_message(p_message);
RETURN jsonb_build_object(
'ok', false,
'code', p_code,
'layer', 'db',
'message', p_message,
'context', COALESCE(p_ctx, '{}'::jsonb),
'data', NULL,
'errors', p_errors
);
END;
$$;
--changeset panel:core-006f-rpc-map-exception endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.rpc_from_exception(
p_fn text,
p_sqlstate text,
p_message text,
p_detail text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_code text := 'INTERNAL';
v_msg text;
v_ctx jsonb := jsonb_build_object('fn', p_fn, 'sqlstate', p_sqlstate);
BEGIN
IF p_sqlstate = '23505' THEN
v_code := 'CONFLICT';
v_msg := format('CONFLICT en %s: %s', p_fn, COALESCE(p_detail, p_message));
ELSIF p_sqlstate = '23503' THEN
v_code := 'VALIDATION';
v_msg := format('Referencia inválida en %s: %s', p_fn, COALESCE(p_detail, p_message));
ELSIF p_sqlstate = '23514' THEN
v_code := 'VALIDATION';
v_msg := format('Restricción de valor en %s: %s', p_fn, COALESCE(p_detail, p_message));
ELSIF p_sqlstate = '40P01' THEN
v_code := 'CONFLICT';
v_msg := format('Deadlock en %s: otra operación modificó los mismos registros, intente de nuevo', p_fn);
ELSE
v_code := 'INTERNAL';
v_msg := format('Error inesperado en %s (sqlstate=%s): %s', p_fn, p_sqlstate, p_message);
END IF;
RETURN core.rpc_err(v_code, v_msg, v_ctx);
END;
$$;
--changeset panel:core-006g-rpc-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.rpc_generic_messages() TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.rpc_assert_message(text) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_core_app;

View file

@ -0,0 +1,123 @@
--liquibase formatted sql
-- PANELS · core · RPC catálogos y configuración tenant
--changeset panel:core-007a-fn-catalogs endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_catalogs(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_risks jsonb;
v_themes jsonb;
v_doc_types jsonb;
v_proj_doc_types jsonb;
v_co_doc_types jsonb;
v_companies jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY r.code), '[]'::jsonb)
INTO v_risks FROM risk_levels r;
SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.id), '[]'::jsonb)
INTO v_themes FROM badge_themes t;
SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.code), '[]'::jsonb)
INTO v_doc_types FROM document_types d;
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.code), '[]'::jsonb)
INTO v_proj_doc_types FROM project_document_types p;
SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.code), '[]'::jsonb)
INTO v_co_doc_types FROM company_document_types c;
SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.kind, lower(x.name)), '[]'::jsonb)
INTO v_companies
FROM (
SELECT c.*, p.code AS parent_code, p.name AS parent_name,
(SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count
FROM companies c
LEFT JOIN companies p ON p.id = c.parent_id
ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, lower(c.name)
) x;
RETURN core.rpc_ok(
jsonb_build_object(
'risks', v_risks,
'themes', v_themes,
'document_types', v_doc_types,
'project_document_types', v_proj_doc_types,
'company_document_types', v_co_doc_types,
'companies', v_companies
),
format('Catálogos cargados: %s empresas, %s tipos de documento de personal',
jsonb_array_length(v_companies), jsonb_array_length(v_doc_types)),
jsonb_build_object('fn', 'fn_catalogs', 'company_count', jsonb_array_length(v_companies))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_catalogs', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-007b-fn-tenant-timezone-get endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_tenant_timezone_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := (payload->>'tenant_id')::integer;
v_tz text;
BEGIN
IF v_tid IS NULL THEN
RETURN core.rpc_ok(
jsonb_build_object('timezone', 'America/Mexico_City'),
'Zona horaria por defecto America/Mexico_City (sin tenant_id en la solicitud)',
jsonb_build_object('fn', 'fn_tenant_timezone_get', 'tenant_id', NULL)
);
END IF;
SELECT timezone INTO v_tz FROM tenant_settings WHERE tenant_id = v_tid;
v_tz := COALESCE(v_tz, 'America/Mexico_City');
RETURN core.rpc_ok(
jsonb_build_object('timezone', v_tz),
format('Zona horaria del tenant %s: %s', v_tid, v_tz),
jsonb_build_object('fn', 'fn_tenant_timezone_get', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_tenant_timezone_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-007c-fn-tenant-settings-upsert endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_tenant_settings_upsert(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := (payload->>'tenant_id')::integer;
v_tz text := btrim(payload->>'timezone');
BEGIN
IF v_tid IS NULL THEN
RETURN core.rpc_err('VALIDATION',
'fn_tenant_settings_upsert: tenant_id es obligatorio para guardar configuración',
jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'field', 'tenant_id'));
END IF;
IF v_tz IS NULL OR v_tz = '' THEN
RETURN core.rpc_err('VALIDATION',
format('fn_tenant_settings_upsert: timezone vacío para tenant_id=%s', v_tid),
jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'tenant_id', v_tid, 'field', 'timezone'));
END IF;
INSERT INTO tenant_settings (tenant_id, timezone, updated_at)
VALUES (v_tid, v_tz, now())
ON CONFLICT (tenant_id) DO UPDATE SET timezone = EXCLUDED.timezone, updated_at = now();
RETURN core.rpc_ok(
jsonb_build_object('timezone', v_tz),
format('Zona horaria del tenant %s actualizada a %s', v_tid, v_tz),
jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_tenant_settings_upsert', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-007d-fn-catalogs-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_catalogs(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_tenant_timezone_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_tenant_settings_upsert(jsonb) TO panels_core_app;

View file

@ -1636,6 +1636,371 @@ EXCEPTION WHEN OTHERS THEN
END; END;
$$; $$;
--changeset panel:core-013i2-fn-payroll-week-list-open endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_week_list_open(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := (payload->>'tenant_id')::integer;
v_weeks jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(w) ORDER BY w.week_start DESC), '[]'::jsonb)
INTO v_weeks
FROM (
SELECT pw.*,
(SELECT COALESCE(SUM(l.loan_discount), 0)
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
WHERE s.week_id = pw.id) AS loan_recovery
FROM payroll_weeks pw
WHERE pw.tenant_id = v_tid AND pw.status != 'paid'
) w;
RETURN core.rpc_ok(
jsonb_build_object('weeks', v_weeks),
format('Semanas abiertas listadas para tenant %s: %s semana(s)', v_tid, jsonb_array_length(v_weeks)),
jsonb_build_object('fn', 'fn_payroll_week_list_open', 'tenant_id', v_tid)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_list_open', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013i3-fn-payroll-week-csv endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_week_csv(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_week_id bigint := (payload->>'week_id')::bigint;
v_csv text;
BEGIN
IF v_week_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_payroll_week_csv: week_id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_week_csv', 'field', 'week_id'));
END IF;
SELECT string_agg(row_text, E'\n' ORDER BY ord)
INTO v_csv
FROM (
SELECT 0 AS ord, 'Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar' AS row_text
UNION ALL
SELECT 1 AS ord,
concat_ws(',',
w.first_name, w.last_name_p, s.kind, COALESCE(p.name, ''),
l.days, l.daily_wage, COALESCE(l.concepto, ''),
COALESCE(l.qty_actual, 0) + COALESCE(l.qty_extra, 0),
COALESCE(l.unit_code, ''), l.gross, l.discounts, l.loan_discount,
replace(COALESCE(l.loan_label, ''), ',', ' '),
l.required_net, l.payable_net
)
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id = l.sheet_id
JOIN workers w ON w.id = l.worker_id
LEFT JOIN projects p ON p.id = s.project_id
WHERE s.week_id = v_week_id
ORDER BY s.kind, p.name, w.last_name_p
) q;
RETURN core.rpc_ok(
jsonb_build_object('csv', v_csv),
format('CSV de nómina generado para semana id=%s', v_week_id),
jsonb_build_object('fn', 'fn_payroll_week_csv', 'week_id', v_week_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_week_csv', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013i4-fn-loan-read endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_loan_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_loans jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.id DESC), '[]'::jsonb)
INTO v_loans
FROM (
SELECT l.*, w.first_name, w.last_name_p
FROM loans l
JOIN workers w ON w.id = l.worker_id
WHERE v_worker_id IS NULL OR l.worker_id = v_worker_id
) x;
RETURN core.rpc_ok(
jsonb_build_object('loans', v_loans),
format('Préstamos listados: %s registro(s)', jsonb_array_length(v_loans)),
jsonb_build_object('fn', 'fn_loan_list', 'worker_id', v_worker_id, 'count', jsonb_array_length(v_loans))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_loan_list', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_loan_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_loan jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_loan_get: id es obligatorio',
jsonb_build_object('fn', 'fn_loan_get', 'field', 'id')
);
END IF;
SELECT row_to_json(x)::jsonb INTO v_loan
FROM (
SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m
FROM loans l
JOIN workers w ON w.id = l.worker_id
WHERE l.id = v_id
) x;
IF v_loan IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
'Préstamo no encontrado',
jsonb_build_object('fn', 'fn_loan_get', 'id', v_id)
);
END IF;
RETURN core.rpc_ok(
jsonb_build_object('loan', v_loan),
format('Préstamo id=%s cargado', v_id),
jsonb_build_object('fn', 'fn_loan_get', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_loan_get', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_loan_payment_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_payment jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_loan_payment_get: id es obligatorio',
jsonb_build_object('fn', 'fn_loan_payment_get', 'field', 'id')
);
END IF;
SELECT row_to_json(x)::jsonb INTO v_payment
FROM (
SELECT p.*, l.delivered, l.commission_pct,
w.first_name, w.middle_name, w.last_name_p, w.last_name_m,
wk.week_end
FROM loan_payments p
JOIN loans l ON l.id = p.loan_id
JOIN workers w ON w.id = l.worker_id
JOIN payroll_weeks wk ON wk.id = p.week_id
WHERE p.id = v_id
) x;
IF v_payment IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
'Recibo no encontrado',
jsonb_build_object('fn', 'fn_loan_payment_get', 'id', v_id)
);
END IF;
RETURN core.rpc_ok(
jsonb_build_object('payment', v_payment),
format('Pago de préstamo id=%s cargado', v_id),
jsonb_build_object('fn', 'fn_loan_payment_get', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_loan_payment_get', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013i5-fn-payroll-period endDelimiter:; splitStatements:true
CREATE OR REPLACE FUNCTION core.fn_payroll_period_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_week_start date := NULLIF(btrim(payload->>'week_start'), '')::date;
v_week_end date := NULLIF(btrim(payload->>'week_end'), '')::date;
v_extra jsonb := COALESCE(payload->'extra_discounts', '{}'::jsonb);
v_period_id bigint;
v_worker record;
v_att_n integer;
v_days integer;
v_gross numeric;
v_extra_disc numeric;
v_net numeric;
BEGIN
IF v_project_id IS NULL OR v_week_start IS NULL OR v_week_end IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_period_create: project_id, week_start y week_end son obligatorios',
jsonb_build_object('fn', 'fn_payroll_period_create')
);
END IF;
INSERT INTO payroll_periods (project_id, week_start, week_end, status)
VALUES (v_project_id, v_week_start, v_week_end, 'draft')
RETURNING id INTO v_period_id;
FOR v_worker IN
SELECT w.id, w.daily_wage
FROM workers w
JOIN assignments a ON a.worker_id = w.id AND a.project_id = v_project_id AND a.active = true
WHERE w.status = 'activo'
LOOP
SELECT COUNT(*)::integer INTO v_att_n
FROM attendance
WHERE worker_id = v_worker.id
AND project_id = v_project_id
AND work_date BETWEEN v_week_start AND v_week_end
AND present = true;
v_days := v_att_n;
v_gross := v_days * v_worker.daily_wage;
v_extra_disc := COALESCE((v_extra->>v_worker.id::text)::numeric, 0);
v_net := v_gross - v_extra_disc;
INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net)
VALUES (v_period_id, v_worker.id, v_days, v_worker.daily_wage, v_gross, v_extra_disc, 0, v_net);
END LOOP;
RETURN core.rpc_created(
jsonb_build_object('id', v_period_id),
format('Periodo de nómina %s creado para proyecto %s', v_period_id, v_project_id),
jsonb_build_object('fn', 'fn_payroll_period_create', 'id', v_period_id, 'project_id', v_project_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_period_create', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_period_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_periods jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.id DESC), '[]'::jsonb)
INTO v_periods
FROM (
SELECT pe.*, p.name AS project_name
FROM payroll_periods pe
JOIN projects p ON p.id = pe.project_id
WHERE v_project_id IS NULL OR pe.project_id = v_project_id
) x;
RETURN core.rpc_ok(
jsonb_build_object('periods', v_periods),
format('Periodos de nómina listados: %s registro(s)', jsonb_array_length(v_periods)),
jsonb_build_object('fn', 'fn_payroll_period_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_periods))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_period_list', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_period_get(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_period jsonb;
v_lines jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_period_get: id es obligatorio',
jsonb_build_object('fn', 'fn_payroll_period_get', 'field', 'id')
);
END IF;
SELECT row_to_json(x)::jsonb INTO v_period
FROM (
SELECT pe.*, p.name AS project_name
FROM payroll_periods pe
JOIN projects p ON p.id = pe.project_id
WHERE pe.id = v_id
) x;
IF v_period IS NULL THEN
RETURN core.rpc_err(
'NOT_FOUND',
'Periodo no encontrado',
jsonb_build_object('fn', 'fn_payroll_period_get', 'id', v_id)
);
END IF;
SELECT COALESCE(jsonb_agg(row_to_json(lx)::jsonb ORDER BY lx.last_name_p), '[]'::jsonb)
INTO v_lines
FROM (
SELECT l.*, w.first_name, w.last_name_p, w.position
FROM payroll_lines l
JOIN workers w ON w.id = l.worker_id
WHERE l.period_id = v_id
) lx;
RETURN core.rpc_ok(
jsonb_build_object('period', v_period, 'lines', v_lines),
format('Periodo de nómina %s cargado con %s línea(s)', v_id, jsonb_array_length(v_lines)),
jsonb_build_object('fn', 'fn_payroll_period_get', 'id', v_id, 'line_count', jsonb_array_length(v_lines))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_period_get', SQLSTATE, SQLERRM);
END;
$$;
CREATE OR REPLACE FUNCTION core.fn_payroll_period_patch(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_status text := btrim(COALESCE(payload->>'status', ''));
BEGIN
IF v_id IS NULL OR v_status = '' THEN
RETURN core.rpc_err(
'VALIDATION',
'fn_payroll_period_patch: id y status son obligatorios',
jsonb_build_object('fn', 'fn_payroll_period_patch')
);
END IF;
IF NOT EXISTS (SELECT 1 FROM payroll_periods WHERE id = v_id) THEN
RETURN core.rpc_err(
'NOT_FOUND',
'Periodo no encontrado',
jsonb_build_object('fn', 'fn_payroll_period_patch', 'id', v_id)
);
END IF;
UPDATE payroll_periods SET status = v_status WHERE id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('ok', true, 'id', v_id, 'status', v_status),
format('Periodo de nómina %s actualizado a estado %s', v_id, v_status),
jsonb_build_object('fn', 'fn_payroll_period_patch', 'id', v_id, 'status', v_status)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_period_patch', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-013j-fn-payroll-grants endDelimiter:; splitStatements:true --changeset panel:core-013j-fn-payroll-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.round_money(numeric) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.round_money(numeric) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_settings_get(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_settings_get(jsonb) TO panels_core_app;
@ -1656,3 +2021,12 @@ GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_add(jsonb) TO panels_core_a
GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_update(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_update(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_remove(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_remove(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_jornal_worker_add(jsonb) TO panels_core_app; GRANT EXECUTE ON FUNCTION core.fn_payroll_jornal_worker_add(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_list_open(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_week_csv(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_loan_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_loan_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_loan_payment_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_period_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_period_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_period_get(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_payroll_period_patch(jsonb) TO panels_core_app;