mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 15:33:17 +00:00
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:
parent
d2be5c5b76
commit
8717c00a83
7 changed files with 1106 additions and 232 deletions
271
api/excel.ts
271
api/excel.ts
|
|
@ -3,9 +3,10 @@ import type { Db } from "./db.ts";
|
|||
import { encryptBytes } from "./docs_crypto.ts";
|
||||
import { sha256Hex } from "./crypto.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 { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts";
|
||||
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||
|
||||
export const IMPORT_COLUMNS = [
|
||||
"NOMBRE",
|
||||
|
|
@ -214,82 +215,34 @@ export async function storeDocument(
|
|||
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 hash = await sha256Hex(bytes);
|
||||
const storage = `${crypto.randomUUID()}.enc`;
|
||||
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 expiresAt = meta.expires_at || null;
|
||||
const imssCompanyId = meta.imss_company_id || null;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null);
|
||||
const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null);
|
||||
const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt;
|
||||
|
||||
await db.prepare(
|
||||
`INSERT INTO documents
|
||||
(worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current,
|
||||
parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by_id, uploaded_by_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`,
|
||||
).run(
|
||||
workerId,
|
||||
type,
|
||||
filename,
|
||||
const env = await callCoreFn(db, "core.fn_worker_document_store", {
|
||||
worker_id: workerId,
|
||||
type_code: type,
|
||||
original_name: filename,
|
||||
mime,
|
||||
bytes.byteLength,
|
||||
hash,
|
||||
size_bytes: bytes.byteLength,
|
||||
sha256: hash,
|
||||
iv,
|
||||
storage,
|
||||
issuedAt || movementDate,
|
||||
expiresAt,
|
||||
imssCompanyId,
|
||||
movementDate,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (type === "alta_imss") {
|
||||
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);
|
||||
}
|
||||
storage_name: storage,
|
||||
uploaded_by_id: userId,
|
||||
issued_at: issuedAt,
|
||||
expires_at: expiresAt,
|
||||
imss_company_id: imssCompanyId,
|
||||
imss_alta_at: imssAltaAt,
|
||||
imss_baja_at: imssBajaAt,
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
|
||||
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(
|
||||
db: Db,
|
||||
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: 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);
|
||||
if (existing) {
|
||||
await db.prepare(
|
||||
`UPDATE workers SET
|
||||
first_name=?, middle_name=?, last_name_p=?, last_name_m=?,
|
||||
curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?,
|
||||
hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?,
|
||||
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);
|
||||
if (!existing) throw new Error("No se pudo guardar el trabajador");
|
||||
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 };
|
||||
return {
|
||||
id: existing.id,
|
||||
action: (data.inserted ?? 0) > 0 ? "inserted" as const : "existed" as const,
|
||||
matched: data.existed_rows?.[0]?.matched ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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(
|
||||
db: Db,
|
||||
bytes: Uint8Array,
|
||||
|
|
@ -496,6 +439,9 @@ export async function importExcel(
|
|||
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 { row, data } of job.rows) {
|
||||
if (isEmptyRow(data)) continue;
|
||||
|
|
@ -539,28 +485,41 @@ export async function importExcel(
|
|||
company_id: company!.id,
|
||||
tenant_id: company!.tenant_id ?? 1,
|
||||
};
|
||||
const res = await upsertWorker(db, n, 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",
|
||||
});
|
||||
}
|
||||
batchRows.push(workerToImportRow(n, job.sheet, row, n.status === "activo" ? projectId : null));
|
||||
const url = data["URL FOTO"];
|
||||
if (url) {
|
||||
const photo = await fetchPhoto(url);
|
||||
if (photo) {
|
||||
await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId);
|
||||
photoJobs.push({ curp: n.curp, rfc: n.rfc, nss: n.nss, url, tenant_id: n.tenant_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
130
api/http_errors.ts
Normal file
130
api/http_errors.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -2,9 +2,11 @@ import type { Context, Hono } from "hono";
|
|||
import type { AuthUser } from "./auth.ts";
|
||||
import { tenantScope } from "./auth.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 { fullName } from "./mx.ts";
|
||||
import { callCoreFn } from "./rpc.ts";
|
||||
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
|
||||
import {
|
||||
addAdminLine,
|
||||
addJornalWorker,
|
||||
|
|
@ -47,13 +49,13 @@ export function registerPayrollRoutes(app: App) {
|
|||
const from = c.req.query("from") ?? "";
|
||||
const to = c.req.query("to") ?? "";
|
||||
const db = c.get("db");
|
||||
const rows = await db.prepare(
|
||||
`SELECT a.*, w.first_name, w.last_name_p FROM attendance a
|
||||
JOIN workers w ON w.id=a.worker_id
|
||||
WHERE a.project_id=? AND a.work_date BETWEEN ? AND ?
|
||||
ORDER BY a.work_date, w.last_name_p`,
|
||||
).all(projectId, from, to);
|
||||
return c.json({ attendance: rows });
|
||||
const env = await callCoreFn<{ attendance: unknown[] }>(
|
||||
db,
|
||||
"core.fn_attendance_list",
|
||||
{ project_id: projectId, from, to },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.put("/v1/attendance", ...requireCoreAuth, async (c) => {
|
||||
|
|
@ -69,7 +71,14 @@ export function registerPayrollRoutes(app: App) {
|
|||
["activo", "pausado"],
|
||||
"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);
|
||||
if ("error" in result) {
|
||||
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) => {
|
||||
const db = c.get("db");
|
||||
const workerId = c.req.query("worker_id");
|
||||
const sql = workerId
|
||||
? "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"
|
||||
: "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";
|
||||
const loans = workerId ? await db.prepare(sql).all(Number(workerId)) : await db.prepare(sql).all();
|
||||
return c.json({ loans });
|
||||
const env = await callCoreFn<{ loans: unknown[] }>(
|
||||
db,
|
||||
"core.fn_loan_list",
|
||||
workerId ? { worker_id: Number(workerId) } : {},
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const loan = await db.prepare(
|
||||
`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 {
|
||||
const env = await callCoreFn<{ loan: {
|
||||
delivered: number;
|
||||
commission_pct: number;
|
||||
commission_amount: number;
|
||||
|
|
@ -172,8 +180,14 @@ export function registerPayrollRoutes(app: App) {
|
|||
last_name_p: string;
|
||||
last_name_m: string;
|
||||
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({
|
||||
title: "Recibo de aceptación de préstamo",
|
||||
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) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const pay = await db.prepare(
|
||||
`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 {
|
||||
const env = await callCoreFn<{ payment: {
|
||||
amount: number;
|
||||
installment_n: number;
|
||||
label: string;
|
||||
|
|
@ -220,8 +227,14 @@ export function registerPayrollRoutes(app: App) {
|
|||
last_name_p: string;
|
||||
last_name_m: 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({
|
||||
title: "Recibo de pago de préstamo",
|
||||
subtitle: `Descuento del sábado ${pay.week_end}`,
|
||||
|
|
@ -380,65 +393,63 @@ export function registerPayrollRoutes(app: App) {
|
|||
["activo", "pausado"],
|
||||
"No se genera nómina de un proyecto concluido o cancelado",
|
||||
);
|
||||
if (blocked) return c.json({ error: blocked.error }, blocked.status);
|
||||
await db.prepare(
|
||||
"INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')",
|
||||
).run(body.project_id, body.week_start, body.week_end);
|
||||
const periodId = await lastInsertId(db);
|
||||
const workers = await db.prepare(
|
||||
`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 (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
if (blocked) {
|
||||
return respondApiError(
|
||||
c,
|
||||
blocked.status === 404 ? "NOT_FOUND" : "VALIDATION",
|
||||
blocked.error,
|
||||
{ route: routeLabel(c), project_id: body.project_id },
|
||||
);
|
||||
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) => {
|
||||
const db = c.get("db");
|
||||
const projectId = c.req.query("project_id");
|
||||
const sql = projectId
|
||||
? `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`
|
||||
: `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`;
|
||||
const periods = projectId ? await db.prepare(sql).all(Number(projectId)) : await db.prepare(sql).all();
|
||||
return c.json({ periods });
|
||||
const env = await callCoreFn<{ periods: unknown[] }>(
|
||||
db,
|
||||
"core.fn_payroll_period_list",
|
||||
projectId ? { project_id: Number(projectId) } : {},
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const period = await db.prepare(
|
||||
`SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`,
|
||||
).get(id);
|
||||
if (!period) return c.json({ error: "Periodo no encontrado" }, 404);
|
||||
const lines = await db.prepare(
|
||||
`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`,
|
||||
).all(id);
|
||||
return c.json({ period, lines });
|
||||
const env = await callCoreFn<{ period: unknown; lines: unknown[] }>(
|
||||
db,
|
||||
"core.fn_payroll_period_get",
|
||||
{ id },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const lines = await db.prepare(
|
||||
`SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net
|
||||
FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`,
|
||||
).all(id) as Record<string, unknown>[];
|
||||
const env = await callCoreFn<{ period: unknown; lines: Array<Record<string, unknown>> }>(
|
||||
db,
|
||||
"core.fn_payroll_period_get",
|
||||
{ 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 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(",")
|
||||
|
|
@ -453,7 +464,12 @@ export function registerPayrollRoutes(app: App) {
|
|||
const id = Number(c.req.param("id"));
|
||||
const { status } = await c.req.json<{ status: string }>();
|
||||
const db = c.get("db");
|
||||
await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id);
|
||||
return c.json({ ok: true });
|
||||
const env = await callCoreFn(
|
||||
db,
|
||||
"core.fn_payroll_period_patch",
|
||||
{ id, status },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
125
api/rpc.ts
Normal file
125
api/rpc.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
147
db/core/changesets/006-rpc-infra.sql
Normal file
147
db/core/changesets/006-rpc-infra.sql
Normal 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;
|
||||
123
db/core/changesets/007-rpc-catalogs.sql
Normal file
123
db/core/changesets/007-rpc-catalogs.sql
Normal 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;
|
||||
|
|
@ -1636,6 +1636,371 @@ EXCEPTION WHEN OTHERS THEN
|
|||
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
|
||||
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;
|
||||
|
|
@ -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_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_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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue