diff --git a/api/excel.ts b/api/excel.ts index a2639ed..e31b617 100644 --- a/api/excel.ts +++ b/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 { } } +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 & { 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 & { company_id: number; tenant_id?: number | null }, projectId: number | null, ) { + const env = await callCoreFn(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); - 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 }; + if (!existing) throw new Error("No se pudo guardar el trabajador"); + await refreshPipeline(db, existing.id, input.tenant_id ?? 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[] = []; + 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); - report.photos++; - } + 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(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; } diff --git a/api/http_errors.ts b/api/http_errors.ts new file mode 100644 index 0000000..183977a --- /dev/null +++ b/api/http_errors.ts @@ -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 = { + ok: boolean; + code: RpcCode | string; + layer: ResponseLayer; + message: string; + context?: Record | null; + data?: T | null; + errors?: Record | string[] | null; +}; + +export type ApiResponse = RpcEnvelope & { 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(c: Context, envelope: RpcEnvelope) { + const status = mapRpcToStatus(String(envelope.code)); + const body: ApiResponse = { ...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, + errors?: Record | 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 } { + 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); +} diff --git a/api/payroll_http.ts b/api/payroll_http.ts index df72e83..5280453 100644 --- a/api/payroll_http.ts +++ b/api/payroll_http.ts @@ -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 (?, ?, ?, ?, ?, ?, ?, ?)`, - ); - 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); + if (blocked) { + return respondApiError( + c, + blocked.status === 404 ? "NOT_FOUND" : "VALIDATION", + blocked.error, + { route: routeLabel(c), project_id: body.project_id }, + ); } - 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[]; + const env = await callCoreFn<{ period: unknown; lines: Array> }>( + 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); }); } diff --git a/api/rpc.ts b/api/rpc.ts new file mode 100644 index 0000000..9855fce --- /dev/null +++ b/api/rpc.ts @@ -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; + 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( + db: Db, + fn: string, + payload: Record, +): Promise> { + 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; + if (isEnvelope(parsed)) return parsed; + } catch { + /* fall through */ + } + } + if (isEnvelope(raw)) return raw as RpcEnvelope; + throw new Error(`La función ${fn} no devolvió un envelope RPC válido`); +} + +export async function callCoreFn( + db: Db, + fn: string, + payload: Record = {}, + opts: { route?: string; retries?: number } = {}, +): Promise> { + 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(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( + db: Db, + fn: string, + payload: Record = {}, + opts: { route?: string } = {}, +): Promise { + const envelope = await callCoreFn(db, fn, payload, opts); + if (!envelope.ok) throw new RpcCallError(envelope); + return envelope.data as T; +} + +export function unwrapRpc( + envelope: RpcEnvelope, +): { 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, + }; +} diff --git a/db/core/changesets/006-rpc-infra.sql b/db/core/changesets/006-rpc-infra.sql new file mode 100644 index 0000000..c669017 --- /dev/null +++ b/db/core/changesets/006-rpc-infra.sql @@ -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; diff --git a/db/core/changesets/007-rpc-catalogs.sql b/db/core/changesets/007-rpc-catalogs.sql new file mode 100644 index 0000000..49eade4 --- /dev/null +++ b/db/core/changesets/007-rpc-catalogs.sql @@ -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; diff --git a/db/core/changesets/013-rpc-payroll.sql b/db/core/changesets/013-rpc-payroll.sql index 5cacc4e..665919e 100644 --- a/db/core/changesets/013-rpc-payroll.sql +++ b/db/core/changesets/013-rpc-payroll.sql @@ -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;