mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:13:18 +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
cff428d283
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 { 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(
|
|
||||||
`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);
|
|
||||||
await refreshPipeline(db, existing.id, input.tenant_id ?? null);
|
await refreshPipeline(db, existing.id, input.tenant_id ?? null);
|
||||||
const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS";
|
return {
|
||||||
return { id: existing.id, action: "existed" as const, matched };
|
id: existing.id,
|
||||||
}
|
action: (data.inserted ?? 0) > 0 ? "inserted" as const : "existed" as const,
|
||||||
await db.prepare(
|
matched: data.existed_rows?.[0]?.matched ?? null,
|
||||||
`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);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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++;
|
report.photos++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
return report;
|
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 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
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;
|
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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue