From b9bf7044b29b226dc6f4b4c833f4a4a0075d2802 Mon Sep 17 00:00:00 2001
From: Alberto Martinez <349769123@users.noreply.cursor.com>
Date: Fri, 4 Sep 2026 02:34:43 +0000
Subject: [PATCH] refactor(core): migrate core CRUD to PostgreSQL RPC + unified
HTTP errors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Migrates the `core` schema business logic from inline `db.prepare()` calls in the API to PostgreSQL RPC functions (`core.fn_*`) with a unified JSON envelope for errors and HTTP status mapping.
### Database (Liquibase changesets 006–017)
- **006** — RPC infra: `rpc_ok`, `rpc_err`, `rpc_created`, `rpc_from_exception`
- **007** — Catalogs and tenant settings
- **008** — Companies CRUD
- **009** — Projects, checklists, document lists
- **010** — Workers CRUD, pipeline, checklist, assign
- **011** — Budget CRUD + `fn_budget_replace`
- **012** — Badge jobs
- **013** — Payroll (settings, attendance, loans, weeks, destajo)
- **014** — Worker import batch + document store
- **015** — Project/company/worker document metadata RPCs
- **016–017** — Fixes: `needs_badge` default on worker create; Liquibase `splitStatements:false` on function changesets
### API
- `api/rpc.ts` — `callCoreFn()`, `RpcCallError` (jsonb payload fix: pass JS object, not `JSON.stringify`)
- `api/http_errors.ts` — `mapRpcToStatus()`, `respondRpc()`, `respondApiError()`, `onAppError()`
- Refactored: `main.ts`, `companies.ts`, `db.ts`, `budget.ts`, `excel.ts`, `payroll.ts`, `payroll_http.ts`
- Front helpers: `web-panel/composables/api-response.ts`, `web-saas/composables/api-response.ts`
### Envelope contract
DB functions return `{ ok, code, layer: "db", message, context, data, errors }`. The API adds `status` (HTTP code) via `respondRpc()` / `respondApiError()`.
### Out of scope
`iam`, `platform`, `saas.ts`, auth/sessions, S3, PDF generation, Excel parsing, and bootstrap scripts still use direct SQL where appropriate.
## Test plan
- [x] `deno check main.ts` — compila sin errores de tipos
- [x] `npm run build` — web-panel y web-saas compilan
- [x] `deno test` — 25 tests unitarios (http_errors, companies, budget, mx, document_validity)
- [x] Liquibase migrations `006`–`017` aplicadas en Postgres local (`--context-filter=dev`)
- [x] API levantada localmente; `/v1/health` OK
- [x] Smoke CRUD vía `scripts/crud-smoke-test.sh`: empresas, proyectos, trabajadores, catálogos (create/get/list/patch)
- [ ] Import Excel de trabajadores (flujo multipart + S3/local storage)
- [ ] Import presupuesto desde Excel
- [ ] Flujo nómina: asistencia → cerrar semana
- [ ] CI en el remoto (sin checks reportados aún)
---
api/budget.ts | 86 +-
api/companies.ts | 234 +-
api/db.ts | 265 +--
api/excel.ts | 356 ++-
api/http_errors.ts | 130 ++
api/http_errors_test.ts | 15 +
api/main.ts | 1043 +++++----
api/payroll.ts | 935 +-------
api/payroll_http.ts | 166 +-
api/rpc.ts | 125 +
db/README.md | 53 +
db/core/changelog-master.xml | 12 +
db/core/changesets/006-rpc-infra.sql | 147 ++
db/core/changesets/007-rpc-catalogs.sql | 123 +
db/core/changesets/008-rpc-companies.sql | 639 ++++++
db/core/changesets/009-rpc-projects.sql | 604 +++++
db/core/changesets/010-rpc-workers.sql | 1453 ++++++++++++
db/core/changesets/011-rpc-budget.sql | 697 ++++++
db/core/changesets/012-rpc-badges.sql | 333 +++
db/core/changesets/013-rpc-payroll.sql | 2032 +++++++++++++++++
db/core/changesets/014-rpc-import.sql | 566 +++++
db/core/changesets/015-rpc-documents.sql | 164 ++
.../changesets/016-rpc-worker-needs-badge.sql | 200 ++
.../017-rpc-worker-normalize-badge.sql | 44 +
scripts/crud-smoke-test.sh | 168 ++
web-panel/composables/api-response.ts | 59 +
web-saas/composables/api-response.ts | 58 +
27 files changed, 8682 insertions(+), 2025 deletions(-)
create mode 100644 api/http_errors.ts
create mode 100644 api/http_errors_test.ts
create mode 100644 api/rpc.ts
create mode 100644 db/core/changesets/006-rpc-infra.sql
create mode 100644 db/core/changesets/007-rpc-catalogs.sql
create mode 100644 db/core/changesets/008-rpc-companies.sql
create mode 100644 db/core/changesets/009-rpc-projects.sql
create mode 100644 db/core/changesets/010-rpc-workers.sql
create mode 100644 db/core/changesets/011-rpc-budget.sql
create mode 100644 db/core/changesets/012-rpc-badges.sql
create mode 100644 db/core/changesets/013-rpc-payroll.sql
create mode 100644 db/core/changesets/014-rpc-import.sql
create mode 100644 db/core/changesets/015-rpc-documents.sql
create mode 100644 db/core/changesets/016-rpc-worker-needs-badge.sql
create mode 100644 db/core/changesets/017-rpc-worker-normalize-badge.sql
create mode 100755 scripts/crud-smoke-test.sh
create mode 100644 web-panel/composables/api-response.ts
create mode 100644 web-saas/composables/api-response.ts
diff --git a/api/budget.ts b/api/budget.ts
index 4ef4c81..38eca1a 100644
--- a/api/budget.ts
+++ b/api/budget.ts
@@ -1,5 +1,6 @@
import * as XLSX from "xlsx";
import type { Db } from "./db.ts";
+import { callCoreFn, RpcCallError } from "./rpc.ts";
export const BUDGET_IVA = 0.16;
@@ -642,17 +643,14 @@ function compareWbs(a: string, b: string) {
}
export async function listBudget(database: Db, projectId: number) {
- const chapters = await database.prepare(
- `SELECT c.id, c.project_id, c.parent_id, c.code, c.name, COALESCE(c.wbs, '') AS wbs, c.sort_order
- FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`,
- ).all(projectId) as ChapterRow[];
- const rawItems = await database.prepare(
- `SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code
- FROM budget_items i
- LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id
- WHERE i.project_id = ?
- ORDER BY COALESCE(ch.sort_order, 9999), i.sort_order, i.id`,
- ).all(projectId) as (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
+ const env = await callCoreFn<{
+ chapters: ChapterRow[];
+ items: (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
+ totals: { subtotal: number; iva: number; total: number; item_count: number };
+ }>(database, "core.fn_budget_list", { project_id: projectId });
+ if (!env.ok) throw new RpcCallError(env);
+ const chapters = env.data?.chapters ?? [];
+ const rawItems = env.data?.items ?? [];
const byParent = new Map();
const byId = new Map();
@@ -720,7 +718,7 @@ export async function listBudget(database: Db, projectId: number) {
});
const subtotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
- const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
+ const iva = env.data?.totals?.iva ?? Math.round(subtotal * BUDGET_IVA * 100) / 100;
const chapterView = chapters.map((chapter) => {
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
const chain = ancestorsOf(chapter.id);
@@ -744,44 +742,34 @@ export async function listBudget(database: Db, projectId: number) {
};
}
-async function insertChapter(
- database: Db,
- projectId: number,
- parentId: number | null,
- code: string,
- name: string,
- wbs: string,
- sort: number,
-): Promise {
- await database.prepare(
- "INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
- ).run(projectId, parentId, code, name, wbs, sort);
- return await database.lastInsertId();
-}
-
export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise {
- await database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId);
- await database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId);
const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs));
- const ids = new Map();
let sort = 1;
- for (const group of groups) {
- const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null;
- const id = await insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort);
- ids.set(group.wbs, id);
- sort++;
- }
+ const chapters = groups.map((group) => ({
+ wbs: group.wbs,
+ parent_wbs: group.parentWbs || null,
+ code: group.code,
+ name: group.name,
+ sort_order: sort++,
+ }));
let itemSort = 1;
- for (const item of parsed.items) {
- const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null;
- const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100;
- await database.prepare(
- `INSERT INTO budget_items
- (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs);
- itemSort++;
- }
+ const items = parsed.items.map((item) => ({
+ chapter_wbs: item.parentWbs || null,
+ code: item.code,
+ description: item.description,
+ unit: item.unit,
+ quantity: item.quantity,
+ unit_price: item.unit_price,
+ amount: Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100,
+ wbs: item.wbs,
+ sort_order: itemSort++,
+ }));
+ const env = await callCoreFn(database, "core.fn_budget_replace", {
+ project_id: projectId,
+ chapters,
+ items,
+ });
+ if (!env.ok) throw new RpcCallError(env);
}
export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise {
@@ -927,7 +915,11 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes:
item_count: parsed.items.length,
};
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado.
- await database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId);
+ const projectEnv = await callCoreFn(database, "core.fn_project_update", {
+ id: projectId,
+ contract_amount: subtotal,
+ });
+ if (!projectEnv.ok) throw new RpcCallError(projectEnv);
return {
replaced: true,
chapters: parsed.groups.length,
diff --git a/api/companies.ts b/api/companies.ts
index dae2f91..19a54b1 100644
--- a/api/companies.ts
+++ b/api/companies.ts
@@ -1,4 +1,7 @@
import type { Db } from "./db.ts";
+import { callCoreFn, RpcCallError } from "./rpc.ts";
+import type { RpcEnvelope } from "./http_errors.ts";
+import { mapRpcToStatus } from "./http_errors.ts";
export type Company = {
id: number;
@@ -60,43 +63,35 @@ function normRfc(value: unknown): string {
return trimText(value).toUpperCase().replace(/\s+/g, "");
}
+function envelopeError(env: RpcEnvelope): { error: string; status: number } {
+ return { error: env.message, status: mapRpcToStatus(String(env.code)) };
+}
+
export async function listCompanies(database: Db, tenantId?: number | null): Promise {
- // El filtro tenant_id aquí es defensa adicional/legibilidad -- el
- // aislamiento real ya lo garantiza Row Level Security sobre la conexión
- // acotada por withCoreScope (ver db/core/changesets/005-rls.sql).
- const where = tenantId != null ? "WHERE c.tenant_id = ?" : "";
- const params = tenantId != null ? [tenantId] : [];
- return await database.prepare(
- `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
- ${where}
- ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, LOWER(c.name)`,
- ).all(...params) as Company[];
+ const env = await callCoreFn<{ companies: Company[] }>(database, "core.fn_company_list", {
+ tenant_id: tenantId ?? null,
+ });
+ if (!env.ok) throw new RpcCallError(env);
+ return (env.data?.companies ?? []) as Company[];
}
export async function companyById(database: Db, id: number): Promise {
- return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as
- | Company
- | undefined;
+ const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get", { id });
+ if (!env.ok) return undefined;
+ return env.data?.company as Company | undefined;
}
export async function companyByCode(database: Db, code: string): Promise {
- return await database.prepare("SELECT * FROM companies WHERE code = ?").get(
- normCompanyCode(code),
- ) as Company | undefined;
+ const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get_by_code", {
+ code: normCompanyCode(code),
+ });
+ if (!env.ok) return undefined;
+ return env.data?.company as Company | undefined;
}
export async function principalCompany(database: Db, tenantId?: number | null): Promise {
- if (tenantId != null) {
- return await database.prepare(
- "SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1",
- ).get(tenantId) as Company | undefined;
- }
- return await database.prepare(
- "SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1",
- ).get() as Company | undefined;
+ const companies = await listCompanies(database, tenantId);
+ return companies.find((c) => c.kind === "principal");
}
export function normCompanyCode(value: string | null | undefined): string {
@@ -114,13 +109,9 @@ export function companyCodeFromName(name: string): string {
}
export async function nextCompanyCode(database: Db, name: string): Promise {
- const base = companyCodeFromName(name);
- if (!await companyByCode(database, base)) return base;
- const row = await database.prepare(
- `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n
- FROM companies WHERE code ~ '^EMP-[0-9]{4}'`,
- ).get() as { n: number };
- return `EMP-${String(row.n).padStart(4, "0")}`;
+ const env = await callCoreFn<{ code: string }>(database, "core.fn_next_company_code", { name });
+ if (!env.ok) throw new RpcCallError(env);
+ return String(env.data?.code ?? companyCodeFromName(name));
}
export async function resolveCompany(
@@ -186,72 +177,19 @@ export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName
};
}
-function validateProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null {
- return validateCompanyProfile(input, opts);
-}
-
-function profileFromInput(input: CompanyProfileInput, fallbackName = "") {
- return normalizeCompanyProfile(input, fallbackName);
-}
-
export async function createSubcompany(
database: Db,
input: CompanyProfileInput,
tenantId?: number | null,
-): Promise<{ company?: Company; error?: string }> {
- const profileErr = validateProfile(input, { requireLegal: true });
- if (profileErr) return { error: profileErr };
- const profile = profileFromInput(input);
- if (!profile.name) return { error: "Nombre de empresa obligatorio" };
-
- const principal = await principalCompany(database, tenantId);
- if (!principal) return { error: "No hay empresa principal" };
- const parentId = input.parent_id ? Number(input.parent_id) : principal.id;
- const parent = await companyById(database, parentId);
- if (!parent) return { error: "Empresa padre no encontrada" };
- if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) {
- return { error: "Empresa padre de otro tenant" };
- }
-
- let code = normCompanyCode(input.code);
- if (!code) code = await nextCompanyCode(database, profile.name);
- const codeErr = validateCompanyCode(code);
- if (codeErr) return { error: codeErr };
- if (await companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" };
-
- const tid = tenantId ?? principal.tenant_id ?? null;
- try {
- await database.prepare(
- `INSERT INTO companies (
- code, name, parent_id, kind, status, tenant_id,
- registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
- domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
- ) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
- code,
- profile.name,
- parent.id,
- tid,
- profile.registro_patronal,
- profile.razon_social,
- profile.nombre_comercial,
- profile.rfc,
- profile.regimen_fiscal,
- profile.clase_riesgo,
- profile.domicilio_fiscal,
- profile.codigo_postal,
- profile.ciudad,
- profile.estado,
- profile.telefono,
- profile.email,
- profile.representante_legal,
- profile.giro,
- );
- } catch (e) {
- if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código" };
- throw e;
- }
- return { company: await companyById(database, await database.lastInsertId()) };
+): Promise<{ company?: Company; error?: string; status?: number }> {
+ const profileErr = validateCompanyProfile(input, { requireLegal: true });
+ if (profileErr) return { error: profileErr, status: 400 };
+ const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_create", {
+ ...input,
+ tenant_id: tenantId ?? null,
+ });
+ if (!env.ok) return envelopeError(env);
+ return { company: env.data?.company as Company };
}
export async function updateCompany(
@@ -260,100 +198,14 @@ export async function updateCompany(
input: CompanyProfileInput,
tenantId?: number | null,
): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> {
- const current = await companyById(database, id);
- // Con RLS activo, una fila de otro tenant ya no aparece aquí (la conexión
- // solo ve app.tenant_id); este chequeo explícito es defensa adicional y
- // un mensaje de error más claro que un 404 "silencioso".
- if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) {
- return { error: "Empresa no encontrada", status: 404 };
+ const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_update", {
+ id,
+ tenant_id: tenantId ?? null,
+ ...input,
+ });
+ if (!env.ok) {
+ const st = mapRpcToStatus(String(env.code));
+ return { error: env.message, status: st === 404 ? 404 : 400 };
}
-
- const profileErr = validateProfile(input);
- if (profileErr) return { error: profileErr, status: 400 };
-
- const merged: CompanyProfileInput = {
- name: input.name !== undefined ? input.name : current.name,
- code: input.code !== undefined ? input.code : current.code,
- status: input.status !== undefined ? input.status : current.status,
- registro_patronal: input.registro_patronal !== undefined ? input.registro_patronal : current.registro_patronal,
- razon_social: input.razon_social !== undefined ? input.razon_social : current.razon_social,
- nombre_comercial: input.nombre_comercial !== undefined ? input.nombre_comercial : current.nombre_comercial,
- rfc: input.rfc !== undefined ? input.rfc : current.rfc,
- regimen_fiscal: input.regimen_fiscal !== undefined ? input.regimen_fiscal : current.regimen_fiscal,
- clase_riesgo: input.clase_riesgo !== undefined ? input.clase_riesgo : current.clase_riesgo,
- domicilio_fiscal: input.domicilio_fiscal !== undefined ? input.domicilio_fiscal : current.domicilio_fiscal,
- codigo_postal: input.codigo_postal !== undefined ? input.codigo_postal : current.codigo_postal,
- ciudad: input.ciudad !== undefined ? input.ciudad : current.ciudad,
- estado: input.estado !== undefined ? input.estado : current.estado,
- telefono: input.telefono !== undefined ? input.telefono : current.telefono,
- email: input.email !== undefined ? input.email : current.email,
- representante_legal: input.representante_legal !== undefined
- ? input.representante_legal
- : current.representante_legal,
- giro: input.giro !== undefined ? input.giro : current.giro,
- };
- const profile = profileFromInput(merged, current.name);
- if (!profile.name) return { error: "Nombre de empresa obligatorio", status: 400 };
-
- let code = current.code;
- if (input.code !== undefined) {
- code = normCompanyCode(input.code);
- const codeErr = validateCompanyCode(code);
- if (codeErr) return { error: codeErr, status: 400 };
- const clash = await companyByCode(database, code);
- if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 };
- }
-
- let status = current.status;
- if (input.status !== undefined) {
- if (!["activo", "inactivo"].includes(input.status)) {
- return { error: "Estatus debe ser activo o inactivo", status: 400 };
- }
- if (current.kind === "principal" && input.status === "inactivo") {
- return { error: "La empresa principal no se puede inactivar", status: 400 };
- }
- status = input.status as Company["status"];
- }
-
- try {
- await database.prepare(
- `UPDATE companies SET
- name = ?, code = ?, status = ?,
- registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
- domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?,
- representante_legal = ?, giro = ?
- WHERE id = ?`,
- ).run(
- profile.name,
- code,
- status,
- profile.registro_patronal,
- profile.razon_social,
- profile.nombre_comercial,
- profile.rfc,
- profile.regimen_fiscal,
- profile.clase_riesgo,
- profile.domicilio_fiscal,
- profile.codigo_postal,
- profile.ciudad,
- profile.estado,
- profile.telefono,
- profile.email,
- profile.representante_legal,
- profile.giro,
- id,
- );
- } catch (e) {
- if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 };
- throw e;
- }
- if (code !== current.code) {
- await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id);
- }
- return { company: await companyById(database, id) };
-}
-
-/** Postgres error code 23505 = unique_violation. */
-function isUniqueViolation(e: unknown): boolean {
- return !!e && typeof e === "object" && (e as { code?: string }).code === "23505";
+ return { company: env.data?.company as Company };
}
diff --git a/api/db.ts b/api/db.ts
index 51cf83a..72e3e65 100644
--- a/api/db.ts
+++ b/api/db.ts
@@ -1,22 +1,15 @@
import postgres, { createPool, PgDb, withTenant } from "./pg.ts";
import { config } from "./config.ts";
+import { callCoreFn, RpcCallError } from "./rpc.ts";
import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
export type Db = PgDb;
-/**
- * panels_product, esquema core: negocio (empresas, personal, obras,
- * expedientes, presupuesto, nómina, gafetes). Aislado de `iam` a propósito
- * -- este módulo no debe importar iam_db.ts.
- */
-
let corePool: postgres.Sql | null = null;
let coreOwnerPool: postgres.Sql | null = null;
function getCorePool(): postgres.Sql {
- if (!corePool) {
- corePool = createPool(config.databaseUrlCore, { max: 20 });
- }
+ if (!corePool) corePool = createPool(config.databaseUrlCore, { max: 20 });
return corePool;
}
@@ -31,10 +24,6 @@ export async function pingCoreDb(): Promise {
await getCorePool()`SELECT 1`;
}
-/** El único camino "normal" para atender un request: abre una transacción
- * con app.tenant_id fijado (Row Level Security), y la cierra sola al
- * terminar `fn` (commit) o al lanzar (rollback). Ver api/pg.ts#withTenant
- * y las políticas en db/core/changesets/005-rls.sql. */
export async function withCoreTenant(
tenantId: number | null,
fn: (db: Db) => Promise,
@@ -42,10 +31,6 @@ export async function withCoreTenant(
return await withTenant(getCorePool(), tenantId, fn);
}
-/** Conexión SIN RLS (rol _owner, dueño de las tablas) -- reservada para
- * scripts (bootstrap, ETL) y para resoluciones internas puntuales que
- * necesitan cruzar tenants a propósito (ej. auth.ts al enriquecer el login
- * con el nombre de la empresa). NO usar en handlers de request normales. */
export async function getCoreDb(): Promise {
return new PgDb(getCoreOwnerPool());
}
@@ -76,17 +61,15 @@ export function isProjectStatus(value: string): value is ProjectStatus {
}
export async function nextProjectCode(database: Db): Promise {
- const row = await database.prepare(
- `SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n
- FROM projects WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}'`,
- ).get() as { n: number };
- return `PRY-${String(row.n).padStart(4, "0")}`;
+ const env = await callCoreFn<{ code: string }>(database, "core.fn_next_project_code", {});
+ if (!env.ok) throw new RpcCallError(env);
+ return String(env.data?.code ?? "PRY-0001");
}
export async function projectById(database: Db, id: number) {
- return await database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as
- | { id: number; code: string; name: string; status: string }
- | undefined;
+ const env = await callCoreFn<{ project: Record }>(database, "core.fn_project_get", { id });
+ if (!env.ok) return undefined;
+ return env.data?.project as { id: number; code: string; name: string; status: string } | undefined;
}
export function projectMustBe(
@@ -102,104 +85,87 @@ export function projectMustBe(
}
export async function workerImssContext(database: Db, workerId: number): Promise {
- const w = await database.prepare(
- "SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?",
- ).get(workerId) as {
- imss_status: ImssStatus;
- imss_alta_at: string | null;
- last_rehire_at: string | null;
- } | undefined;
- const altaDoc = await database.prepare(
- `SELECT imss_alta_at, uploaded_at FROM documents
- WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = true
- LIMIT 1`,
- ).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined;
- return {
- imss_status: (w?.imss_status as ImssStatus) || "sin_alta",
- last_rehire_at: w?.last_rehire_at ?? null,
- current_alta_at: altaDoc?.imss_alta_at || w?.imss_alta_at || altaDoc?.uploaded_at || null,
- };
+ const env = await callCoreFn<{ imss_context: WorkerImssContext }>(
+ database, "core.fn_worker_checklist_raw", { worker_id: workerId },
+ );
+ if (env.ok && env.data?.imss_context) return env.data.imss_context;
+ return { imss_status: "sin_alta" as ImssStatus, last_rehire_at: null, current_alta_at: null };
}
-/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor.
- * tenantId (Fase 4d) resuelve la zona horaria del tenant para calcular
- * "hoy" de forma consistente con nómina -- antes esta función usaba
- * `new Date()` crudo (hora del servidor/UTC) sin relación con PAYROLL_TZ. */
export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) {
const tz = await tenantTimezone(database, tenantId);
const today = todayInTimezone(tz);
- const types = await database.prepare(
- `SELECT code, label, required, validity_mode, freshness_days,
- requires_issued_at, requires_expires_at, category
- FROM document_types`,
- ).all() as {
- code: string;
- label: string;
- required: boolean;
- validity_mode: string;
- freshness_days: number | null;
- requires_issued_at: boolean;
- requires_expires_at: boolean;
- category: string;
- }[];
- const currentDocs = await database.prepare(
- `SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
- FROM documents WHERE worker_id = ? AND is_current = true`,
- ).all(workerId) as {
- type_code: string;
- issued_at: string | null;
- expires_at: string | null;
- uploaded_at: string;
- imss_alta_at: string | null;
- }[];
- const byType = new Map(currentDocs.map((d) => [d.type_code, d]));
- const ctx = await workerImssContext(database, workerId);
-
- return {
- ctx,
- freshness_required: freshnessRequired(ctx),
- items: types.map((t) => {
- const policy = {
- code: t.code,
- label: t.label,
- required: !!t.required,
- validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry",
- freshness_days: t.freshness_days,
- requires_issued_at: !!t.requires_issued_at,
- requires_expires_at: !!t.requires_expires_at,
- };
- const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today);
- return {
- code: t.code,
- label: t.label,
- required: !!t.required,
- validity_mode: policy.validity_mode,
- freshness_days: t.freshness_days,
- requires_issued_at: policy.requires_issued_at,
- requires_expires_at: policy.requires_expires_at,
- category: t.category || "otro",
- present: evaled.present,
- valid: evaled.valid,
- validity_status: evaled.validity_status,
- issued_at: evaled.issued_at,
- expires_at: evaled.expires_at,
- };
- }),
- };
+ const env = await callCoreFn<{
+ items?: Array>;
+ ctx?: WorkerImssContext;
+ imss_context?: WorkerImssContext;
+ freshness_required?: boolean;
+ document_types?: Array>;
+ current_documents?: Array>;
+ }>(database, "core.fn_worker_checklist_raw", { worker_id: workerId, tenant_id: tenantId });
+ if (env.ok && env.data?.items) {
+ return {
+ ctx: (env.data.ctx ?? env.data.imss_context) as WorkerImssContext,
+ freshness_required: Boolean(env.data.freshness_required),
+ items: env.data.items,
+ };
+ }
+ const types = env.data?.document_types ?? [];
+ const currentDocs = env.data?.current_documents ?? [];
+ const ctx = (env.data?.imss_context ?? await workerImssContext(database, workerId)) as WorkerImssContext;
+ const byType = new Map(currentDocs.map((d) => [String(d.type_code), d]));
+ const items = types.map((t) => {
+ const policy = {
+ code: String(t.code),
+ label: String(t.label),
+ required: !!t.required,
+ validity_mode: (String(t.validity_mode || "none")) as "none" | "freshness" | "expiry",
+ freshness_days: t.freshness_days as number | null,
+ requires_issued_at: !!t.requires_issued_at,
+ requires_expires_at: !!t.requires_expires_at,
+ };
+ const evaled = evaluateDocumentValidity(policy, byType.get(policy.code) as never, ctx, today);
+ return {
+ code: policy.code,
+ label: policy.label,
+ required: policy.required,
+ validity_mode: policy.validity_mode,
+ freshness_days: t.freshness_days,
+ requires_issued_at: policy.requires_issued_at,
+ requires_expires_at: policy.requires_expires_at,
+ category: String(t.category || "otro"),
+ present: evaled.present,
+ valid: evaled.valid,
+ validity_status: evaled.validity_status,
+ issued_at: evaled.issued_at,
+ expires_at: evaled.expires_at,
+ };
+ });
+ return { ctx, freshness_required: freshnessRequired(ctx), items };
}
export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) {
const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId);
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
- const inProject = !!await database.prepare(
- "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
- ).get(workerId);
+ const env = await callCoreFn<{ workers: Array> }>(
+ database, "core.fn_worker_list", { tenant_id: tenantId },
+ );
+ const row = env.ok ? (env.data?.workers ?? []).find((w) => Number(w.id) === workerId) : undefined;
+ if (row) {
+ return {
+ imss_status: String(row.imss_status ?? ctx.imss_status),
+ freshness_required: Boolean(row.freshness_required ?? needFresh),
+ imss_ready: Boolean(row.imss_ready),
+ in_project_without_imss: Boolean(row.in_project_without_imss),
+ expediente_ok: Boolean(row.expediente_ok),
+ };
+ }
const hasImss = ctx.imss_status === "alta";
return {
imss_status: ctx.imss_status,
freshness_required: needFresh,
imss_ready: requiredOk && !hasImss,
- in_project_without_imss: inProject && !hasImss,
+ in_project_without_imss: false,
expediente_ok: requiredOk,
};
}
@@ -209,82 +175,37 @@ export async function checklistFor(database: Db, workerId: number, tenantId: num
}
export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise {
- const w = await database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as
- | { status: string }
- | undefined;
- if (!w) return;
- if (w.status === "baja") {
- await database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId);
- return;
- }
- const items = await checklistFor(database, workerId, tenantId);
- const photo = items.find((i) => i.code === "foto");
- const photoOk = photo?.present && photo?.valid;
- const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
- const printed = await database.prepare(
- `SELECT 1 FROM badge_job_people p
- JOIN badge_jobs j ON j.id = p.job_id
- WHERE p.worker_id = ? LIMIT 1`,
- ).get(workerId);
- let pipeline = "incompleto";
- if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete";
- const assigned = await database.prepare(
- "SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
- ).get(workerId);
- if (pipeline !== "incompleto" && assigned) {
- pipeline = printed ? "activo" : "listo_gafete";
- }
- await database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId);
+ const env = await callCoreFn(database, "core.fn_worker_refresh_pipeline", {
+ worker_id: workerId, tenant_id: tenantId,
+ });
+ if (!env.ok) throw new RpcCallError(env);
}
export async function projectChecklistFor(database: Db, projectId: number) {
- const types = await database.prepare(
- "SELECT code, label, required, category FROM project_document_types",
- ).all() as { code: string; label: string; required: boolean; category: string }[];
- const current = await database.prepare(
- `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = true`,
- ).all(projectId) as { type_code: string }[];
- const have = new Set(current.map((c) => c.type_code));
- return types.map((t) => ({
- code: t.code,
- label: t.label,
- required: !!t.required,
- category: t.category || "otro",
- present: have.has(t.code),
- }));
+ const env = await callCoreFn<{ checklist: unknown[] }>(
+ database, "core.fn_project_checklist", { project_id: projectId },
+ );
+ if (!env.ok) throw new RpcCallError(env);
+ return env.data?.checklist ?? [];
}
export async function companyChecklistFor(database: Db, companyId: number) {
- const types = await database.prepare(
- "SELECT code, label, required, category FROM company_document_types",
- ).all() as { code: string; label: string; required: boolean; category: string }[];
- const current = await database.prepare(
- `SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = true`,
- ).all(companyId) as { type_code: string }[];
- const have = new Set(current.map((c) => c.type_code));
- return types.map((t) => ({
- code: t.code,
- label: t.label,
- required: !!t.required,
- category: t.category || "otro",
- present: have.has(t.code),
- }));
+ const env = await callCoreFn<{ checklist: unknown[] }>(
+ database, "core.fn_company_checklist", { company_id: companyId },
+ );
+ if (!env.ok) throw new RpcCallError(env);
+ return env.data?.checklist ?? [];
}
-/** Zona horaria del tenant (Fase 4d) -- reemplaza el PAYROLL_TZ
- * hardcodeado. Con default sensato si el tenant no la configuró. */
export async function tenantTimezone(database: Db, tenantId: number | null): Promise {
if (tenantId == null) return "America/Mexico_City";
- const row = await database.prepare(
- "SELECT timezone FROM tenant_settings WHERE tenant_id = ?",
- ).get(tenantId) as { timezone: string } | undefined;
- return row?.timezone || "America/Mexico_City";
+ const env = await callCoreFn<{ timezone: string }>(
+ database, "core.fn_tenant_timezone_get", { tenant_id: tenantId },
+ );
+ if (!env.ok) return "America/Mexico_City";
+ return String(env.data?.timezone ?? "America/Mexico_City");
}
-/** "Hoy" en la zona horaria del tenant, formato ISO (YYYY-MM-DD). Usado por
- * nómina y por validación de vigencia de documentos -- antes eran dos
- * nociones de "hoy" distintas (PAYROLL_TZ vs. new Date() crudo); ahora es
- * una sola función parametrizada por tenant. */
export function todayInTimezone(timezone: string, now = new Date()): string {
return now.toLocaleDateString("en-CA", { timeZone: timezone });
}
diff --git a/api/excel.ts b/api/excel.ts
index a2639ed..cf37498 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",
@@ -190,12 +191,12 @@ export function buildImportTemplate(companies: { code: string; name: string }[]
}
async function findExisting(db: Db, curp: string, rfc: string, nss: string) {
- return await db.prepare(
- `SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers
- WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`,
- ).get(curp, rfc, nss) as
- | { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string }
- | undefined;
+ const env = await callCoreFn<{
+ worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null;
+ found?: boolean;
+ }>(db, "core.fn_worker_find_existing", { curp, rfc, nss });
+ if (!env.ok || !env.data?.found) return undefined;
+ return env.data.worker;
}
export async function storeDocument(
@@ -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);
}
@@ -303,21 +256,23 @@ export async function storeProjectDocument(
bytes: Uint8Array,
userId: number | null,
) {
- const allowed = await db.prepare("SELECT code FROM project_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(projectDocKey(projectId, storage), cipher);
- await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run(
- projectId,
- type,
- );
- await db.prepare(
- `INSERT INTO project_documents
- (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
- ).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
+ const env = await callCoreFn(db, "core.fn_project_document_store", {
+ project_id: projectId,
+ type_code: type,
+ original_name: filename,
+ mime,
+ size_bytes: bytes.byteLength,
+ sha256: hash,
+ iv,
+ storage_name: storage,
+ uploaded_by_id: userId,
+ uploaded_by_name: "",
+ });
+ if (!env.ok) throw new RpcCallError(env);
}
export async function storeCompanyDocument(
@@ -329,21 +284,23 @@ export async function storeCompanyDocument(
bytes: Uint8Array,
userId: number | null,
) {
- const allowed = await db.prepare("SELECT code FROM company_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(companyDocKey(companyId, storage), cipher);
- await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run(
- companyId,
- type,
- );
- await db.prepare(
- `INSERT INTO company_documents
- (company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
- ).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
+ const env = await callCoreFn(db, "core.fn_company_document_store", {
+ company_id: companyId,
+ type_code: type,
+ original_name: filename,
+ mime,
+ size_bytes: bytes.byteLength,
+ sha256: hash,
+ iv,
+ storage_name: storage,
+ uploaded_by_id: userId,
+ uploaded_by_name: "",
+ });
+ if (!env.ok) throw new RpcCallError(env);
}
async function fetchPhoto(url: string): Promise {
@@ -357,92 +314,6 @@ async function fetchPhoto(url: string): Promise {
}
}
-async function upsertWorker(
- db: Db,
- input: ReturnType & { company_id: number; tenant_id?: number | null },
- projectId: number | null,
-) {
- 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 };
-}
-
-async function assign(db: Db, workerId: number, projectId: number) {
- await db.prepare(
- `INSERT INTO assignments (worker_id, project_id, active, start_date)
- VALUES (?, ?, true, current_date)
- ON CONFLICT(worker_id, project_id) DO UPDATE SET
- active=true,
- start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END,
- end_date=NULL`,
- ).run(workerId, projectId);
-}
-
export type ImportReport = {
inserted: number;
existed: number;
@@ -452,6 +323,79 @@ export type ImportReport = {
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) 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) {
+ const env = await callCoreFn(db, "core.fn_worker_assign", {
+ worker_id: workerId,
+ project_id: projectId,
+ });
+ if (!env.ok) throw new RpcCallError(env);
+}
+
export async function importExcel(
db: Db,
bytes: Uint8Array,
@@ -459,9 +403,9 @@ export async function importExcel(
userId: number | null,
): Promise {
const wb = XLSX.read(bytes, { type: "array" });
- const risks = new Set(
- (await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code),
- );
+ const catEnv = await callCoreFn<{ risks?: { code: string }[] }>(db, "core.fn_catalogs", {});
+ if (!catEnv.ok) throw new RpcCallError(catEnv);
+ const risks = new Set((catEnv.data?.risks ?? []).map((r) => r.code));
const report: ImportReport = {
inserted: 0,
existed: 0,
@@ -496,6 +440,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 +486,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/http_errors_test.ts b/api/http_errors_test.ts
new file mode 100644
index 0000000..090926a
--- /dev/null
+++ b/api/http_errors_test.ts
@@ -0,0 +1,15 @@
+import { assertEquals } from "jsr:@std/assert@1";
+import { isGenericMessage, mapRpcToStatus } from "./http_errors.ts";
+
+Deno.test("mapRpcToStatus maps business codes to HTTP", () => {
+ assertEquals(mapRpcToStatus("OK"), 200);
+ assertEquals(mapRpcToStatus("CREATED"), 201);
+ assertEquals(mapRpcToStatus("NOT_FOUND"), 404);
+ assertEquals(mapRpcToStatus("CONFLICT"), 409);
+ assertEquals(mapRpcToStatus("INTERNAL"), 500);
+});
+
+Deno.test("isGenericMessage rejects vague messages", () => {
+ assertEquals(isGenericMessage("Error interno"), true);
+ assertEquals(isGenericMessage("No se encontró la empresa con id=5"), false);
+});
diff --git a/api/main.ts b/api/main.ts
index 0ac52b5..23619b4 100644
--- a/api/main.ts
+++ b/api/main.ts
@@ -3,7 +3,6 @@ import { cors } from "hono/cors";
import {
checklistFor,
refreshPipeline,
- nextProjectCode,
isProjectStatus,
projectById,
projectMustBe,
@@ -11,13 +10,19 @@ import {
projectChecklistFor,
companyChecklistFor,
imssFlagsFor,
- checklistItemsFor,
- lastInsertId,
pingCoreDb,
tenantTimezone,
getCoreDb,
type Db,
} from "./db.ts";
+import { callCoreFn } from "./rpc.ts";
+import {
+ respondRpc,
+ respondApiError,
+ onAppError,
+ routeLabel,
+ type RpcEnvelope,
+} from "./http_errors.ts";
import { config } from "./config.ts";
import {
clearSession,
@@ -36,14 +41,13 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd
import { smtpConfigured, testSmtp } from "./mail.ts";
import { saveSmtpSettings, smtpPublicView } from "./smtp.ts";
import { decryptBytes } from "./docs_crypto.ts";
-import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, findExisting, assign, buildImportTemplate } from "./excel.ts";
+import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, buildImportTemplate } from "./excel.ts";
import {
normalizeWorker,
validateCurp,
validateNss,
validateRfc,
validateWorkerFields,
- fullName,
formatNss,
normUpper,
titleCase,
@@ -58,15 +62,48 @@ import {
exportBudgetWorkbook,
previewBudgetExcel,
importBudgetExcel,
- lineAmount,
listBudget,
} from "./budget.ts";
-import { companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts";
+import { badgeJobPdfKey, companyDocKey, getObject, pingStorage, projectDocKey, workerDocKey } from "./storage.ts";
import { cacheCore, cacheKeyCore } from "./cache.ts";
import { pingRedis } from "./redis.ts";
const app = new Hono<{ Variables: { user: AuthUser; db: Db } }>();
+app.onError(onAppError);
+
+type StorageDoc = {
+ storage_name: string;
+ iv: string;
+ original_name: string;
+ mime?: string;
+};
+
+async function rpcDocumentForDownload(
+ db: Db,
+ fn: string,
+ payload: Record,
+ route: string,
+): Promise<{ doc?: StorageDoc; envelope: RpcEnvelope }> {
+ const envelope = await callCoreFn<{ document: StorageDoc }>(db, fn, payload, { route });
+ if (!envelope.ok) return { envelope };
+ const doc = envelope.data?.document;
+ if (!doc?.storage_name) {
+ return {
+ envelope: {
+ ok: false,
+ code: "NOT_FOUND",
+ layer: "db",
+ message: "Documento no encontrado",
+ context: { fn, ...payload },
+ data: null,
+ errors: null,
+ },
+ };
+ }
+ return { doc, envelope };
+}
+
async function scopedCompany(
db: Db,
id: number,
@@ -237,20 +274,16 @@ app.get("/v1/catalogs", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const user = c.get("user");
const tid = tenantScope(user);
- // Catálogos de baja escritura (Fase 4e): TTL corto, sin invalidación
- // explícita. La llave incluye tenant_id porque `companies` sí es dato
- // de negocio por-tenant -- risk_levels/badge_themes/document_types son
- // globales, pero se cachean juntos por simplicidad de esta única
- // respuesta agregada.
- const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => ({
- risks: await db.prepare("SELECT * FROM risk_levels").all(),
- themes: await db.prepare("SELECT * FROM badge_themes").all(),
- document_types: await db.prepare("SELECT * FROM document_types").all(),
- project_document_types: await db.prepare("SELECT * FROM project_document_types").all(),
- company_document_types: await db.prepare("SELECT * FROM company_document_types").all(),
- project_statuses: PROJECT_STATUS_CATALOG,
- companies: await listCompanies(db, tid),
- }));
+ const route = routeLabel(c);
+ const payload = await cacheCore(cacheKeyCore(tid, "catalogs"), 30, async () => {
+ const env = await callCoreFn>(db, "core.fn_catalogs", {}, { route });
+ if (!env.ok) throw new Error(env.message);
+ return {
+ ...env.data,
+ project_statuses: PROJECT_STATUS_CATALOG,
+ companies: await listCompanies(db, tid),
+ };
+ });
return c.json(payload);
});
@@ -265,25 +298,44 @@ app.get("/v1/configuracion", ...requireCoreAuth, async (c) => {
app.put("/v1/configuracion", ...requireCoreAuth, async (c) => {
const user = c.get("user");
- if (user.role !== "tenant_admin") return c.json({ error: "Solo administradores" }, 403);
+ if (user.role !== "tenant_admin") {
+ return respondApiError(c, "FORBIDDEN", "Solo administradores pueden cambiar la configuración del tenant", {
+ route: routeLabel(c),
+ });
+ }
const db = c.get("db");
const tid = tenantScope(user);
- if (tid == null) return c.json({ error: "Cuenta sin tenant" }, 400);
+ if (tid == null) {
+ return respondApiError(c, "VALIDATION", "Cuenta sin tenant asociada para guardar configuración", {
+ route: routeLabel(c),
+ });
+ }
const body = await c.req.json<{ timezone?: string }>();
const tz = (body.timezone ?? "").trim();
const valid = new Set(Intl.supportedValuesOf("timeZone"));
- if (!tz || !valid.has(tz)) return c.json({ error: "Zona horaria inválida" }, 400);
- await db.prepare(
- `INSERT INTO tenant_settings (tenant_id, timezone, updated_at) VALUES (?, ?, now())
- ON CONFLICT (tenant_id) DO UPDATE SET timezone = excluded.timezone, updated_at = now()`,
- ).run(tid, tz);
- return c.json({ timezone: tz });
+ if (!tz || !valid.has(tz)) {
+ return respondApiError(c, "VALIDATION", `Zona horaria inválida en ${routeLabel(c)}: ${tz || "(vacío)"}`, {
+ route: routeLabel(c),
+ timezone: tz || null,
+ });
+ }
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_tenant_settings_upsert",
+ { tenant_id: tid, timezone: tz },
+ { route: routeLabel(c) },
+ ));
});
app.get("/v1/companies", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
- return c.json({ companies: await listCompanies(db, tid) });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_company_list",
+ { tenant_id: tid },
+ { route: routeLabel(c) },
+ ));
});
app.post("/v1/companies", ...requireCoreAuth, async (c) => {
@@ -291,8 +343,17 @@ app.post("/v1/companies", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
const result = await createSubcompany(db, body, tid);
- if (result.error || !result.company) return c.json({ error: result.error }, 400);
- return c.json({ company: result.company }, 201);
+ if (result.error || !result.company) {
+ const code = result.status === 404 ? "NOT_FOUND" : "VALIDATION";
+ return respondApiError(c, code, result.error ?? "No se pudo crear la empresa", { route: routeLabel(c) });
+ }
+ return respondRpc(c, {
+ ok: true,
+ code: "CREATED",
+ layer: "db",
+ message: `Empresa ${result.company.code} creada`,
+ data: { company: result.company },
+ });
});
app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => {
@@ -301,22 +362,50 @@ app.patch("/v1/companies/:id", ...requireCoreAuth, async (c) => {
const db = c.get("db");
const tid = tenantScope(c.get("user"));
const result = await updateCompany(db, id, body, tid);
- if (result.error) return c.json({ error: result.error }, (result.status ?? 400) as 400 | 404);
- return c.json({ company: result.company });
+ if (result.error) {
+ const code = result.status === 404 ? "NOT_FOUND" : "VALIDATION";
+ return respondApiError(c, code, result.error, { route: routeLabel(c), company_id: id });
+ }
+ return respondRpc(c, {
+ ok: true,
+ code: "OK",
+ layer: "db",
+ message: `Empresa id=${id} actualizada`,
+ data: { company: result.company },
+ });
});
app.get("/v1/companies/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
- const company = await scopedCompany(db, id, tenantScope(c.get("user")));
- if (!company) return c.json({ error: "Empresa no encontrada" }, 404);
- const documents = await db.prepare(
- "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM company_documents WHERE company_id = ? ORDER BY uploaded_at DESC",
- ).all(id);
- return c.json({
- company,
- documents,
- checklist: await companyChecklistFor(db, id),
+ const tid = tenantScope(c.get("user"));
+ const route = routeLabel(c);
+ const companyEnv = await callCoreFn<{ company: Record }>(db, "core.fn_company_get", { id }, { route });
+ if (!companyEnv.ok) return respondRpc(c, companyEnv);
+ const company = companyEnv.data?.company;
+ if (!company) {
+ return respondApiError(c, "NOT_FOUND", `Empresa id=${id} no encontrada en ${route}`, { route, company_id: id });
+ }
+ if (tid != null && company.tenant_id != null && company.tenant_id !== tid) {
+ return respondApiError(c, "NOT_FOUND", `Empresa id=${id} no encontrada en ${route}`, { route, company_id: id });
+ }
+ const docsEnv = await callCoreFn<{ documents: unknown[] }>(
+ db,
+ "core.fn_company_document_list",
+ { company_id: id },
+ { route },
+ );
+ if (!docsEnv.ok) return respondRpc(c, docsEnv);
+ return respondRpc(c, {
+ ok: true,
+ code: "OK",
+ layer: "db",
+ message: `Empresa ${company.code} cargada`,
+ data: {
+ company,
+ documents: docsEnv.data?.documents ?? [],
+ checklist: await companyChecklistFor(db, id),
+ },
});
});
@@ -341,22 +430,19 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => {
const companyId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId"));
const db = c.get("db");
- if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404);
- const doc = await db.prepare(
- "SELECT * FROM company_documents WHERE id=? AND company_id=?",
- ).get(docId, companyId) as {
- storage_name: string;
- iv: string;
- mime: string;
- original_name: string;
- } | undefined;
- if (!doc) return c.json({ error: "Documento no encontrado" }, 404);
+ const route = routeLabel(c);
+ if (!await scopedCompany(db, companyId, tenantScope(c.get("user")))) {
+ return respondApiError(c, "NOT_FOUND", `Empresa id=${companyId} no encontrada en ${route}`, { route, company_id: companyId });
+ }
+ const { doc, envelope } = await rpcDocumentForDownload(
+ db,
+ "core.fn_company_document_get",
+ { company_id: companyId, id: docId },
+ route,
+ );
+ if (!doc) return respondRpc(c, envelope);
const enc = await getObject(companyDocKey(companyId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
- // Documentos subidos por el usuario se sirven como adjunto, nunca inline:
- // servir "inline" dejaría que el navegador renderice el Content-Type que
- // el propio uploader eligió (ej. un .html disfrazado de "comprobante"),
- // habilitando XSS almacenado dentro del origen autenticado de la app.
c.header("Content-Type", "application/octet-stream");
c.header("X-Content-Type-Options", "nosniff");
c.header("Content-Disposition", `attachment; filename="${encodeURIComponent(doc.original_name)}"`);
@@ -365,36 +451,20 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, async (c) => {
app.get("/v1/projects", ...requireCoreAuth, async (c) => {
const db = c.get("db");
- const tid = tenantScope(c.get("user"));
const status = (c.req.query("status") ?? "").trim();
const allowed = status ? status.split(",").map((s) => s.trim()).filter(isProjectStatus) : [];
- const clauses: string[] = [];
- const params: (string | number)[] = [];
- if (tid != null) {
- clauses.push("p.tenant_id = ?");
- params.push(tid);
+ if (status && !allowed.length) {
+ return respondApiError(c, "VALIDATION", `Ningún estado de proyecto válido en ${routeLabel(c)}: ${status}`, {
+ route: routeLabel(c),
+ status,
+ });
}
- if (allowed.length) {
- clauses.push(`p.status IN (${allowed.map(() => "?").join(",")})`);
- params.push(...allowed);
- }
- const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
- return c.json({
- projects: await db.prepare(
- `SELECT p.*, c.name AS company_name, c.code AS company_code,
- (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count,
- (SELECT COUNT(*) FROM project_document_types t
- WHERE t.required = true AND NOT EXISTS (
- SELECT 1 FROM project_documents d
- WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true
- )) AS missing_docs,
- (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count
- FROM projects p
- LEFT JOIN companies c ON c.id = p.company_id
- ${where}
- ORDER BY CASE p.status WHEN 'activo' THEN 0 WHEN 'pausado' THEN 1 WHEN 'concluido' THEN 2 ELSE 3 END, p.id`,
- ).all(...params),
- });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_project_list",
+ { status: allowed.length ? allowed.join(",") : undefined },
+ { route: routeLabel(c) },
+ ));
});
type ProjectInput = {
@@ -446,98 +516,129 @@ function projectFields(body: ProjectInput, cur?: Record) {
app.post("/v1/projects", ...requireCoreAuth, async (c) => {
const body = await c.req.json();
- if (!body.name?.trim()) return c.json({ error: "Nombre de proyecto obligatorio" }, 400);
+ if (!body.name?.trim()) {
+ return respondApiError(c, "VALIDATION", `Nombre de proyecto obligatorio en ${routeLabel(c)}`, {
+ route: routeLabel(c),
+ });
+ }
const status = body.status && isProjectStatus(body.status) ? body.status : "activo";
const db = c.get("db");
const fields = projectFields(body);
- if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400);
+ if (!fields.company_id) {
+ return respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${routeLabel(c)}`, {
+ route: routeLabel(c),
+ });
+ }
if (!await resolveCompany(db, { company_id: fields.company_id })) {
- return c.json({ error: "Empresa no encontrada" }, 400);
+ return respondApiError(c, "VALIDATION", `Empresa id=${fields.company_id} no encontrada en ${routeLabel(c)}`, {
+ route: routeLabel(c),
+ company_id: fields.company_id,
+ });
}
if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) {
- return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400);
+ return respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${routeLabel(c)}`, {
+ route: routeLabel(c),
+ });
}
- const code = await nextProjectCode(db);
const tid = tenantScope(c.get("user"));
- await db.prepare(
- `INSERT INTO projects (
- code, name, address, theme_id, status, company_id, contract_amount,
- start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
- code,
- fields.name,
- fields.address,
- fields.theme_id,
- status,
- fields.company_id,
- fields.contract_amount,
- fields.start_date,
- fields.end_date,
- fields.resident_name,
- fields.siroc,
- fields.payroll_tax_pct,
- tid,
- );
- const id = await lastInsertId(db);
- return c.json({ id, code, status }, 201);
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_project_create",
+ {
+ tenant_id: tid,
+ name: fields.name,
+ address: fields.address,
+ theme_id: fields.theme_id,
+ status,
+ company_id: fields.company_id,
+ contract_amount: fields.contract_amount,
+ start_date: fields.start_date,
+ end_date: fields.end_date,
+ resident_name: fields.resident_name,
+ siroc: fields.siroc,
+ payroll_tax_pct: fields.payroll_tax_pct,
+ },
+ { route: routeLabel(c) },
+ ));
});
app.patch("/v1/projects/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json();
const db = c.get("db");
- // Con RLS activo (db/core/changesets/005-rls.sql), esta consulta ya no
- // puede traer una fila de otro tenant: la conexión solo ve app.tenant_id.
- const cur = await db.prepare("SELECT * FROM projects WHERE id = ?").get(id) as Record | undefined;
- if (!cur) return c.json({ error: "Proyecto no encontrado" }, 404);
+ const route = routeLabel(c);
+ const curEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route });
+ if (!curEnv.ok) return respondRpc(c, curEnv);
+ const cur = curEnv.data?.project;
+ if (!cur) {
+ return respondApiError(c, "NOT_FOUND", `Proyecto id=${id} no encontrado en ${route}`, { route, project_id: id });
+ }
const status = body.status && isProjectStatus(body.status) ? body.status : String(cur.status || "activo");
const fields = projectFields(body, cur);
- if (!fields.company_id) return c.json({ error: "Seleccione la empresa del proyecto" }, 400);
+ if (!fields.company_id) {
+ return respondApiError(c, "VALIDATION", `Seleccione la empresa del proyecto en ${route}`, { route, project_id: id });
+ }
if (!await resolveCompany(db, { company_id: fields.company_id })) {
- return c.json({ error: "Empresa no encontrada" }, 400);
+ return respondApiError(c, "VALIDATION", `Empresa id=${fields.company_id} no encontrada en ${route}`, {
+ route,
+ project_id: id,
+ company_id: fields.company_id,
+ });
}
if (fields.start_date && fields.end_date && fields.end_date < fields.start_date) {
- return c.json({ error: "La fecha de término no puede ser anterior al inicio" }, 400);
+ return respondApiError(c, "VALIDATION", `La fecha de término no puede ser anterior al inicio en ${route}`, {
+ route,
+ project_id: id,
+ });
}
- await db.prepare(
- `UPDATE projects SET name=?, address=?, theme_id=?, status=?, company_id=?, contract_amount=?,
- start_date=?, end_date=?, resident_name=?, siroc=?, payroll_tax_pct=? WHERE id=?`,
- ).run(
- fields.name,
- fields.address,
- fields.theme_id,
- status,
- fields.company_id,
- fields.contract_amount,
- fields.start_date,
- fields.end_date,
- fields.resident_name,
- fields.siroc,
- fields.payroll_tax_pct,
- id,
- );
- return c.json({ ok: true, id, code: cur.code, status });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_project_update",
+ {
+ id,
+ name: fields.name,
+ address: fields.address,
+ theme_id: fields.theme_id,
+ status,
+ company_id: fields.company_id,
+ contract_amount: fields.contract_amount,
+ start_date: fields.start_date,
+ end_date: fields.end_date,
+ resident_name: fields.resident_name,
+ siroc: fields.siroc,
+ payroll_tax_pct: fields.payroll_tax_pct,
+ },
+ { route },
+ ));
});
app.get("/v1/projects/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
- const project = await db.prepare(
- `SELECT p.*, c.name AS company_name, c.code AS company_code,
- (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count
- FROM projects p
- LEFT JOIN companies c ON c.id = p.company_id
- WHERE p.id = ?`,
- ).get(id);
- if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
- const documents = await db.prepare(
- "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM project_documents WHERE project_id = ? ORDER BY uploaded_at DESC",
- ).all(id);
- return c.json({
- project,
- documents,
- checklist: await projectChecklistFor(db, id),
+ const route = routeLabel(c);
+ const projectEnv = await callCoreFn<{ project: Record }>(db, "core.fn_project_get", { id }, { route });
+ if (!projectEnv.ok) return respondRpc(c, projectEnv);
+ const project = projectEnv.data?.project;
+ if (!project) {
+ return respondApiError(c, "NOT_FOUND", `Proyecto id=${id} no encontrado en ${route}`, { route, project_id: id });
+ }
+ const docsEnv = await callCoreFn<{ documents: unknown[] }>(
+ db,
+ "core.fn_project_document_list",
+ { project_id: id },
+ { route },
+ );
+ if (!docsEnv.ok) return respondRpc(c, docsEnv);
+ return respondRpc(c, {
+ ok: true,
+ code: "OK",
+ layer: "db",
+ message: `Proyecto ${project.code} cargado`,
+ data: {
+ project,
+ documents: docsEnv.data?.documents ?? [],
+ checklist: await projectChecklistFor(db, id),
+ },
});
});
@@ -562,15 +663,14 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, async (c) => {
const projectId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId"));
const db = c.get("db");
- const doc = await db.prepare(
- "SELECT * FROM project_documents WHERE id=? AND project_id=?",
- ).get(docId, projectId) as {
- storage_name: string;
- iv: string;
- mime: string;
- original_name: string;
- } | undefined;
- if (!doc) return c.json({ error: "Documento no encontrado" }, 404);
+ const route = routeLabel(c);
+ const { doc, envelope } = await rpcDocumentForDownload(
+ db,
+ "core.fn_project_document_get",
+ { project_id: projectId, id: docId },
+ route,
+ );
+ if (!doc) return respondRpc(c, envelope);
const enc = await getObject(projectDocKey(projectId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
c.header("Content-Type", "application/octet-stream");
@@ -644,18 +744,24 @@ app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, async (c) => {
app.post("/v1/projects/:id/budget/chapters", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json<{ name?: string; code?: string; parent_id?: number | null }>();
- if (!body.name?.trim()) return c.json({ error: "Nombre de capítulo obligatorio" }, 400);
+ if (!body.name?.trim()) {
+ return respondApiError(c, "VALIDATION", `Nombre de capítulo obligatorio en ${routeLabel(c)}`, {
+ route: routeLabel(c),
+ project_id: id,
+ });
+ }
const db = c.get("db");
- if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
- const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_chapters WHERE project_id = ?").get(id) as { n: number }).n;
- await db.prepare("INSERT INTO budget_chapters (project_id, parent_id, code, name, sort_order) VALUES (?, ?, ?, ?, ?)").run(
- id,
- body.parent_id || null,
- (body.code || "").trim(),
- body.name.trim(),
- sort,
- );
- return c.json({ id: await lastInsertId(db) }, 201);
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_budget_chapter_create",
+ {
+ project_id: id,
+ parent_id: body.parent_id || null,
+ code: (body.code || "").trim(),
+ name: body.name.trim(),
+ },
+ { route: routeLabel(c) },
+ ));
});
app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => {
@@ -663,25 +769,29 @@ app.patch("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c)
const cid = Number(c.req.param("cid"));
const body = await c.req.json<{ name?: string; code?: string }>();
const db = c.get("db");
- const cur = await db.prepare("SELECT * FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id) as { name: string; code: string } | undefined;
- if (!cur) return c.json({ error: "Capítulo no encontrado" }, 404);
- await db.prepare("UPDATE budget_chapters SET name = ?, code = ? WHERE id = ?").run(
- (body.name ?? cur.name).trim(),
- (body.code ?? cur.code).trim(),
- cid,
- );
- return c.json({ ok: true });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_budget_chapter_update",
+ {
+ project_id: id,
+ id: cid,
+ name: body.name,
+ code: body.code,
+ },
+ { route: routeLabel(c) },
+ ));
});
app.delete("/v1/projects/:id/budget/chapters/:cid", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const cid = Number(c.req.param("cid"));
const db = c.get("db");
- const found = await db.prepare("SELECT id FROM budget_chapters WHERE id = ? AND project_id = ?").get(cid, id);
- if (!found) return c.json({ error: "Capítulo no encontrado" }, 404);
- await db.prepare("UPDATE budget_items SET chapter_id = NULL WHERE project_id = ? AND chapter_id = ?").run(id, cid);
- await db.prepare("DELETE FROM budget_chapters WHERE id = ? AND project_id = ?").run(cid, id);
- return c.json({ ok: true });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_budget_chapter_delete",
+ { project_id: id, id: cid },
+ { route: routeLabel(c) },
+ ));
});
type BudgetItemInput = {
@@ -696,27 +806,27 @@ type BudgetItemInput = {
app.post("/v1/projects/:id/budget/items", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json();
- if (!body.description?.trim()) return c.json({ error: "Descripción de partida obligatoria" }, 400);
+ if (!body.description?.trim()) {
+ return respondApiError(c, "VALIDATION", `Descripción de partida obligatoria en ${routeLabel(c)}`, {
+ route: routeLabel(c),
+ project_id: id,
+ });
+ }
const db = c.get("db");
- if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
- const qty = Number(body.quantity ?? 0);
- const price = Number(body.unit_price ?? 0);
- const sort = (await db.prepare("SELECT COALESCE(MAX(sort_order), 0) + 1 AS n FROM budget_items WHERE project_id = ?").get(id) as { n: number }).n;
- await db.prepare(
- `INSERT INTO budget_items (project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
- id,
- body.chapter_id || null,
- (body.code || "").trim().toUpperCase(),
- body.description.trim(),
- (body.unit || "").trim().toUpperCase(),
- qty,
- price,
- lineAmount(qty, price),
- sort,
- );
- return c.json({ id: await lastInsertId(db) }, 201);
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_budget_item_create",
+ {
+ project_id: id,
+ chapter_id: body.chapter_id || null,
+ code: (body.code || "").trim(),
+ description: body.description.trim(),
+ unit: (body.unit || "").trim(),
+ quantity: body.quantity ?? 0,
+ unit_price: body.unit_price ?? 0,
+ },
+ { route: routeLabel(c) },
+ ));
});
app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => {
@@ -724,39 +834,40 @@ app.patch("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) =>
const iid = Number(c.req.param("iid"));
const body = await c.req.json();
const db = c.get("db");
- const cur = await db.prepare("SELECT * FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id) as Record | undefined;
- if (!cur) return c.json({ error: "Partida no encontrada" }, 404);
- const qty = body.quantity !== undefined ? Number(body.quantity) : Number(cur.quantity);
- const price = body.unit_price !== undefined ? Number(body.unit_price) : Number(cur.unit_price);
- await db.prepare(
- `UPDATE budget_items SET chapter_id=?, code=?, description=?, unit=?, quantity=?, unit_price=?, amount=? WHERE id=?`,
- ).run(
- body.chapter_id !== undefined ? (body.chapter_id || null) : cur.chapter_id,
- (body.code ?? String(cur.code ?? "")).trim().toUpperCase(),
- (body.description ?? String(cur.description ?? "")).trim(),
- (body.unit ?? String(cur.unit ?? "")).trim().toUpperCase(),
- qty,
- price,
- lineAmount(qty, price),
- iid,
- );
- return c.json({ ok: true });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_budget_item_update",
+ {
+ project_id: id,
+ id: iid,
+ chapter_id: body.chapter_id,
+ code: body.code,
+ description: body.description,
+ unit: body.unit,
+ quantity: body.quantity,
+ unit_price: body.unit_price,
+ },
+ { route: routeLabel(c) },
+ ));
});
app.delete("/v1/projects/:id/budget/items/:iid", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const iid = Number(c.req.param("iid"));
const db = c.get("db");
- const found = await db.prepare("SELECT id FROM budget_items WHERE id = ? AND project_id = ?").get(iid, id);
- if (!found) return c.json({ error: "Partida no encontrada" }, 404);
- await db.prepare("DELETE FROM budget_items WHERE id = ? AND project_id = ?").run(iid, id);
- return c.json({ ok: true });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_budget_item_delete",
+ { project_id: id, id: iid },
+ { route: routeLabel(c) },
+ ));
});
app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => {
const body = await c.req.json();
const errors = validateWorkerFields(body);
const db = c.get("db");
+ const route = routeLabel(c);
const exclude = body.id ?? 0;
const checks: { field: string; value: string; err: string | null }[] = [
{ field: "curp", value: normUpper(body.curp), err: validateCurp(body.curp ?? "") },
@@ -765,9 +876,16 @@ app.post("/v1/workers/validate", ...requireCoreAuth, async (c) => {
];
for (const ch of checks) {
if (ch.err) continue;
- const row = await db.prepare(
- `SELECT id, first_name, last_name_p FROM workers WHERE ${ch.field} = ? AND id != ?`,
- ).get(ch.value, exclude) as { id: number; first_name: string; last_name_p: string } | undefined;
+ const env = await callCoreFn<{ worker?: { id: number; first_name: string; last_name_p: string } | null; found?: boolean }>(
+ db,
+ "core.fn_worker_find_existing",
+ {
+ [ch.field]: ch.value,
+ exclude_id: exclude,
+ },
+ { route },
+ );
+ const row = env.ok && env.data?.found ? env.data.worker : undefined;
if (row) {
errors[ch.field] =
`Este ${ch.field.toUpperCase()} ya pertenece a ${row.first_name} ${row.last_name_p}`;
@@ -782,128 +900,36 @@ app.get("/v1/workers", ...requireCoreAuth, async (c) => {
const q = (c.req.query("q") ?? "").trim();
const status = c.req.query("status");
const projectId = c.req.query("project_id");
- let sql = `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text,
- c.name AS company_name, c.kind AS company_kind,
- ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal,
- (SELECT STRING_AGG(p.name, ', ') FROM assignments a JOIN projects p ON p.id = a.project_id
- WHERE a.worker_id = w.id AND a.active = true) AS proyectos,
- (SELECT STRING_AGG(p.code || '|' || replace(p.name, '|', '/'), ';;') FROM assignments a JOIN projects p ON p.id = a.project_id
- WHERE a.worker_id = w.id AND a.active = true) AS proyecto_pairs,
- (SELECT STRING_AGG(p.id::text, ',') FROM assignments a JOIN projects p ON p.id = a.project_id
- WHERE a.worker_id = w.id AND a.active = true) AS project_ids,
- (SELECT COUNT(*) FROM document_types t
- WHERE t.required = true AND NOT EXISTS (
- SELECT 1 FROM documents d
- WHERE d.worker_id = w.id AND d.type_code = t.code AND d.is_current = true
- )) AS missing_docs,
- (SELECT COALESCE(SUM(l.balance), 0) FROM loans l WHERE l.worker_id = w.id AND l.balance > 0) AS loan_balance,
- CASE WHEN EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.active = true) THEN true ELSE false END AS in_project
- FROM workers w
- JOIN risk_levels r ON r.code = w.risk_code
- LEFT JOIN companies c ON c.id = w.company_id
- LEFT JOIN companies ic ON ic.id = w.imss_company_id
- WHERE 1=1`;
- const params: (string | number)[] = [];
- if (tid != null) {
- sql += " AND w.tenant_id = ?";
- params.push(tid);
- }
- if (status) {
- sql += " AND w.status = ?";
- params.push(status);
- if (status === "activo") sql += " AND w.pipeline_status != 'baja'";
- }
- if (projectId) {
- sql +=
- " AND EXISTS (SELECT 1 FROM assignments a WHERE a.worker_id = w.id AND a.project_id = ? AND a.active = true)";
- params.push(Number(projectId));
- }
- if (q) {
- sql +=
- " AND (w.first_name ILIKE ? OR w.last_name_p ILIKE ? OR w.curp ILIKE ? OR w.rfc ILIKE ? OR w.nss ILIKE ?)";
- const like = `%${q}%`;
- params.push(like, like, like, like, like);
- }
- sql += " ORDER BY w.last_name_p, w.first_name";
- const rows = await db.prepare(sql).all(...params) as Array & {
- id: number;
- status: string;
- pipeline_status: string;
- imss_status?: string;
- in_project?: boolean;
- }>;
- const workers = [];
- for (const row of rows) {
- const flags = await imssFlagsFor(db, row.id, tid);
- workers.push({
- ...row,
- imss_status: flags.imss_status,
- imss_ready: flags.imss_ready,
- in_project_without_imss: flags.in_project_without_imss,
- expediente_ok: flags.expediente_ok,
- freshness_required: flags.freshness_required,
- });
- }
- const active = workers.filter((w) => w.status === "activo" && w.pipeline_status !== "baja");
- const withImss = active.filter((w) => w.imss_status === "alta").length;
- const withoutImss = active.length - withImss;
- const inProjectWithoutImss = active.filter((w) => w.in_project_without_imss).length;
- const imssReady = active.filter((w) => w.imss_ready).length;
- return c.json({
- workers,
- imss_stats: {
- with_imss: withImss,
- without_imss: withoutImss,
- in_project_without_imss: inProjectWithoutImss,
- imss_ready_count: imssReady,
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_worker_list",
+ {
+ tenant_id: tid,
+ q: q || undefined,
+ status: status || undefined,
+ project_id: projectId ? Number(projectId) : undefined,
},
- });
+ { route: routeLabel(c) },
+ ));
});
app.get("/v1/workers/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const tid = tenantScope(c.get("user"));
- const worker = await db.prepare(
- `SELECT w.*, r.label AS risk_label, r.color AS risk_color, r.text_color AS risk_text,
- c.name AS company_name, c.kind AS company_kind,
- ic.name AS imss_company_name, ic.code AS imss_company_code, ic.registro_patronal AS imss_registro_patronal
- FROM workers w
- JOIN risk_levels r ON r.code = w.risk_code
- LEFT JOIN companies c ON c.id = w.company_id
- LEFT JOIN companies ic ON ic.id = w.imss_company_id
- WHERE w.id = ?`,
- ).get(id);
- if (!worker) return c.json({ error: "No encontrado" }, 404);
- const documents = await db.prepare(
- `SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at,
- issued_at, expires_at, imss_company_id, imss_alta_at
- FROM documents WHERE worker_id = ? ORDER BY uploaded_at DESC`,
- ).all(id);
- const assignments = await db.prepare(
- `SELECT a.*, p.name AS project_name, p.code AS project_code FROM assignments a
- JOIN projects p ON p.id = a.project_id WHERE a.worker_id = ?
- ORDER BY a.active DESC, a.start_date DESC, a.id DESC`,
- ).all(id);
- const loans = await db.prepare("SELECT * FROM loans WHERE worker_id = ? ORDER BY id DESC").all(id);
- const { items, freshness_required } = await checklistItemsFor(db, id, tid);
- const flags = await imssFlagsFor(db, id, tid);
- const docTypes = await db.prepare(
- `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category
- FROM document_types ORDER BY required DESC, label`,
- ).all();
- return c.json({
- worker: { ...worker, full_name: fullName(worker as never), ...flags },
- documents,
- assignments,
- loans,
- checklist: items,
- document_types: docTypes,
- freshness_required,
- });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_worker_get",
+ { id, tenant_id: tid },
+ { route: routeLabel(c) },
+ ));
});
-function conflict(row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined, n: ReturnType, excludeId = 0) {
+function conflict(
+ row: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined,
+ n: ReturnType,
+ excludeId = 0,
+) {
if (row && row.id !== excludeId) {
const field = row.curp === n.curp ? "CURP" : row.rfc === n.rfc ? "RFC" : "NSS";
return {
@@ -917,97 +943,113 @@ function conflict(row: { id: number; first_name: string; last_name_p: string; cu
return null;
}
+async function findExistingWorker(
+ db: Db,
+ curp: string,
+ rfc: string,
+ nss: string,
+ excludeId = 0,
+ route = "worker-duplicate-check",
+): Promise<
+ { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined
+> {
+ const env = await callCoreFn<{
+ worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null;
+ found?: boolean;
+ }>(db, "core.fn_worker_find_existing", { curp, rfc, nss, exclude_id: excludeId }, { route });
+ if (!env.ok || !env.data?.found) return undefined;
+ return env.data.worker ?? undefined;
+}
+
app.post("/v1/workers", ...requireCoreAuth, async (c) => {
const body = await c.req.json();
const errors = validateWorkerFields(body);
const db = c.get("db");
- if (Object.keys(errors).length) return c.json({ errors }, 400);
+ const route = routeLabel(c);
+ if (Object.keys(errors).length) {
+ return respondApiError(c, "VALIDATION", `Datos del trabajador inválidos en ${route}`, { route }, errors);
+ }
const n = normalizeWorker({ ...body, hire_type: "" });
- const existing = await findExisting(db, n.curp, n.rfc, n.nss);
+ const existing = await findExistingWorker(db, n.curp, n.rfc, n.nss, 0, route);
const cf = conflict(existing, n);
- if (cf) return c.json(cf.body, cf.status);
+ if (cf) {
+ return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id });
+ }
if (body.project_id) {
const blocked = projectMustBe(
await projectById(db, body.project_id),
["activo"],
"Solo se asigna personal a proyectos activos",
);
- if (blocked) return c.json({ error: blocked.error }, blocked.status);
+ if (blocked) {
+ const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION";
+ return respondApiError(c, code, blocked.error, { route, project_id: body.project_id });
+ }
}
const tid = tenantScope(c.get("user"));
- 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(
- n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss,
- n.phone, n.email, n.address, n.blood_type, "", null, n.position, n.risk_code,
- n.work_type, n.daily_wage, n.needs_badge, n.status, tid,
- );
- const id = await lastInsertId(db);
- if (body.project_id) {
- await assign(db, id, body.project_id);
- }
- await refreshPipeline(db, id, tid);
- return c.json({ id }, 201);
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_worker_create",
+ { ...body, tenant_id: tid, project_id: body.project_id ?? null },
+ { route },
+ ));
});
app.patch("/v1/workers/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
const tid = tenantScope(c.get("user"));
- const cur = await db.prepare("SELECT * FROM workers WHERE id = ?").get(id) as Record | undefined;
- if (!cur) return c.json({ error: "No encontrado" }, 404);
+ const route = routeLabel(c);
+ const curEnv = await callCoreFn<{ worker: Record }>(db, "core.fn_worker_get", { id, tenant_id: tid }, { route });
+ if (!curEnv.ok) return respondRpc(c, curEnv);
+ const cur = curEnv.data?.worker;
+ if (!cur) {
+ return respondApiError(c, "NOT_FOUND", `Trabajador id=${id} no encontrado en ${route}`, { route, worker_id: id });
+ }
const body = await c.req.json();
const merged = { ...cur, ...body } as WorkerInput;
const errors = validateWorkerFields(merged);
- if (Object.keys(errors).length) return c.json({ errors }, 400);
- // Empresa/patrón solo cambia con alta/baja IMSS; no se sobrescribe desde el formulario.
- const hireType = String(cur.hire_type || "");
- const companyId = (cur.company_id as number | null) ?? null;
- const n = normalizeWorker({ ...merged, hire_type: hireType });
- const existing = await findExisting(db, n.curp, n.rfc, n.nss);
+ if (Object.keys(errors).length) {
+ return respondApiError(c, "VALIDATION", `Datos del trabajador inválidos en ${route}`, { route, worker_id: id }, errors);
+ }
+ const n = normalizeWorker({ ...merged, hire_type: String(cur.hire_type || "") });
+ const existing = await findExistingWorker(db, n.curp, n.rfc, n.nss, id, route);
const cf = conflict(existing, n, id);
- if (cf) return c.json(cf.body, cf.status);
- 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=?, position=?, risk_code=?,
- work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=now()
- WHERE id=?`,
- ).run(
- n.first_name, n.middle_name, n.last_name_p, n.last_name_m, n.curp, n.rfc, n.nss,
- n.phone, n.email, n.address, n.blood_type, hireType, companyId, n.position, n.risk_code,
- n.work_type, n.daily_wage, n.needs_badge, n.status, id,
- );
- await refreshPipeline(db, id, tid);
- return c.json({ ok: true });
+ if (cf) {
+ return respondApiError(c, "CONFLICT", cf.body.error, { route, worker_id: cf.body.worker_id });
+ }
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_worker_update",
+ { id, tenant_id: tid, ...body },
+ { route },
+ ));
});
app.patch("/v1/workers/:id/pipeline", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const { pipeline_status } = await c.req.json<{ pipeline_status: string }>();
const allowed = ["incompleto", "listo_gafete", "impreso", "activo", "baja"];
- if (!allowed.includes(pipeline_status)) return c.json({ error: "Estado inválido" }, 400);
+ if (!allowed.includes(pipeline_status)) {
+ return respondApiError(c, "VALIDATION", `Estado de pipeline inválido en ${routeLabel(c)}: ${pipeline_status}`, {
+ route: routeLabel(c),
+ worker_id: id,
+ pipeline_status,
+ });
+ }
const db = c.get("db");
const tid = tenantScope(c.get("user"));
- const prev = await db.prepare("SELECT status FROM workers WHERE id=?").get(id) as { status: string } | undefined;
- if (pipeline_status === "baja") {
- await db.prepare("UPDATE workers SET status='baja', pipeline_status='baja' WHERE id=?").run(id);
- } else {
- // Reactivar: si venía de baja, marca rehire para exigir docs frescos hasta nueva alta IMSS.
- if (prev?.status === "baja") {
- await db.prepare(
- "UPDATE workers SET status='activo', last_rehire_at=current_date WHERE id=?",
- ).run(id);
- } else {
- await db.prepare("UPDATE workers SET status='activo' WHERE id=?").run(id);
- }
- await refreshPipeline(db, id, tid);
- }
- const worker = await db.prepare("SELECT status, pipeline_status, last_rehire_at, imss_status FROM workers WHERE id=?").get(id);
- return c.json({ ok: true, worker });
+ const env = await callCoreFn(
+ db,
+ "core.fn_worker_set_pipeline",
+ { id, tenant_id: tid, pipeline_status },
+ { route: routeLabel(c) },
+ );
+ if (!env.ok) return respondRpc(c, env);
+ return respondRpc(c, {
+ ...env,
+ data: { ok: true, ...(env.data as Record) },
+ });
});
app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => {
@@ -1015,20 +1057,35 @@ app.post("/v1/workers/:id/assign", ...requireCoreAuth, async (c) => {
const { project_id, active } = await c.req.json<{ project_id: number; active?: boolean }>();
const db = c.get("db");
const tid = tenantScope(c.get("user"));
+ const route = routeLabel(c);
if (active === false) {
- await db.prepare(
- "UPDATE assignments SET active=false, end_date=current_date WHERE worker_id=? AND project_id=?",
- ).run(id, project_id);
- } else {
- const project = await projectById(db, project_id);
- if (!project) return c.json({ error: "Proyecto no encontrado" }, 404);
- if (project.status !== "activo") {
- return c.json({ error: "Solo se asigna personal a proyectos activos" }, 400);
- }
- await assign(db, id, project_id);
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_worker_unassign",
+ { worker_id: id, project_id, tenant_id: tid },
+ { route },
+ ));
}
- await refreshPipeline(db, id, tid);
- return c.json({ ok: true });
+ const project = await projectById(db, project_id);
+ if (!project) {
+ return respondApiError(c, "NOT_FOUND", `Proyecto id=${project_id} no encontrado en ${route}`, {
+ route,
+ project_id,
+ });
+ }
+ if (project.status !== "activo") {
+ return respondApiError(c, "VALIDATION", `Solo se asigna personal a proyectos activos en ${route}`, {
+ route,
+ project_id,
+ status: project.status,
+ });
+ }
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_worker_assign",
+ { worker_id: id, project_id, tenant_id: tid },
+ { route },
+ ));
});
app.get("/v1/workers/import/template", ...requireCoreAuth, async (c) => {
@@ -1063,19 +1120,36 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => {
const form = await c.req.formData();
const type = String(form.get("type") || "");
const file = form.get("file");
- if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400);
+ if (!(file instanceof File) || !type) {
+ return respondApiError(c, "VALIDATION", `type y file son requeridos en ${routeLabel(c)}`, {
+ route: routeLabel(c),
+ worker_id: id,
+ });
+ }
const db = c.get("db");
- const exists = await db.prepare("SELECT id FROM workers WHERE id=?").get(id);
- if (!exists) return c.json({ error: "No encontrado" }, 404);
+ const tid = tenantScope(c.get("user"));
+ const route = routeLabel(c);
+ const workerEnv = await callCoreFn(db, "core.fn_worker_get", { id, tenant_id: tid }, { route });
+ if (!workerEnv.ok) return respondRpc(c, workerEnv);
- const policy = await db.prepare(
- `SELECT validity_mode, requires_issued_at, requires_expires_at FROM document_types WHERE code = ?`,
- ).get(type) as {
+ const catEnv = await callCoreFn<{ document_types?: Array> }>(
+ db,
+ "core.fn_catalogs",
+ {},
+ { route },
+ );
+ const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as {
validity_mode: string;
requires_issued_at: boolean;
requires_expires_at: boolean;
} | undefined;
- if (!policy) return c.json({ error: "Tipo de documento no válido" }, 400);
+ if (!policy) {
+ return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, {
+ route,
+ worker_id: id,
+ type,
+ });
+ }
const issuedAt = String(form.get("issued_at") || "").trim() || null;
const expiresAt = String(form.get("expires_at") || "").trim() || null;
@@ -1084,19 +1158,19 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => {
const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null;
if (policy.requires_issued_at && !issuedAt) {
- return c.json({ error: "Indique la fecha de emisión del documento" }, 400);
+ return respondApiError(c, "VALIDATION", `Indique la fecha de emisión del documento en ${route}`, { route, worker_id: id });
}
if (policy.requires_expires_at && !expiresAt) {
- return c.json({ error: "Indique la fecha de vigencia / vencimiento" }, 400);
+ return respondApiError(c, "VALIDATION", `Indique la fecha de vigencia / vencimiento en ${route}`, { route, worker_id: id });
}
if (type === "alta_imss" && !imssCompanyId) {
- return c.json({ error: "Seleccione la empresa patrón del alta IMSS" }, 400);
+ return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id });
}
if (type === "alta_imss" && !imssAltaAt) {
- return c.json({ error: "Indique la fecha de alta IMSS" }, 400);
+ return respondApiError(c, "VALIDATION", `Indique la fecha de alta IMSS en ${route}`, { route, worker_id: id });
}
if (type === "baja_imss" && !imssBajaAt) {
- return c.json({ error: "Indique la fecha de baja IMSS" }, 400);
+ return respondApiError(c, "VALIDATION", `Indique la fecha de baja IMSS en ${route}`, { route, worker_id: id });
}
const bytes = new Uint8Array(await file.arrayBuffer());
@@ -1109,9 +1183,9 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, async (c) => {
imss_baja_at: imssBajaAt,
});
} catch (error) {
- return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar" }, 400);
+ const message = error instanceof Error ? error.message : "No se pudo guardar el documento";
+ return respondApiError(c, "VALIDATION", message, { route, worker_id: id });
}
- const tid = tenantScope(c.get("user"));
return c.json({
ok: true,
checklist: await checklistFor(db, id, tid),
@@ -1123,15 +1197,14 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, async (c) => {
const workerId = Number(c.req.param("id"));
const docId = Number(c.req.param("docId"));
const db = c.get("db");
- const doc = await db.prepare(
- "SELECT * FROM documents WHERE id=? AND worker_id=?",
- ).get(docId, workerId) as {
- storage_name: string;
- iv: string;
- mime: string;
- original_name: string;
- } | undefined;
- if (!doc) return c.json({ error: "Documento no encontrado" }, 404);
+ const route = routeLabel(c);
+ const { doc, envelope } = await rpcDocumentForDownload(
+ db,
+ "core.fn_worker_document_get",
+ { worker_id: workerId, id: docId },
+ route,
+ );
+ if (!doc) return respondRpc(c, envelope);
const enc = await getObject(workerDocKey(workerId, doc.storage_name));
const plain = await decryptBytes(doc.iv, enc);
c.header("Content-Type", "application/octet-stream");
@@ -1169,68 +1242,85 @@ app.post("/v1/projects/:id/badge-jobs", ...requireCoreAuth, async (c) => {
const projectId = Number(c.req.param("id"));
const body = await c.req.json<{ worker_ids?: number[] }>().catch(() => ({ worker_ids: [] as number[] }));
const db = c.get("db");
+ const user = c.get("user");
+ const tid = tenantScope(user);
+ const route = routeLabel(c);
const blocked = projectMustBe(
await projectById(db, projectId),
["activo"],
"Solo se generan gafetes de proyectos activos",
);
- if (blocked) return c.json({ error: blocked.error }, blocked.status);
+ if (blocked) {
+ const code = blocked.status === 404 ? "NOT_FOUND" : "VALIDATION";
+ return respondApiError(c, code, blocked.error, { route, project_id: projectId });
+ }
let ids = body.worker_ids ?? [];
if (!ids.length) {
- ids = (await db.prepare(
- `SELECT w.id FROM workers w
- JOIN assignments a ON a.worker_id = w.id AND a.project_id = ? AND a.active = true
- WHERE w.status = 'activo' AND w.needs_badge = true
- AND EXISTS (SELECT 1 FROM documents d WHERE d.worker_id=w.id AND d.type_code='foto' AND d.is_current=true)`,
- ).all(projectId) as { id: number }[]).map((r) => r.id);
+ const listEnv = await callCoreFn<{ workers?: Array> }>(
+ db,
+ "core.fn_worker_list",
+ { tenant_id: tid, project_id: projectId, status: "activo" },
+ { route },
+ );
+ if (!listEnv.ok) return respondRpc(c, listEnv);
+ ids = (listEnv.data?.workers ?? [])
+ .filter((w) => w.needs_badge && ["listo_gafete", "impreso", "activo"].includes(String(w.pipeline_status)))
+ .map((w) => Number(w.id));
+ }
+ if (!ids.length) {
+ return respondApiError(c, "VALIDATION", `No hay personal activo con foto para imprimir en ${route}`, {
+ route,
+ project_id: projectId,
+ });
}
- if (!ids.length) return c.json({ error: "No hay personal activo con foto para imprimir" }, 400);
const bytes = await generateBadgePdf(db, projectId, ids);
- const user = c.get("user");
- await db.prepare(
- "INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name) VALUES (?, 'done', ?, ?)",
- ).run(projectId, user.id, user.display_name);
- const jobId = await lastInsertId(db);
- const path = await saveJobPdf(bytes, jobId);
- await db.prepare("UPDATE badge_jobs SET pdf_path=? WHERE id=?").run(path, jobId);
- const ins = db.prepare("INSERT INTO badge_job_people (job_id, worker_id) VALUES (?, ?)");
- const tid = tenantScope(user);
+ const createEnv = await callCoreFn(
+ db,
+ "core.fn_badge_job_create",
+ {
+ project_id: projectId,
+ worker_ids: ids,
+ created_by_id: user.id,
+ created_by_name: user.display_name,
+ },
+ { route },
+ );
+ if (!createEnv.ok) return respondRpc(c, createEnv);
+ const jobId = Number((createEnv.data as { id?: number })?.id);
+ await saveJobPdf(bytes, jobId);
for (const wid of ids) {
- await ins.run(jobId, wid);
await refreshPipeline(db, wid, tid);
}
- return c.json({ id: jobId, count: ids.length });
+ return respondRpc(c, {
+ ...createEnv,
+ data: { ...(createEnv.data as Record), count: ids.length },
+ });
});
app.get("/v1/badge-jobs", ...requireCoreAuth, async (c) => {
const db = c.get("db");
- const jobs = await db.prepare(
- `SELECT j.*, p.name AS project_name,
- (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id) AS people,
- (SELECT COUNT(*) FROM badge_job_people x WHERE x.job_id=j.id AND x.delivered=true) AS delivered
- FROM badge_jobs j JOIN projects p ON p.id=j.project_id ORDER BY j.id DESC`,
- ).all();
- return c.json({ jobs });
+ return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_list", {}, { route: routeLabel(c) }));
});
app.get("/v1/badge-jobs/:id", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
- const job = await db.prepare("SELECT * FROM badge_jobs WHERE id=?").get(id);
- if (!job) return c.json({ error: "Job no encontrado" }, 404);
- const people = await db.prepare(
- `SELECT p.*, w.first_name, w.last_name_p, w.position FROM badge_job_people p
- JOIN workers w ON w.id = p.worker_id WHERE p.job_id=?`,
- ).all(id);
- return c.json({ job, people });
+ return respondRpc(c, await callCoreFn(db, "core.fn_badge_job_get", { job_id: id }, { route: routeLabel(c) }));
});
app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, async (c) => {
const id = Number(c.req.param("id"));
const db = c.get("db");
- const job = await db.prepare("SELECT pdf_path FROM badge_jobs WHERE id=?").get(id) as { pdf_path: string } | undefined;
- if (!job?.pdf_path) return c.json({ error: "PDF no encontrado" }, 404);
- const bytes = await getObject(job.pdf_path);
+ const route = routeLabel(c);
+ const jobEnv = await callCoreFn<{ job?: { pdf_path?: string | null } }>(
+ db,
+ "core.fn_badge_job_get",
+ { job_id: id },
+ { route },
+ );
+ if (!jobEnv.ok) return respondRpc(c, jobEnv);
+ const pdfPath = jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id);
+ const bytes = await getObject(pdfPath);
c.header("Content-Type", "application/pdf");
c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`);
return c.body(bytes.buffer as ArrayBuffer);
@@ -1241,11 +1331,12 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, async (c) =
const workerId = Number(c.req.param("workerId"));
const { delivered } = await c.req.json<{ delivered: boolean }>();
const db = c.get("db");
- await db.prepare(
- `UPDATE badge_job_people SET delivered=?, delivered_at=CASE WHEN ? THEN now() ELSE NULL END
- WHERE job_id=? AND worker_id=?`,
- ).run(delivered, delivered, jobId, workerId);
- return c.json({ ok: true });
+ return respondRpc(c, await callCoreFn(
+ db,
+ "core.fn_badge_job_set_delivered",
+ { job_id: jobId, worker_id: workerId, delivered },
+ { route: routeLabel(c) },
+ ));
});
registerPayrollRoutes(app);
diff --git a/api/payroll.ts b/api/payroll.ts
index 62f34d6..4698762 100644
--- a/api/payroll.ts
+++ b/api/payroll.ts
@@ -117,921 +117,124 @@ export function loanConditionLabel(
return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`;
}
+import { callCoreFn } from "./rpc.ts";
+import type { RpcEnvelope } from "./http_errors.ts";
+import { mapRpcToStatus } from "./http_errors.ts";
+
+function throwFromRpc(env: RpcEnvelope): never {
+ const e = new Error(env.message) as Error & { status?: number };
+ e.status = mapRpcToStatus(String(env.code));
+ throw e;
+}
+
export async function getSettings(db: Db, tenantId: number): Promise {
- await seedDefaults(db, tenantId);
- return await db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings;
+ const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_get", { tenant_id: tenantId });
+ if (!env.ok) throwFromRpc(env);
+ return env.data!.settings;
}
-export async function saveSettings(
- db: Db,
- tenantId: number,
- patch: Partial,
-): Promise {
- const cur = await getSettings(db, tenantId);
- const next = {
- loan_commission_enabled: patch.loan_commission_enabled ?? cur.loan_commission_enabled,
- loan_commission_pct: patch.loan_commission_pct ?? cur.loan_commission_pct,
- loan_small_max: patch.loan_small_max ?? cur.loan_small_max,
- };
- await db.prepare(
- `UPDATE payroll_settings
- SET loan_commission_enabled=?, loan_commission_pct=?, loan_small_max=?
- WHERE tenant_id=?`,
- ).run(
- Boolean(next.loan_commission_enabled),
- Number(next.loan_commission_pct) || 0,
- Number(next.loan_small_max) || 0,
- tenantId,
- );
- return await getSettings(db, tenantId);
+export async function saveSettings(db: Db, tenantId: number, patch: Partial): Promise {
+ const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_save", { tenant_id: tenantId, ...patch });
+ if (!env.ok) throwFromRpc(env);
+ return env.data!.settings;
}
-export async function seedDefaults(db: Db, tenantId: number): Promise {
- await db.prepare(
- `INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max)
- VALUES (?, true, 10, 500)
- ON CONFLICT(tenant_id) DO NOTHING`,
- ).run(tenantId);
- const n = await db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number };
- if (Number(n.n) === 0) {
- const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)");
- for (const u of DEFAULT_UNITS) await ins.run(tenantId, u.code, u.label);
- }
-}
+export async function seedDefaults(db: Db, tenantId: number): Promise { await getSettings(db, tenantId); }
export async function listUnits(db: Db, tenantId: number) {
- await seedDefaults(db, tenantId);
- return await db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId);
+ const env = await callCoreFn<{ units: unknown[] }>(db, "core.fn_payroll_unit_list", { tenant_id: tenantId });
+ if (!env.ok) throwFromRpc(env);
+ return env.data?.units ?? [];
}
export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise {
- const c = code.trim().toLowerCase().replace(/\s+/g, "");
- const l = label.trim() || c;
- if (!c) throw new Error("Unidad requerida");
- await db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l);
- return await db.lastInsertId();
+ const env = await callCoreFn<{ id: number }>(db, "core.fn_payroll_unit_add", { tenant_id: tenantId, code, label });
+ if (!env.ok) throwFromRpc(env);
+ return Number(env.data?.id);
}
-export function firstDueForLoan(opts: {
- delivered: number;
- smallMax: number;
- plan: "single" | "installments";
- grantIso: string;
- weekAssembled: boolean;
-}): string {
- const small = opts.delivered <= opts.smallMax && opts.plan === "single";
- if (small) return nextSaturdayAfterThisWeek(opts.grantIso);
- if (opts.weekAssembled) return nextSaturdayAfterThisWeek(opts.grantIso);
- return thisSaturday(opts.grantIso);
+export async function createLoan(db: Db, tenantId: number, body: Record): Promise<{ id: number }> {
+ const env = await callCoreFn<{ id: number }>(db, "core.fn_loan_create", { tenant_id: tenantId, ...body });
+ if (!env.ok) throwFromRpc(env);
+ return { id: Number(env.data?.id) };
}
-export async function createLoan(
- db: Db,
- tenantId: number,
- body: {
- worker_id: number;
- delivered: number;
- commission_enabled?: boolean;
- commission_pct?: number;
- plan?: "single" | "installments";
- installments_n?: number;
- note?: string;
- grantIso?: string;
- },
-): Promise<{ id: number }> {
- const worker = await db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id);
- if (!worker) throw Object.assign(new Error("Persona no encontrada"), { status: 404 });
- const settings = await getSettings(db, tenantId);
- const delivered = roundMoney(body.delivered);
- if (delivered <= 0) throw Object.assign(new Error("Monto requerido"), { status: 400 });
- const enabled = body.commission_enabled ?? Boolean(settings.loan_commission_enabled);
- const pct = enabled ? Number(body.commission_pct ?? settings.loan_commission_pct) || 0 : 0;
- const comm = commissionAmount(delivered, pct);
- const balance = roundMoney(delivered + comm);
- const small = delivered <= settings.loan_small_max;
- let plan = body.plan ?? "single";
- let installments = Math.max(1, Math.floor(body.installments_n ?? 1));
- if (small) {
- plan = "single";
- installments = 1;
- }
- if (plan === "installments" && installments < 2) installments = 2;
- if (plan === "single") installments = 1;
- const weekly = roundMoney(balance / installments);
- const grantIso = body.grantIso ?? await resolveToday(db, tenantId);
- const week = weekContaining(grantIso);
- const current = await db.prepare(
- "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
- ).get(tenantId, week.weekStart) as { id: number; status: string } | undefined;
- const assembled = current?.status === "assembled" || current?.status === "paid";
- const firstDue = firstDueForLoan({
- delivered,
- smallMax: settings.loan_small_max,
- plan,
- grantIso,
- weekAssembled: assembled && weekdayMon0(grantIso) >= 4,
- });
- await db.prepare(
- `INSERT INTO loans
- (worker_id, amount, delivered, balance, weekly_payment, note,
- commission_pct, commission_amount, plan, installments_n, first_due)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
- body.worker_id,
- delivered,
- delivered,
- balance,
- weekly,
- body.note ?? null,
- pct,
- comm,
- plan,
- installments,
- firstDue,
- );
- const id = await db.lastInsertId();
- if (current && current.status !== "paid") await recalcWeek(db, current.id);
- return { id };
-}
-
-export async function presentOnOtherProject(
- db: Db,
- workerId: number,
- workDate: string,
- exceptProjectId?: number,
-): Promise<{ project_id: number; project_name: string } | null> {
- const row = await db.prepare(
- `SELECT a.project_id, p.name AS project_name
- FROM attendance a JOIN projects p ON p.id=a.project_id
- WHERE a.worker_id=? AND a.work_date=? AND a.present=true
- AND a.project_id != ?`,
- ).get(workerId, workDate, exceptProjectId ?? 0) as
- | { project_id: number; project_name: string }
- | undefined;
- return row ?? null;
-}
-
-export async function setAttendance(
- db: Db,
- tenantId: number,
- body: { project_id: number; worker_id: number; work_date: string; present: boolean },
-): Promise<{ ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } }> {
- if (body.present) {
- const other = await presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id);
- if (other) {
- return {
- error: `Ese día ya está marcado en ${other.project_name}`,
- status: 409,
- other,
- };
- }
- }
- await db.prepare(
- `INSERT INTO attendance (worker_id, project_id, work_date, present)
- VALUES (?, ?, ?, ?)
- ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`,
- ).run(body.worker_id, body.project_id, body.work_date, body.present);
- const { weekStart } = weekContaining(body.work_date);
- const week = await db.prepare(
- "SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
- ).get(tenantId, weekStart) as { id: number; status: string } | undefined;
- if (week && week.status !== "paid") await recalcWeek(db, week.id);
- return { ok: true };
+export async function setAttendance(db: Db, tenantId: number, body: { project_id: number; worker_id: number; work_date: string; present: boolean }) {
+ const env = await callCoreFn(db, "core.fn_attendance_set", { tenant_id: tenantId, ...body });
+ if (!env.ok) return { error: env.message, status: mapRpcToStatus(String(env.code)), other: (env.data as { other?: unknown })?.other };
+ return { ok: true as const };
}
export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise {
- await seedDefaults(db, tenantId);
- const { weekEnd } = weekContaining(weekStart);
- const start = weekContaining(weekStart).weekStart;
- let week = await db.prepare(
- "SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
- ).get(tenantId, start) as { id: number } | undefined;
- if (!week) {
- await db.prepare(
- `INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
- VALUES (?, ?, 'draft', ?)`,
- ).run(start, weekEnd, tenantId);
- week = { id: await db.lastInsertId() };
- }
- const weekId = week.id;
- const destajo = destajoPeriodBounds(start);
- const period = await db.prepare(
- "SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?",
- ).get(tenantId, destajo.periodEnd) as { id: number } | undefined;
- if (!period) {
- await db.prepare(
- `INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
- VALUES (?, ?, ?, ?)`,
- ).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId);
- }
- const projects = await db.prepare(
- `SELECT id FROM projects
- WHERE status IN ('activo', 'pausado')
- AND (? = 0 OR tenant_id = ? OR tenant_id IS NULL)
- ORDER BY name`,
- ).all(tenantId, tenantId) as { id: number }[];
- const hasSheet = db.prepare(
- `SELECT id FROM payroll_sheets
- WHERE week_id=? AND kind=? AND COALESCE(project_id, 0)=COALESCE(?, 0)`,
- );
- const insSheet = db.prepare(
- `INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`,
- );
- const ensureSheet = async (kind: string, projectId: number | null) => {
- if (await hasSheet.get(weekId, kind, projectId)) return;
- await insSheet.run(weekId, kind, projectId);
- };
- for (const p of projects) {
- await ensureSheet("obra", p.id);
- await ensureSheet("destajo", p.id);
- }
- await ensureSheet("admin", null);
- return weekId;
-}
-
-async function jornalRoster(db: Db, projectId: number, weekStart: string, weekEnd: string, weekId = 0) {
- return await db.prepare(
- `SELECT w.id, w.first_name, w.last_name_p, w.position, w.daily_wage, w.work_type
- FROM workers w
- WHERE w.status='activo' AND w.pipeline_status != 'baja'
- AND (
- EXISTS (
- SELECT 1 FROM assignments a
- WHERE a.worker_id=w.id AND a.project_id=? AND a.active=true AND w.work_type='N'
- )
- OR EXISTS (
- SELECT 1 FROM attendance att
- WHERE att.worker_id=w.id AND att.project_id=? AND att.work_date BETWEEN ? AND ?
- )
- OR EXISTS (
- SELECT 1 FROM payroll_week_lines l
- JOIN payroll_sheets s ON s.id=l.sheet_id
- WHERE l.worker_id=w.id AND s.project_id=? AND s.kind='obra' AND s.week_id=?
- )
- )
- ORDER BY w.last_name_p, w.first_name`,
- ).all(projectId, projectId, weekStart, weekEnd, projectId, weekId) as {
- id: number;
- first_name: string;
- last_name_p: string;
- position: string;
- daily_wage: number;
- work_type: string;
- }[];
-}
-
-async function sheetLine(
- db: Db,
- sheetId: number,
- workerId: number,
- destajoCutLineId: number | null,
-): Promise {
- const existing = await db.prepare(
- destajoCutLineId
- ? "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND destajo_cut_line_id=?"
- : "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=? AND destajo_cut_line_id IS NULL",
- ).get(sheetId, destajoCutLineId ?? workerId) as { id: number } | undefined;
- if (existing) return existing.id;
- await db.prepare(
- `INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id)
- VALUES (?, ?, ?)`,
- ).run(sheetId, workerId, destajoCutLineId);
- return await db.lastInsertId();
-}
-
-async function dueLoansForSaturday(db: Db, workerId: number, saturday: string): Promise {
- return await db.prepare(
- `SELECT * FROM loans
- WHERE worker_id=? AND balance > 0 AND first_due IS NOT NULL AND first_due <= ?
- ORDER BY first_due, id`,
- ).all(workerId, saturday) as LoanRow[];
-}
-
-async function paidInstallments(db: Db, loanId: number): Promise {
- const row = await db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number };
- return Number(row.n);
+ const env = await callCoreFn<{ week_id: number }>(db, "core.fn_payroll_week_ensure", { tenant_id: tenantId, week_start: weekStart });
+ if (!env.ok) throwFromRpc(env);
+ return Number(env.data?.week_id);
}
export async function recalcWeek(db: Db, weekId: number): Promise {
- const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
- id: number;
- week_start: string;
- week_end: string;
- status: string;
- required_net: number;
- tenant_id: number;
- } | undefined;
- if (!week || week.status === "paid") return;
- const saturday = week.week_end;
- const sheets = await db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as {
- id: number;
- kind: string;
- project_id: number | null;
- }[];
-
- await db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)")
- .run(weekId);
-
- for (const sheet of sheets) {
- if (sheet.kind === "obra" && sheet.project_id) {
- const roster = await jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId);
- for (const w of roster) {
- const lineId = await sheetLine(db, sheet.id, w.id, null);
- 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, sheet.project_id, week.week_start, week.week_end) as { n: number };
- const frozen = await db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number };
- const wage = week.status === "assembled" && frozen.daily_wage > 0 ? frozen.daily_wage : w.daily_wage;
- const gross = roundMoney(Number(att.n) * wage);
- await db.prepare(
- `UPDATE payroll_week_lines
- SET days=?, daily_wage=?, amount=?, gross=?, payable_net=?
- WHERE id=?`,
- ).run(Number(att.n), wage, gross, gross, gross, lineId);
- }
- }
-
- if (sheet.kind === "destajo" && sheet.project_id) {
- const period = await db.prepare(
- "SELECT id FROM destajo_periods WHERE week_id=?",
- ).get(weekId) as { id: number } | undefined;
- if (!period) continue;
- const cuts = await db.prepare(
- `SELECT c.*, j.worker_id, j.unit_price, j.unit_code, j.concepto
- FROM destajo_cut_lines c
- JOIN destajo_jobs j ON j.id=c.job_id
- WHERE c.period_id=? AND j.project_id=?`,
- ).all(period.id, sheet.project_id) as {
- id: number;
- worker_id: number;
- qty_planned: number;
- qty_actual: number;
- qty_extra: number;
- unit_price: number;
- unit_code: string;
- concepto: string;
- }[];
- const keep = new Set();
- for (const cut of cuts) {
- const lineId = await sheetLine(db, sheet.id, cut.worker_id, cut.id);
- keep.add(lineId);
- const payableQty = roundMoney(
- week.status === "draft" && cut.qty_actual === 0 && cut.qty_extra === 0
- ? cut.qty_planned
- : cut.qty_actual + cut.qty_extra,
- );
- const gross = roundMoney(payableQty * cut.unit_price);
- const requiredGross = roundMoney(cut.qty_planned * cut.unit_price);
- await db.prepare(
- `UPDATE payroll_week_lines
- SET destajo_cut_line_id=?, qty_planned=?, qty_actual=?, qty_extra=?,
- unit_price=?, unit_code=?, concepto=?, amount=?, gross=?, payable_net=?
- WHERE id=?`,
- ).run(
- cut.id,
- cut.qty_planned,
- cut.qty_actual,
- cut.qty_extra,
- cut.unit_price,
- cut.unit_code,
- cut.concepto,
- requiredGross,
- gross,
- gross,
- lineId,
- );
- }
- const stale = await db.prepare(
- "SELECT id, destajo_cut_line_id FROM payroll_week_lines WHERE sheet_id=?",
- ).all(sheet.id) as { id: number; destajo_cut_line_id: number | null }[];
- for (const row of stale) {
- if (!keep.has(row.id)) await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id);
- }
- }
-
- if (sheet.kind === "admin") {
- const lines = await db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as {
- id: number;
- amount: number;
- discounts: number;
- }[];
- for (const line of lines) {
- const gross = roundMoney(line.amount);
- await db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run(
- gross,
- roundMoney(gross - line.discounts),
- line.id,
- );
- }
- }
- }
-
- const allLines = await db.prepare(
- `SELECT l.id, l.worker_id, l.gross, l.discounts, l.payable_net
- FROM payroll_week_lines l
- JOIN payroll_sheets s ON s.id=l.sheet_id
- WHERE s.week_id=?
- ORDER BY l.gross DESC, l.id`,
- ).all(weekId) as { id: number; worker_id: number; gross: number; discounts: number; payable_net: number }[];
-
- const byWorker = new Map();
- for (const line of allLines) {
- const list = byWorker.get(line.worker_id) ?? [];
- list.push(line);
- byWorker.set(line.worker_id, list);
- }
-
- for (const [workerId, lines] of byWorker) {
- const gross = roundMoney(lines.reduce((s, l) => s + l.gross - l.discounts, 0));
- const loans = await dueLoansForSaturday(db, workerId, saturday);
- let remainingNet = Math.max(0, gross);
- let placed = false;
- for (const loan of loans) {
- if (remainingNet <= 0) break;
- const n = await paidInstallments(db, loan.id) + 1;
- const isLast = n >= (loan.installments_n || 1);
- const want = isLast ? loan.balance : Math.min(loan.weekly_payment || loan.balance, loan.balance);
- const take = roundMoney(Math.min(want, remainingNet));
- if (take <= 0) continue;
- remainingNet = roundMoney(remainingNet - take);
- if (!placed) {
- const host = lines[0];
- const label = loanConditionLabel(loan, take, n);
- await db.prepare(
- `UPDATE payroll_week_lines SET loan_id=?, loan_discount=?, loan_label=?, payable_net=? WHERE id=?`,
- ).run(loan.id, take, label, roundMoney(host.gross - host.discounts - take), host.id);
- placed = true;
- }
- break;
- }
- if (!placed) {
- for (const line of lines) {
- await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
- roundMoney(line.gross - line.discounts),
- line.id,
- );
- }
- } else {
- for (const line of lines.slice(1)) {
- await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
- roundMoney(line.gross - line.discounts),
- line.id,
- );
- }
- }
- }
-
- const tot = await db.prepare(
- `SELECT COALESCE(SUM(l.payable_net), 0) AS n
- FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id
- WHERE s.week_id=?`,
- ).get(weekId) as { n: number };
- await db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
- if (week.status === "draft") {
- await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
- await db.prepare(
- `UPDATE payroll_week_lines SET required_net=payable_net
- WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
- ).run(weekId);
- }
-}
-
-async function prefillThuSat(db: Db, projectId: number, weekStart: string, workerId: number): Promise {
- for (const offset of [3, 4, 5]) {
- const day = addDays(weekStart, offset);
- if (await presentOnOtherProject(db, workerId, day, projectId)) continue;
- const existing = await db.prepare(
- "SELECT id, present FROM attendance WHERE worker_id=? AND project_id=? AND work_date=?",
- ).get(workerId, projectId, day) as { id: number; present: boolean } | undefined;
- if (existing) continue;
- await db.prepare(
- "INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)",
- ).run(workerId, projectId, day);
- }
+ const env = await callCoreFn(db, "core.fn_payroll_week_recalc", { week_id: weekId });
+ if (!env.ok) throwFromRpc(env);
}
export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise {
- const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
- id: number;
- week_start: string;
- status: string;
- tenant_id: number;
- } | undefined;
- if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
- if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
- if (!canAssembleWeek(week.week_start, nowIso)) {
- throw Object.assign(
- new Error("La nómina se arma el jueves o después. Aún no es jueves de esta semana."),
- { status: 400 },
- );
- }
- const sheets = await db.prepare(
- "SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'",
- ).all(weekId) as { id: number; project_id: number | null }[];
- for (const sheet of sheets) {
- if (!sheet.project_id) continue;
- const roster = await jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId);
- for (const w of roster) {
- const assigned = await db.prepare(
- "SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=true",
- ).get(w.id, sheet.project_id) as { ok: number } | undefined;
- if (!assigned) continue;
- if (w.work_type !== "N") {
- const hasLine = await db.prepare(
- "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?",
- ).get(sheet.id, w.id);
- if (!hasLine) continue;
- }
- await prefillThuSat(db, sheet.project_id, week.week_start, w.id);
- }
- }
- const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
- if (period) {
- await db.prepare(
- `UPDATE destajo_cut_lines SET qty_actual = qty_planned
- WHERE period_id=? AND qty_actual=0 AND qty_planned>0`,
- ).run(period.id);
- }
- await db.prepare(
- "UPDATE payroll_weeks SET status='assembled', assembled_at=now() WHERE id=?",
- ).run(weekId);
- await recalcWeek(db, weekId);
- const payable = await db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number };
- await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId);
- await db.prepare(
- `UPDATE payroll_week_lines SET required_net=payable_net
- WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
- ).run(weekId);
+ const env = await callCoreFn(db, "core.fn_payroll_week_assemble", { week_id: weekId, today: nowIso });
+ if (!env.ok) throwFromRpc(env);
}
export async function payWeek(db: Db, weekId: number, nowIso: string): Promise {
- const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
- id: number;
- week_end: string;
- status: string;
- } | undefined;
- if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
- if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
- if (week.status !== "assembled") {
- await assembleWeek(db, weekId, nowIso);
- }
- await recalcWeek(db, weekId);
- const lines = await db.prepare(
- `SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
- FROM payroll_week_lines l
- JOIN payroll_sheets s ON s.id=l.sheet_id
- WHERE s.week_id=? AND l.loan_id IS NOT NULL AND l.loan_discount > 0`,
- ).all(weekId) as { loan_id: number; loan_discount: number; loan_label: string; worker_id: number }[];
- for (const line of lines) {
- const loan = await db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined;
- if (!loan || loan.balance <= 0) continue;
- const take = roundMoney(Math.min(line.loan_discount, loan.balance));
- if (take <= 0) continue;
- const n = await paidInstallments(db, loan.id) + 1;
- await db.prepare(
- `INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`,
- ).run(loan.id, weekId, take, n, line.loan_label);
- await db.prepare("UPDATE loans SET balance = ROUND((balance - ?)::numeric, 2) WHERE id=?").run(take, loan.id);
- }
- const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
- if (period) {
- const jobs = await db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as {
- id: number;
- qty_total_estimated: number;
- }[];
- for (const job of jobs) {
- const paid = await db.prepare(
- `SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
- FROM destajo_cut_lines c
- JOIN destajo_periods p ON p.id=c.period_id
- JOIN payroll_weeks w ON w.id=p.week_id
- WHERE c.job_id=? AND (w.status='paid' OR w.id=?)`,
- ).get(job.id, weekId) as { n: number };
- if (Number(paid.n) >= job.qty_total_estimated) {
- await db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id);
- }
- }
- }
- await db.prepare(
- "UPDATE payroll_weeks SET status='paid', paid_at=now() WHERE id=?",
- ).run(weekId);
+ const env = await callCoreFn(db, "core.fn_payroll_week_pay", { week_id: weekId, today: nowIso });
+ if (!env.ok) throwFromRpc(env);
}
export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise {
- const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
- if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
- const sheet = await db.prepare(
- "SELECT id FROM payroll_sheets WHERE week_id=? AND kind='obra' AND project_id=?",
- ).get(weekId, projectId) as { id: number } | undefined;
- if (!sheet) throw Object.assign(new Error("Hoja de obra no encontrada"), { status: 404 });
- await sheetLine(db, sheet.id, workerId, null);
- await recalcWeek(db, weekId);
+ const env = await callCoreFn(db, "core.fn_payroll_jornal_worker_add", { week_id: weekId, project_id: projectId, worker_id: workerId });
+ if (!env.ok) throwFromRpc(env);
}
-export async function addAdminLine(
- db: Db,
- weekId: number,
- body: { worker_id: number; amount: number; project_id?: number | null },
-): Promise {
- const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
- if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
- const sheet = await db.prepare(
- "SELECT id FROM payroll_sheets WHERE week_id=? AND kind='admin' AND project_id IS NULL",
- ).get(weekId) as { id: number } | undefined;
- if (!sheet) throw Object.assign(new Error("Hoja administrativa no encontrada"), { status: 404 });
- const lineId = await sheetLine(db, sheet.id, body.worker_id, null);
- await db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run(
- roundMoney(body.amount),
- body.project_id ?? null,
- roundMoney(body.amount),
- roundMoney(body.amount),
- lineId,
- );
- await recalcWeek(db, weekId);
+export async function addAdminLine(db: Db, weekId: number, body: { worker_id: number; amount: number; project_id?: number | null }): Promise {
+ const env = await callCoreFn(db, "core.fn_payroll_admin_line_add", { week_id: weekId, ...body });
+ if (!env.ok) throwFromRpc(env);
}
export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise {
- const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
- if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
- await db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId);
- await recalcWeek(db, weekId);
+ const env = await callCoreFn(db, "core.fn_payroll_admin_line_update", { week_id: weekId, line_id: lineId, amount });
+ if (!env.ok) throwFromRpc(env);
}
export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise {
- const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
- if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
- await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId);
- await recalcWeek(db, weekId);
+ const env = await callCoreFn(db, "core.fn_payroll_admin_line_remove", { week_id: weekId, line_id: lineId });
+ if (!env.ok) throwFromRpc(env);
}
-export async function jobRemainder(db: Db, jobId: number): Promise {
- const job = await db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as
- | { qty_total_estimated: number }
- | undefined;
- if (!job) return 0;
- const paid = await db.prepare(
- `SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
- FROM destajo_cut_lines c
- JOIN destajo_periods p ON p.id=c.period_id
- JOIN payroll_weeks w ON w.id=p.week_id
- WHERE c.job_id=? AND w.status='paid'`,
- ).get(jobId) as { n: number };
- return roundMoney(Math.max(0, job.qty_total_estimated - Number(paid.n)));
+export async function createDestajoJob(db: Db, tenantId: number, weekId: number, body: Record): Promise<{ id: number }> {
+ const env = await callCoreFn<{ id: number }>(db, "core.fn_destajo_job_create", { tenant_id: tenantId, week_id: weekId, ...body });
+ if (!env.ok) throwFromRpc(env);
+ return { id: Number(env.data?.id) };
}
-export async function createDestajoJob(
- db: Db,
- tenantId: number,
- weekId: number,
- body: {
- project_id: number;
- worker_id: number;
- concepto: string;
- unit_code: string;
- qty_total_estimated: number;
- unit_price: number;
- qty_planned?: number;
- qty_extra?: number;
- },
-): Promise<{ id: number }> {
- const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
- if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
- const concepto = body.concepto.trim();
- if (!concepto) throw Object.assign(new Error("Concepto requerido"), { status: 400 });
- await db.prepare(
- `INSERT INTO destajo_jobs
- (project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
- ).run(
- body.project_id,
- body.worker_id,
- concepto,
- body.unit_code,
- body.qty_total_estimated,
- body.unit_price,
- tenantId,
- );
- const jobId = await db.lastInsertId();
- const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number };
- await db.prepare(
- `INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
- VALUES (?, ?, ?, 0, ?)`,
- ).run(period.id, jobId, body.qty_planned ?? 0, body.qty_extra ?? 0);
- await recalcWeek(db, weekId);
- return { id: jobId };
+export async function patchDestajoCut(db: Db, weekId: number, cutId: number, patch: Record): Promise {
+ const env = await callCoreFn(db, "core.fn_destajo_cut_patch", { week_id: weekId, cut_id: cutId, ...patch });
+ if (!env.ok) throwFromRpc(env);
}
-export async function patchDestajoCut(
- db: Db,
- weekId: number,
- cutId: number,
- patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number },
-): Promise {
- const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
- if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
- const cut = await db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as {
- qty_planned: number;
- qty_actual: number;
- qty_extra: number;
- } | undefined;
- if (!cut) throw Object.assign(new Error("Partida no encontrada"), { status: 404 });
- const planned = patch.qty_planned ?? cut.qty_planned;
- const actual = patch.qty_actual ?? cut.qty_actual;
- const extra = patch.qty_extra ?? cut.qty_extra;
- await db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run(
- planned,
- actual,
- extra,
- cutId,
- );
- await recalcWeek(db, weekId);
-}
-
-export async function ensureCutForOpenJobs(db: Db, weekId: number): Promise {
- const period = await db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as
- | { id: number; tenant_id: number }
- | undefined;
- if (!period) return;
- const jobs = await db.prepare(
- "SELECT id FROM destajo_jobs WHERE tenant_id=? AND status='open'",
- ).all(period.tenant_id) as { id: number }[];
- const ins = db.prepare(
- `INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
- VALUES (?, ?, 0, 0, 0)
- ON CONFLICT(period_id, job_id) DO NOTHING`,
- );
- for (const job of jobs) await ins.run(period.id, job.id);
-}
-
-type AttCell = {
- date: string;
- present: boolean;
- other_project_id: number | null;
- other_project_name: string | null;
-};
-
export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) {
- const weekId = await ensureWeek(db, tenantId, weekStart);
- await ensureCutForOpenJobs(db, weekId);
- await recalcWeek(db, weekId);
- const week = await db.prepare(
- `SELECT * FROM payroll_weeks WHERE id=?`,
- ).get(weekId) as Record;
- const period = await db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId);
- const start = String(week.week_start);
- const end = String(week.week_end);
- const days = weekDays(start);
- const sheetsRaw = await db.prepare(
- `SELECT s.*, p.name AS project_name, p.code AS project_code
- FROM payroll_sheets s
- LEFT JOIN projects p ON p.id=s.project_id
- WHERE s.week_id=?
- ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name`,
- ).all(weekId) as Record[];
-
- const attRows = await db.prepare(
- `SELECT a.worker_id, a.project_id, a.work_date, a.present, p.name AS project_name
- FROM attendance a JOIN projects p ON p.id=a.project_id
- WHERE a.work_date BETWEEN ? AND ?`,
- ).all(start, end) as {
- worker_id: number;
- project_id: number;
- work_date: string;
- present: boolean;
- project_name: string;
- }[];
-
- const sheets = [];
- for (const sheet of sheetsRaw) {
- const lines = await db.prepare(
- `SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.position, w.work_type
- FROM payroll_week_lines l
- JOIN workers w ON w.id=l.worker_id
- WHERE l.sheet_id=?
- ORDER BY w.last_name_p, w.first_name, l.id`,
- ).all(Number(sheet.id)) as Record[];
- const withCells = lines.map((line) => {
- const cells: AttCell[] = days.map((date) => {
- const mine = attRows.find((a) =>
- a.worker_id === line.worker_id && a.project_id === sheet.project_id && a.work_date === date
- );
- const other = attRows.find((a) =>
- a.worker_id === line.worker_id && a.present === true && a.project_id !== sheet.project_id && a.work_date === date
- );
- return {
- date,
- present: Boolean(mine?.present),
- other_project_id: other && !mine?.present ? other.project_id : null,
- other_project_name: other && !mine?.present ? other.project_name : null,
- };
- });
- return { ...line, cells };
- });
- sheets.push({ ...sheet, lines: withCells });
- }
-
- const jobs = await db.prepare(
- `SELECT j.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name
- FROM destajo_jobs j
- JOIN workers w ON w.id=j.worker_id
- JOIN projects p ON p.id=j.project_id
- WHERE j.tenant_id=?
- ORDER BY j.status, j.id DESC`,
- ).all(tenantId) as Record[];
- const jobsOut = [];
- for (const job of jobs) jobsOut.push({ ...job, remainder: await jobRemainder(db, Number(job.id)) });
- const cuts = period
- ? await db.prepare(
- `SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id,
- j.status AS job_status, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name
- FROM destajo_cut_lines c
- JOIN destajo_jobs j ON j.id=c.job_id
- JOIN workers w ON w.id=j.worker_id
- JOIN projects p ON p.id=j.project_id
- WHERE c.period_id=?
- ORDER BY p.name, w.last_name_p`,
- ).all((period as { id: number }).id)
- : [];
- const settings = await getSettings(db, tenantId);
- const units = await listUnits(db, tenantId);
- const required = Number(week.required_net) || 0;
- const payable = Number(week.payable_net) || 0;
- const recoveryRow = await db.prepare(
- `SELECT COALESCE(SUM(l.loan_discount), 0) AS n
- FROM payroll_week_lines l
- JOIN payroll_sheets s ON s.id=l.sheet_id
- WHERE s.week_id=?`,
- ).get(weekId) as { n: number };
- const loanRecovery = roundMoney(Number(recoveryRow.n));
- const faltante = roundMoney(Math.max(0, payable - required));
+ await ensureWeek(db, tenantId, weekStart);
+ const env = await callCoreFn>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart });
+ if (!env.ok) throwFromRpc(env);
+ const data = env.data ?? {};
const today = await resolveToday(db, tenantId);
- return {
- week,
- destajo_period: period,
- sheets,
- destajo_jobs: jobsOut,
- destajo_cuts: cuts,
- settings,
- units,
- days,
- board: {
- required_net: required,
- payable_net: payable,
- loan_recovery: loanRecovery,
- faltante,
- delta: roundMoney(required - payable),
- },
- can_assemble: canAssembleWeek(start, today),
- assemble_from: weekAssembleDate(start),
- };
+ const start = String((data.week as Record)?.week_start ?? weekStart);
+ return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) };
}
export async function weekCsv(db: Db, weekId: number): Promise {
- const lines = await db.prepare(
- `SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name,
- l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code,
- l.gross, l.discounts, l.loan_discount, l.loan_label, l.payable_net, l.required_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=?
- ORDER BY s.kind, p.name, w.last_name_p`,
- ).all(weekId) as Record[];
- const header =
- "Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar";
- const rows = lines.map((l) =>
- [
- l.first_name,
- l.last_name_p,
- l.kind,
- l.project_name ?? "",
- l.days,
- l.daily_wage,
- l.concepto ?? "",
- Number(l.qty_actual || 0) + Number(l.qty_extra || 0),
- l.unit_code ?? "",
- l.gross,
- l.discounts,
- l.loan_discount,
- String(l.loan_label ?? "").replaceAll(",", " "),
- l.required_net,
- l.payable_net,
- ].join(",")
- );
- return [header, ...rows].join("\n");
+ const env = await callCoreFn<{ csv: string }>(db, "core.fn_payroll_week_csv", { week_id: weekId });
+ if (!env.ok) throwFromRpc(env);
+ return String(env.data?.csv ?? "");
}
export async function listOpenWeeks(db: Db, tenantId: number) {
- return await db.prepare(
- `SELECT w.*,
- (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=w.id) AS loan_recovery
- FROM payroll_weeks w
- WHERE w.tenant_id=? AND w.status != 'paid'
- ORDER BY w.week_start DESC`,
- ).all(tenantId);
+ const env = await callCoreFn<{ weeks: unknown[] }>(db, "core.fn_payroll_week_list_open", { tenant_id: tenantId });
+ if (!env.ok) throwFromRpc(env);
+ return env.data?.weeks ?? [];
}
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..e2e284d
--- /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(
+ 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/README.md b/db/README.md
index c8cb95f..560852d 100644
--- a/db/README.md
+++ b/db/README.md
@@ -79,3 +79,56 @@ El `password_hash` (PBKDF2) no lo puede generar un changeset SQL. El primer
`scripts/bootstrap-admin.ts` (ver Fase 4 del plan de migración), no con
lógica de seed en el arranque de la API ni con datos hardcodeados en
Liquibase.
+
+## Funciones RPC (`core.fn_*`)
+
+A partir del changeset `006-rpc-infra.sql`, el esquema `core` expone la
+lógica de negocio como funciones PostgreSQL invocadas desde la API con
+`SELECT core.fn_nombre($1::jsonb)`. La capa TypeScript **no** debe usar
+`db.prepare()` contra tablas de `core` en rutas de negocio; solo
+[`api/rpc.ts`](../api/rpc.ts) ejecuta el `SELECT` del RPC.
+
+### Envelope de respuesta (BD)
+
+Toda función devuelve un `jsonb` con esta forma:
+
+```json
+{
+ "ok": true,
+ "code": "OK",
+ "layer": "db",
+ "message": "Mensaje detallado en español",
+ "context": { "fn": "fn_empresa_get", "id": 5 },
+ "data": { },
+ "errors": null
+}
+```
+
+- `code`: código de negocio (`OK`, `CREATED`, `VALIDATION`, `NOT_FOUND`,
+ `CONFLICT`, `INTERNAL`, …).
+- `layer`: siempre `"db"` desde Postgres.
+- `message`: texto legible y específico (nunca genérico).
+- `context`: metadatos seguros para depuración (nombre de función, ids).
+- `data`: payload de éxito; `errors`: mapa de campos en validación.
+
+Helpers en `006-rpc-infra.sql`: `rpc_ok`, `rpc_err`, `rpc_created`,
+`rpc_from_exception`.
+
+### Contrato API
+
+[`api/http_errors.ts`](../api/http_errors.ts) mapea `code` → HTTP y añade
+`status` al body. `respondRpc()` preserva `layer` y `message` de la BD;
+`respondApiError()` construye envelopes de capa `"api"`.
+
+### Convenciones al añadir funciones
+
+1. Un solo parámetro `payload jsonb`.
+2. Prefijo `fn_` para funciones que devuelven envelope.
+3. `SECURITY INVOKER` y `SET search_path = core`.
+4. Errores de negocio con `RETURN core.rpc_err(...)`; constraints de
+ Postgres capturados en `EXCEPTION` y traducidos a mensajes claros.
+5. `GRANT EXECUTE ... TO panels_core_app` en el mismo changeset.
+6. Comentario de ejemplo `SELECT core.fn_*(...)` en el SQL.
+
+Changesets RPC: `006` (infra) … `015` (documentos). Ver
+[`changelog-master.xml`](core/changelog-master.xml).
diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml
index 0a661ea..f1eee80 100644
--- a/db/core/changelog-master.xml
+++ b/db/core/changelog-master.xml
@@ -12,5 +12,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/db/core/changesets/006-rpc-infra.sql b/db/core/changesets/006-rpc-infra.sql
new file mode 100644
index 0000000..11164f2
--- /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 splitStatements:false
+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 splitStatements:false
+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 splitStatements:false
+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 splitStatements:false
+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 splitStatements:false
+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 splitStatements:false
+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..c68f79d
--- /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 splitStatements:false
+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 splitStatements:false
+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 splitStatements:false
+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/008-rpc-companies.sql b/db/core/changesets/008-rpc-companies.sql
new file mode 100644
index 0000000..c6f5015
--- /dev/null
+++ b/db/core/changesets/008-rpc-companies.sql
@@ -0,0 +1,639 @@
+--liquibase formatted sql
+-- PANELS · core · RPC empresas (CRUD)
+
+--changeset panel:core-008a-company-helpers splitStatements:false
+CREATE OR REPLACE FUNCTION core._company_trim_text(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT btrim(COALESCE(p_value, ''));
+$$;
+
+CREATE OR REPLACE FUNCTION core._company_norm_code(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g'));
+$$;
+
+CREATE OR REPLACE FUNCTION core._company_norm_rfc(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g'));
+$$;
+
+CREATE OR REPLACE FUNCTION core._company_code_from_name(p_name text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT COALESCE(
+ NULLIF(
+ left(
+ upper(
+ regexp_replace(
+ translate(
+ COALESCE(p_name, ''),
+ 'áàäâãåéèëêíìïîóòöôõúùüûñÁÀÄÂÃÅÉÈËÊÍÌÏÎÓÒÖÔÕÚÙÜÛÑ',
+ 'aaaaaaeeeeiiiiooooouuuunAAAAAAEEEEIIIIOOOOOUUUUN'
+ ),
+ '[^A-Z0-9]+',
+ '',
+ 'g'
+ )
+ ),
+ 12
+ ),
+ ''
+ ),
+ 'EMP'
+ );
+$$;
+
+CREATE OR REPLACE FUNCTION core._company_validate_code(p_code text)
+RETURNS text
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+BEGIN
+ IF p_code IS NULL OR p_code = '' THEN
+ RETURN 'Código de 2 a 16 caracteres (letras, números, _ o -)';
+ END IF;
+ IF p_code !~ '^[A-Z0-9][A-Z0-9_-]{1,15}$' THEN
+ RETURN 'Código de 2 a 16 caracteres (letras, números, _ o -)';
+ END IF;
+ RETURN NULL;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._company_validate_profile(
+ p_payload jsonb,
+ p_require_legal boolean DEFAULT false
+)
+RETURNS text
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_rfc text := core._company_norm_rfc(p_payload->>'rfc');
+ v_clase text := upper(core._company_trim_text(p_payload->>'clase_riesgo'));
+BEGIN
+ IF v_rfc <> '' AND v_rfc !~ '^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$' THEN
+ RETURN 'RFC inválido (formato mexicano de 12 o 13 caracteres)';
+ END IF;
+ IF p_require_legal THEN
+ IF core._company_trim_text(p_payload->>'razon_social') = ''
+ AND core._company_trim_text(p_payload->>'name') = ''
+ AND core._company_trim_text(p_payload->>'nombre_comercial') = '' THEN
+ RETURN 'Indique razón social o nombre comercial';
+ END IF;
+ END IF;
+ IF v_clase <> '' AND v_clase NOT IN ('I', 'II', 'III', 'IV', 'V') THEN
+ RETURN 'Clase de riesgo IMSS debe ser I, II, III, IV o V';
+ END IF;
+ RETURN NULL;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._company_normalize_profile(
+ p_payload jsonb,
+ p_fallback_name text DEFAULT ''
+)
+RETURNS jsonb
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_nombre_comercial text;
+ v_razon_social text;
+ v_name text;
+BEGIN
+ v_nombre_comercial := COALESCE(
+ NULLIF(core._company_trim_text(p_payload->>'nombre_comercial'), ''),
+ NULLIF(core._company_trim_text(p_payload->>'name'), ''),
+ p_fallback_name
+ );
+ v_razon_social := COALESCE(
+ NULLIF(core._company_trim_text(p_payload->>'razon_social'), ''),
+ NULLIF(v_nombre_comercial, ''),
+ p_fallback_name
+ );
+ v_name := COALESCE(
+ NULLIF(core._company_trim_text(p_payload->>'name'), ''),
+ NULLIF(v_nombre_comercial, ''),
+ NULLIF(v_razon_social, ''),
+ p_fallback_name
+ );
+ RETURN jsonb_build_object(
+ 'name', v_name,
+ 'nombre_comercial', v_nombre_comercial,
+ 'razon_social', v_razon_social,
+ 'rfc', core._company_norm_rfc(p_payload->>'rfc'),
+ 'regimen_fiscal', core._company_trim_text(p_payload->>'regimen_fiscal'),
+ 'registro_patronal', upper(core._company_trim_text(p_payload->>'registro_patronal')),
+ 'clase_riesgo', upper(core._company_trim_text(p_payload->>'clase_riesgo')),
+ 'domicilio_fiscal', core._company_trim_text(p_payload->>'domicilio_fiscal'),
+ 'codigo_postal', core._company_trim_text(p_payload->>'codigo_postal'),
+ 'ciudad', core._company_trim_text(p_payload->>'ciudad'),
+ 'estado', core._company_trim_text(p_payload->>'estado'),
+ 'telefono', core._company_trim_text(p_payload->>'telefono'),
+ 'email', lower(core._company_trim_text(p_payload->>'email')),
+ 'representante_legal', core._company_trim_text(p_payload->>'representante_legal'),
+ 'giro', core._company_trim_text(p_payload->>'giro')
+ );
+END;
+$$;
+
+--changeset panel:core-008b-fn-company-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_company_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := NULLIF(payload->>'tenant_id', '')::integer;
+ v_companies jsonb;
+ v_count integer;
+BEGIN
+ SELECT COALESCE(
+ jsonb_agg(row_to_json(x)::jsonb ORDER BY
+ CASE x.kind WHEN 'principal' THEN 0 ELSE 1 END,
+ lower(x.name)),
+ '[]'::jsonb
+ ),
+ COUNT(*)::integer
+ INTO v_companies, v_count
+ 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
+ WHERE v_tid IS NULL OR c.tenant_id = v_tid
+ ) x;
+ RETURN core.rpc_ok(
+ jsonb_build_object('companies', v_companies),
+ format('Listado de empresas: %s registro(s)%s',
+ v_count,
+ CASE WHEN v_tid IS NOT NULL THEN format(' para tenant_id=%s', v_tid) ELSE '' END),
+ jsonb_build_object('fn', 'fn_company_list', 'tenant_id', v_tid, 'count', v_count)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-008c-fn-company-get splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_company_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_tid integer := NULLIF(payload->>'tenant_id', '')::integer;
+ v_company jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_company_get: id es obligatorio para consultar una empresa',
+ jsonb_build_object('fn', 'fn_company_get', 'field', 'id')
+ );
+ END IF;
+ SELECT to_jsonb(c) INTO v_company
+ FROM companies c
+ WHERE c.id = v_id
+ AND (v_tid IS NULL OR c.tenant_id IS NULL OR c.tenant_id = v_tid);
+ IF v_company IS NULL THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_company_get: empresa id=%s no encontrada%s', v_id,
+ CASE WHEN v_tid IS NOT NULL THEN format(' en tenant_id=%s', v_tid) ELSE '' END),
+ jsonb_build_object('fn', 'fn_company_get', 'id', v_id, 'tenant_id', v_tid)
+ );
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('company', v_company),
+ format('Empresa id=%s cargada (%s)', v_id, v_company->>'code'),
+ jsonb_build_object('fn', 'fn_company_get', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-008d-fn-company-get-by-code splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_company_get_by_code(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_code text := core._company_norm_code(payload->>'code');
+ v_company jsonb;
+BEGIN
+ IF v_code = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_company_get_by_code: code es obligatorio para consultar una empresa',
+ jsonb_build_object('fn', 'fn_company_get_by_code', 'field', 'code')
+ );
+ END IF;
+ SELECT to_jsonb(c) INTO v_company
+ FROM companies c
+ WHERE c.code = v_code;
+ IF v_company IS NULL THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_company_get_by_code: empresa con código %s no encontrada', v_code),
+ jsonb_build_object('fn', 'fn_company_get_by_code', 'code', v_code)
+ );
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('company', v_company),
+ format('Empresa con código %s cargada (id=%s)', v_code, v_company->>'id'),
+ jsonb_build_object('fn', 'fn_company_get_by_code', 'code', v_code)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_get_by_code', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-008e-fn-next-company-code splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_next_company_code(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_name text := core._company_trim_text(payload->>'name');
+ v_base text;
+ v_n integer;
+ v_code text;
+BEGIN
+ IF v_name = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_next_company_code: name es obligatorio para generar un código de empresa',
+ jsonb_build_object('fn', 'fn_next_company_code', 'field', 'name')
+ );
+ END IF;
+ v_base := core._company_code_from_name(v_name);
+ IF NOT EXISTS (SELECT 1 FROM companies WHERE code = v_base) THEN
+ v_code := v_base;
+ ELSE
+ SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1
+ INTO v_n
+ FROM companies
+ WHERE code ~ '^EMP-[0-9]{4}';
+ v_code := 'EMP-' || lpad(v_n::text, 4, '0');
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('code', v_code),
+ format('Código sugerido para "%s": %s', v_name, v_code),
+ jsonb_build_object('fn', 'fn_next_company_code', 'name', v_name, 'code', v_code)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_next_company_code', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-008f-fn-company-create splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_company_create(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := NULLIF(payload->>'tenant_id', '')::integer;
+ v_profile_err text;
+ v_profile jsonb;
+ v_principal companies%ROWTYPE;
+ v_parent_id bigint;
+ v_parent companies%ROWTYPE;
+ v_code text;
+ v_code_err text;
+ v_new_id bigint;
+ v_n integer;
+ v_company jsonb;
+BEGIN
+ v_profile_err := core._company_validate_profile(payload, true);
+ IF v_profile_err IS NOT NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_create: %s', v_profile_err),
+ jsonb_build_object('fn', 'fn_company_create', 'field', 'profile')
+ );
+ END IF;
+
+ v_profile := core._company_normalize_profile(payload, '');
+ IF core._company_trim_text(v_profile->>'name') = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_company_create: nombre de empresa obligatorio',
+ jsonb_build_object('fn', 'fn_company_create', 'field', 'name')
+ );
+ END IF;
+
+ IF v_tid IS NOT NULL THEN
+ SELECT * INTO v_principal
+ FROM companies
+ WHERE kind = 'principal' AND tenant_id = v_tid
+ ORDER BY id
+ LIMIT 1;
+ ELSE
+ SELECT * INTO v_principal
+ FROM companies
+ WHERE kind = 'principal'
+ ORDER BY id
+ LIMIT 1;
+ END IF;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_company_create: no hay empresa principal configurada en el tenant',
+ jsonb_build_object('fn', 'fn_company_create', 'tenant_id', v_tid)
+ );
+ END IF;
+
+ v_parent_id := COALESCE(NULLIF(payload->>'parent_id', '')::bigint, v_principal.id);
+ SELECT * INTO v_parent FROM companies WHERE id = v_parent_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_create: empresa padre id=%s no encontrada', v_parent_id),
+ jsonb_build_object('fn', 'fn_company_create', 'parent_id', v_parent_id)
+ );
+ END IF;
+ IF v_tid IS NOT NULL AND v_parent.tenant_id IS NOT NULL AND v_parent.tenant_id <> v_tid THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_create: empresa padre id=%s pertenece a tenant_id=%s, distinto del solicitado (%s)',
+ v_parent_id, v_parent.tenant_id, v_tid),
+ jsonb_build_object('fn', 'fn_company_create', 'parent_id', v_parent_id, 'tenant_id', v_tid)
+ );
+ END IF;
+
+ v_code := core._company_norm_code(payload->>'code');
+ IF v_code = '' THEN
+ v_code := core._company_code_from_name(v_profile->>'name');
+ IF EXISTS (SELECT 1 FROM companies WHERE code = v_code) THEN
+ SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1
+ INTO v_n
+ FROM companies
+ WHERE code ~ '^EMP-[0-9]{4}';
+ v_code := 'EMP-' || lpad(v_n::text, 4, '0');
+ END IF;
+ END IF;
+ v_code_err := core._company_validate_code(v_code);
+ IF v_code_err IS NOT NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_create: %s (código=%s)', v_code_err, v_code),
+ jsonb_build_object('fn', 'fn_company_create', 'field', 'code', 'code', v_code)
+ );
+ END IF;
+ IF EXISTS (SELECT 1 FROM companies WHERE code = v_code) THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ format('fn_company_create: ya existe una empresa con el código %s', v_code),
+ jsonb_build_object('fn', 'fn_company_create', 'code', v_code)
+ );
+ END IF;
+
+ INSERT INTO companies (
+ code, name, parent_id, kind, status, tenant_id,
+ registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
+ domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
+ ) VALUES (
+ v_code,
+ v_profile->>'name',
+ v_parent.id,
+ 'sub',
+ 'activo',
+ COALESCE(v_tid, v_principal.tenant_id),
+ v_profile->>'registro_patronal',
+ v_profile->>'razon_social',
+ v_profile->>'nombre_comercial',
+ v_profile->>'rfc',
+ v_profile->>'regimen_fiscal',
+ v_profile->>'clase_riesgo',
+ v_profile->>'domicilio_fiscal',
+ v_profile->>'codigo_postal',
+ v_profile->>'ciudad',
+ v_profile->>'estado',
+ v_profile->>'telefono',
+ v_profile->>'email',
+ v_profile->>'representante_legal',
+ v_profile->>'giro'
+ )
+ RETURNING id INTO v_new_id;
+
+ SELECT to_jsonb(c) INTO v_company FROM companies c WHERE c.id = v_new_id;
+ RETURN core.rpc_created(
+ jsonb_build_object('company', v_company),
+ format('Subempresa creada: %s (%s), id=%s', v_company->>'name', v_code, v_new_id),
+ jsonb_build_object('fn', 'fn_company_create', 'id', v_new_id, 'code', v_code, 'parent_id', v_parent.id)
+ );
+EXCEPTION
+ WHEN unique_violation THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ format('fn_company_create: ya existe una empresa con el código %s', v_code),
+ jsonb_build_object('fn', 'fn_company_create', 'code', v_code)
+ );
+ WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-008g-fn-company-update splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_company_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_tid integer := NULLIF(payload->>'tenant_id', '')::integer;
+ v_current companies%ROWTYPE;
+ v_merged jsonb;
+ v_profile_err text;
+ v_profile jsonb;
+ v_code text;
+ v_code_err text;
+ v_status text;
+ v_clash_id bigint;
+ v_company jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_company_update: id es obligatorio para actualizar una empresa',
+ jsonb_build_object('fn', 'fn_company_update', 'field', 'id')
+ );
+ END IF;
+
+ SELECT * INTO v_current FROM companies WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_company_update: empresa id=%s no encontrada', v_id),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'tenant_id', v_tid)
+ );
+ END IF;
+ IF v_tid IS NOT NULL AND v_current.tenant_id IS NOT NULL AND v_current.tenant_id <> v_tid THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_company_update: empresa id=%s no encontrada en tenant_id=%s', v_id, v_tid),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'tenant_id', v_tid)
+ );
+ END IF;
+
+ v_merged := jsonb_build_object(
+ 'name', CASE WHEN payload ? 'name' THEN payload->>'name' ELSE v_current.name END,
+ 'code', CASE WHEN payload ? 'code' THEN payload->>'code' ELSE v_current.code END,
+ 'status', CASE WHEN payload ? 'status' THEN payload->>'status' ELSE v_current.status END,
+ 'registro_patronal', CASE WHEN payload ? 'registro_patronal' THEN payload->>'registro_patronal' ELSE v_current.registro_patronal END,
+ 'razon_social', CASE WHEN payload ? 'razon_social' THEN payload->>'razon_social' ELSE v_current.razon_social END,
+ 'nombre_comercial', CASE WHEN payload ? 'nombre_comercial' THEN payload->>'nombre_comercial' ELSE v_current.nombre_comercial END,
+ 'rfc', CASE WHEN payload ? 'rfc' THEN payload->>'rfc' ELSE v_current.rfc END,
+ 'regimen_fiscal', CASE WHEN payload ? 'regimen_fiscal' THEN payload->>'regimen_fiscal' ELSE v_current.regimen_fiscal END,
+ 'clase_riesgo', CASE WHEN payload ? 'clase_riesgo' THEN payload->>'clase_riesgo' ELSE v_current.clase_riesgo END,
+ 'domicilio_fiscal', CASE WHEN payload ? 'domicilio_fiscal' THEN payload->>'domicilio_fiscal' ELSE v_current.domicilio_fiscal END,
+ 'codigo_postal', CASE WHEN payload ? 'codigo_postal' THEN payload->>'codigo_postal' ELSE v_current.codigo_postal END,
+ 'ciudad', CASE WHEN payload ? 'ciudad' THEN payload->>'ciudad' ELSE v_current.ciudad END,
+ 'estado', CASE WHEN payload ? 'estado' THEN payload->>'estado' ELSE v_current.estado END,
+ 'telefono', CASE WHEN payload ? 'telefono' THEN payload->>'telefono' ELSE v_current.telefono END,
+ 'email', CASE WHEN payload ? 'email' THEN payload->>'email' ELSE v_current.email END,
+ 'representante_legal', CASE WHEN payload ? 'representante_legal' THEN payload->>'representante_legal' ELSE v_current.representante_legal END,
+ 'giro', CASE WHEN payload ? 'giro' THEN payload->>'giro' ELSE v_current.giro END
+ );
+
+ v_profile_err := core._company_validate_profile(v_merged, false);
+ IF v_profile_err IS NOT NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_update: %s (id=%s)', v_profile_err, v_id),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'profile')
+ );
+ END IF;
+
+ v_profile := core._company_normalize_profile(v_merged, v_current.name);
+ IF core._company_trim_text(v_profile->>'name') = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_update: nombre de empresa obligatorio (id=%s)', v_id),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'name')
+ );
+ END IF;
+
+ v_code := v_current.code;
+ IF payload ? 'code' THEN
+ v_code := core._company_norm_code(payload->>'code');
+ v_code_err := core._company_validate_code(v_code);
+ IF v_code_err IS NOT NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_update: %s (id=%s, código=%s)', v_code_err, v_id, v_code),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'code', 'code', v_code)
+ );
+ END IF;
+ SELECT id INTO v_clash_id FROM companies WHERE code = v_code AND id <> v_id;
+ IF FOUND THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ format('fn_company_update: ya existe otra empresa con el código %s (id=%s)', v_code, v_clash_id),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code, 'conflict_id', v_clash_id)
+ );
+ END IF;
+ END IF;
+
+ v_status := v_current.status;
+ IF payload ? 'status' THEN
+ IF (payload->>'status') NOT IN ('activo', 'inactivo') THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_update: estatus debe ser activo o inactivo (recibido=%s, id=%s)',
+ payload->>'status', v_id),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'status', 'status', payload->>'status')
+ );
+ END IF;
+ IF v_current.kind = 'principal' AND payload->>'status' = 'inactivo' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_company_update: la empresa principal id=%s no se puede inactivar', v_id),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'kind', v_current.kind)
+ );
+ END IF;
+ v_status := payload->>'status';
+ END IF;
+
+ UPDATE companies SET
+ name = v_profile->>'name',
+ code = v_code,
+ status = v_status,
+ registro_patronal = v_profile->>'registro_patronal',
+ razon_social = v_profile->>'razon_social',
+ nombre_comercial = v_profile->>'nombre_comercial',
+ rfc = v_profile->>'rfc',
+ regimen_fiscal = v_profile->>'regimen_fiscal',
+ clase_riesgo = v_profile->>'clase_riesgo',
+ domicilio_fiscal = v_profile->>'domicilio_fiscal',
+ codigo_postal = v_profile->>'codigo_postal',
+ ciudad = v_profile->>'ciudad',
+ estado = v_profile->>'estado',
+ telefono = v_profile->>'telefono',
+ email = v_profile->>'email',
+ representante_legal = v_profile->>'representante_legal',
+ giro = v_profile->>'giro'
+ WHERE id = v_id;
+
+ IF v_code <> v_current.code THEN
+ UPDATE workers SET hire_type = v_code WHERE company_id = v_id;
+ END IF;
+
+ SELECT to_jsonb(c) INTO v_company FROM companies c WHERE c.id = v_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('company', v_company),
+ format('Empresa id=%s actualizada (%s)', v_id, v_code),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code)
+ );
+EXCEPTION
+ WHEN unique_violation THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ format('fn_company_update: ya existe una empresa con el código %s (id=%s)', v_code, v_id),
+ jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code)
+ );
+ WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_update', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-008h-fn-companies-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core._company_trim_text(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._company_norm_code(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._company_norm_rfc(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._company_code_from_name(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._company_validate_code(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._company_validate_profile(jsonb, boolean) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._company_normalize_profile(jsonb, text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_get_by_code(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_next_company_code(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_create(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_update(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/009-rpc-projects.sql b/db/core/changesets/009-rpc-projects.sql
new file mode 100644
index 0000000..ad123d3
--- /dev/null
+++ b/db/core/changesets/009-rpc-projects.sql
@@ -0,0 +1,604 @@
+--liquibase formatted sql
+-- PANELS · core · RPC proyectos (obras)
+
+--changeset panel:core-009a-fn-next-project-code splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_next_project_code(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_n integer;
+ v_code text;
+BEGIN
+ SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1
+ INTO v_n
+ FROM projects
+ WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}';
+ v_code := 'PRY-' || lpad(v_n::text, 4, '0');
+ RETURN core.rpc_ok(
+ jsonb_build_object('code', v_code),
+ format('Siguiente código de proyecto asignado: %s', v_code),
+ jsonb_build_object('fn', 'fn_next_project_code', 'sequence', v_n)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_next_project_code', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009b-fn-project-assert-status splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_assert_status(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := (payload->>'project_id')::bigint;
+ v_allowed text[];
+ v_proj record;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_assert_status: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'project_id')
+ );
+ END IF;
+ IF payload->'allowed_statuses' IS NULL OR jsonb_typeof(payload->'allowed_statuses') <> 'array' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_assert_status: allowed_statuses debe ser un arreglo JSON',
+ jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'allowed_statuses')
+ );
+ END IF;
+ SELECT array_agg(btrim(elem))
+ INTO v_allowed
+ FROM jsonb_array_elements_text(payload->'allowed_statuses') AS elem
+ WHERE btrim(elem) IN ('activo', 'pausado', 'concluido', 'cancelado');
+ IF v_allowed IS NULL OR cardinality(v_allowed) = 0 THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_assert_status: allowed_statuses no contiene estados válidos',
+ jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'allowed_statuses')
+ );
+ END IF;
+ SELECT p.id, p.code, p.name, p.status
+ INTO v_proj
+ FROM projects p
+ WHERE p.id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_project_assert_status: proyecto id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_project_assert_status', 'project_id', v_id)
+ );
+ END IF;
+ IF NOT (v_proj.status = ANY (v_allowed)) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_project_assert_status: proyecto %s (%s) está en estado %s, no permitido para esta operación',
+ v_proj.code, v_id, v_proj.status),
+ jsonb_build_object(
+ 'fn', 'fn_project_assert_status',
+ 'project_id', v_id,
+ 'status', v_proj.status,
+ 'allowed_statuses', to_jsonb(v_allowed)
+ )
+ );
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'project_id', v_proj.id,
+ 'code', v_proj.code,
+ 'name', v_proj.name,
+ 'status', v_proj.status
+ ),
+ format('Proyecto %s en estado %s válido para la operación', v_proj.code, v_proj.status),
+ jsonb_build_object('fn', 'fn_project_assert_status', 'project_id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_assert_status', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009c-fn-project-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_status_raw text := nullif(btrim(payload->>'status'), '');
+ v_statuses text[];
+ v_projects jsonb;
+BEGIN
+ IF v_status_raw IS NOT NULL THEN
+ SELECT array_agg(DISTINCT btrim(s))
+ INTO v_statuses
+ FROM unnest(string_to_array(v_status_raw, ',')) AS s
+ WHERE btrim(s) IN ('activo', 'pausado', 'concluido', 'cancelado');
+ IF v_statuses IS NULL OR cardinality(v_statuses) = 0 THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_project_list: ningún estado válido en status=%s', v_status_raw),
+ jsonb_build_object('fn', 'fn_project_list', 'field', 'status', 'value', v_status_raw)
+ );
+ END IF;
+ END IF;
+ SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.sort_key, x.id), '[]'::jsonb)
+ INTO v_projects
+ FROM (
+ SELECT
+ p.*,
+ c.name AS company_name,
+ c.code AS company_code,
+ (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count,
+ (SELECT COUNT(*) FROM project_document_types t
+ WHERE t.required = true AND NOT EXISTS (
+ SELECT 1 FROM project_documents d
+ WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true
+ )) AS missing_docs,
+ (SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count,
+ CASE p.status
+ WHEN 'activo' THEN 0
+ WHEN 'pausado' THEN 1
+ WHEN 'concluido' THEN 2
+ ELSE 3
+ END AS sort_key
+ FROM projects p
+ LEFT JOIN companies c ON c.id = p.company_id
+ WHERE v_statuses IS NULL OR p.status = ANY (v_statuses)
+ ) x;
+ RETURN core.rpc_ok(
+ jsonb_build_object('projects', v_projects),
+ format('Listado de proyectos: %s registro(s)', jsonb_array_length(v_projects)),
+ jsonb_build_object('fn', 'fn_project_list', 'count', jsonb_array_length(v_projects))
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009d-fn-project-get splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := (payload->>'id')::bigint;
+ v_project jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_get: id es obligatorio',
+ jsonb_build_object('fn', 'fn_project_get', 'field', 'id')
+ );
+ END IF;
+ SELECT row_to_json(x)::jsonb
+ INTO v_project
+ FROM (
+ SELECT
+ p.*,
+ c.name AS company_name,
+ c.code AS company_code,
+ (SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count
+ FROM projects p
+ LEFT JOIN companies c ON c.id = p.company_id
+ WHERE p.id = v_id
+ ) x;
+ IF v_project IS NULL THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_project_get: proyecto id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_project_get', 'id', v_id)
+ );
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('project', v_project),
+ format('Proyecto %s cargado', v_project->>'code'),
+ jsonb_build_object('fn', 'fn_project_get', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009e-fn-project-create splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_create(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := COALESCE(
+ (payload->>'tenant_id')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_name text := nullif(btrim(payload->>'name'), '');
+ v_address text := coalesce(nullif(btrim(payload->>'address'), ''), '');
+ v_theme_id text := coalesce(nullif(btrim(payload->>'theme_id'), ''), 'arctec-dos-logos-fold');
+ v_status text := coalesce(nullif(btrim(payload->>'status'), ''), 'activo');
+ v_company_id integer := (payload->>'company_id')::integer;
+ v_contract_amount numeric(14,2) := (payload->>'contract_amount')::numeric(14,2);
+ v_start_date date := nullif(btrim(payload->>'start_date'), '')::date;
+ v_end_date date := nullif(btrim(payload->>'end_date'), '')::date;
+ v_resident_name text := coalesce(nullif(btrim(payload->>'resident_name'), ''), '');
+ v_siroc text := upper(coalesce(nullif(btrim(payload->>'siroc'), ''), ''));
+ v_payroll_tax_pct numeric(5,2) := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), 4);
+ v_code_env jsonb;
+ v_code text;
+ v_row projects%ROWTYPE;
+BEGIN
+ IF v_name IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_create: nombre de proyecto obligatorio',
+ jsonb_build_object('fn', 'fn_project_create', 'field', 'name')
+ );
+ END IF;
+ IF v_company_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_create: company_id es obligatorio',
+ jsonb_build_object('fn', 'fn_project_create', 'field', 'company_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_project_create: empresa id=%s no encontrada', v_company_id),
+ jsonb_build_object('fn', 'fn_project_create', 'company_id', v_company_id)
+ );
+ END IF;
+ IF v_status NOT IN ('activo', 'pausado', 'concluido', 'cancelado') THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_project_create: estado inválido %s', v_status),
+ jsonb_build_object('fn', 'fn_project_create', 'field', 'status', 'value', v_status)
+ );
+ END IF;
+ IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_create: la fecha de término no puede ser anterior al inicio',
+ jsonb_build_object('fn', 'fn_project_create', 'field', 'end_date')
+ );
+ END IF;
+ v_code_env := core.fn_next_project_code('{}'::jsonb);
+ IF NOT COALESCE((v_code_env->>'ok')::boolean, false) THEN
+ RETURN v_code_env;
+ END IF;
+ v_code := v_code_env->'data'->>'code';
+ INSERT INTO projects (
+ code, name, address, theme_id, status, company_id, contract_amount,
+ start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id
+ ) VALUES (
+ v_code, v_name, v_address, v_theme_id, v_status, v_company_id, v_contract_amount,
+ v_start_date, v_end_date, v_resident_name, v_siroc, v_payroll_tax_pct, v_tid
+ )
+ RETURNING * INTO v_row;
+ RETURN core.rpc_created(
+ jsonb_build_object('id', v_row.id, 'code', v_row.code, 'status', v_row.status),
+ format('Proyecto %s (%s) creado', v_row.code, v_row.name),
+ jsonb_build_object('fn', 'fn_project_create', 'id', v_row.id, 'code', v_row.code)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009f-fn-project-update splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := (payload->>'id')::bigint;
+ v_cur projects%ROWTYPE;
+ v_name text;
+ v_address text;
+ v_theme_id text;
+ v_status text;
+ v_company_id integer;
+ v_contract_amount numeric(14,2);
+ v_start_date date;
+ v_end_date date;
+ v_resident_name text;
+ v_siroc text;
+ v_payroll_tax_pct numeric(5,2);
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_update: id es obligatorio',
+ jsonb_build_object('fn', 'fn_project_update', 'field', 'id')
+ );
+ END IF;
+ SELECT * INTO v_cur FROM projects WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_project_update: proyecto id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_project_update', 'id', v_id)
+ );
+ END IF;
+ v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name);
+ v_address := CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE v_cur.address END;
+ v_theme_id := coalesce(nullif(btrim(payload->>'theme_id'), ''), v_cur.theme_id);
+ v_status := CASE
+ WHEN payload ? 'status' AND btrim(payload->>'status') IN ('activo', 'pausado', 'concluido', 'cancelado')
+ THEN btrim(payload->>'status')
+ ELSE v_cur.status
+ END;
+ v_company_id := coalesce((payload->>'company_id')::integer, v_cur.company_id);
+ v_contract_amount := CASE
+ WHEN payload ? 'contract_amount' THEN (payload->>'contract_amount')::numeric(14,2)
+ ELSE v_cur.contract_amount
+ END;
+ v_start_date := CASE
+ WHEN payload ? 'start_date' THEN nullif(btrim(payload->>'start_date'), '')::date
+ ELSE v_cur.start_date
+ END;
+ v_end_date := CASE
+ WHEN payload ? 'end_date' THEN nullif(btrim(payload->>'end_date'), '')::date
+ ELSE v_cur.end_date
+ END;
+ v_resident_name := CASE
+ WHEN payload ? 'resident_name' THEN coalesce(nullif(btrim(payload->>'resident_name'), ''), '')
+ ELSE v_cur.resident_name
+ END;
+ v_siroc := CASE
+ WHEN payload ? 'siroc' THEN upper(coalesce(nullif(btrim(payload->>'siroc'), ''), ''))
+ ELSE v_cur.siroc
+ END;
+ v_payroll_tax_pct := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), v_cur.payroll_tax_pct);
+ IF v_company_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_update: company_id es obligatorio',
+ jsonb_build_object('fn', 'fn_project_update', 'field', 'company_id', 'id', v_id)
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_project_update: empresa id=%s no encontrada', v_company_id),
+ jsonb_build_object('fn', 'fn_project_update', 'company_id', v_company_id, 'id', v_id)
+ );
+ END IF;
+ IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_update: la fecha de término no puede ser anterior al inicio',
+ jsonb_build_object('fn', 'fn_project_update', 'field', 'end_date', 'id', v_id)
+ );
+ END IF;
+ UPDATE projects SET
+ name = v_name,
+ address = v_address,
+ theme_id = v_theme_id,
+ status = v_status,
+ company_id = v_company_id,
+ contract_amount = v_contract_amount,
+ start_date = v_start_date,
+ end_date = v_end_date,
+ resident_name = v_resident_name,
+ siroc = v_siroc,
+ payroll_tax_pct = v_payroll_tax_pct
+ WHERE id = v_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id, 'code', v_cur.code, 'status', v_status),
+ format('Proyecto %s actualizado', v_cur.code),
+ jsonb_build_object('fn', 'fn_project_update', 'id', v_id, 'code', v_cur.code)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_update', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009g-fn-project-document-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_document_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := (payload->>'project_id')::bigint;
+ v_documents jsonb;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_document_list: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_project_document_list', 'field', 'project_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_project_document_list: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_project_document_list', 'project_id', v_project_id)
+ );
+ END IF;
+ SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.uploaded_at DESC), '[]'::jsonb)
+ INTO v_documents
+ FROM (
+ SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at
+ FROM project_documents
+ WHERE project_id = v_project_id
+ ) d;
+ RETURN core.rpc_ok(
+ jsonb_build_object('documents', v_documents),
+ format('Documentos del proyecto %s: %s archivo(s)', v_project_id, jsonb_array_length(v_documents)),
+ jsonb_build_object('fn', 'fn_project_document_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_documents))
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_document_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009h-fn-company-document-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_company_document_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_company_id integer := (payload->>'company_id')::integer;
+ v_documents jsonb;
+BEGIN
+ IF v_company_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_company_document_list: company_id es obligatorio',
+ jsonb_build_object('fn', 'fn_company_document_list', 'field', 'company_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_company_document_list: empresa id=%s no encontrada', v_company_id),
+ jsonb_build_object('fn', 'fn_company_document_list', 'company_id', v_company_id)
+ );
+ END IF;
+ SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.uploaded_at DESC), '[]'::jsonb)
+ INTO v_documents
+ FROM (
+ SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at
+ FROM company_documents
+ WHERE company_id = v_company_id
+ ) d;
+ RETURN core.rpc_ok(
+ jsonb_build_object('documents', v_documents),
+ format('Documentos de la empresa %s: %s archivo(s)', v_company_id, jsonb_array_length(v_documents)),
+ jsonb_build_object('fn', 'fn_company_document_list', 'company_id', v_company_id, 'count', jsonb_array_length(v_documents))
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_document_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009i-fn-project-checklist splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_checklist(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := (payload->>'project_id')::bigint;
+ v_checklist jsonb;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_project_checklist: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_project_checklist', 'field', 'project_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_project_checklist: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_project_checklist', 'project_id', v_project_id)
+ );
+ END IF;
+ SELECT COALESCE(jsonb_agg(
+ jsonb_build_object(
+ 'code', t.code,
+ 'label', t.label,
+ 'required', t.required,
+ 'category', coalesce(t.category, 'otro'),
+ 'present', EXISTS (
+ SELECT 1 FROM project_documents d
+ WHERE d.project_id = v_project_id
+ AND d.type_code = t.code
+ AND d.is_current = true
+ )
+ ) ORDER BY t.code
+ ), '[]'::jsonb)
+ INTO v_checklist
+ FROM project_document_types t;
+ RETURN core.rpc_ok(
+ jsonb_build_object('checklist', v_checklist),
+ format('Checklist documental del proyecto %s: %s tipo(s)', v_project_id, jsonb_array_length(v_checklist)),
+ jsonb_build_object('fn', 'fn_project_checklist', 'project_id', v_project_id, 'count', jsonb_array_length(v_checklist))
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_checklist', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009j-fn-company-checklist splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_company_checklist(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_company_id integer := (payload->>'company_id')::integer;
+ v_checklist jsonb;
+BEGIN
+ IF v_company_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_company_checklist: company_id es obligatorio',
+ jsonb_build_object('fn', 'fn_company_checklist', 'field', 'company_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_company_checklist: empresa id=%s no encontrada', v_company_id),
+ jsonb_build_object('fn', 'fn_company_checklist', 'company_id', v_company_id)
+ );
+ END IF;
+ SELECT COALESCE(jsonb_agg(
+ jsonb_build_object(
+ 'code', t.code,
+ 'label', t.label,
+ 'required', t.required,
+ 'category', coalesce(t.category, 'otro'),
+ 'present', EXISTS (
+ SELECT 1 FROM company_documents d
+ WHERE d.company_id = v_company_id
+ AND d.type_code = t.code
+ AND d.is_current = true
+ )
+ ) ORDER BY t.code
+ ), '[]'::jsonb)
+ INTO v_checklist
+ FROM company_document_types t;
+ RETURN core.rpc_ok(
+ jsonb_build_object('checklist', v_checklist),
+ format('Checklist documental de la empresa %s: %s tipo(s)', v_company_id, jsonb_array_length(v_checklist)),
+ jsonb_build_object('fn', 'fn_company_checklist', 'company_id', v_company_id, 'count', jsonb_array_length(v_checklist))
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_checklist', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-009k-fn-projects-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_next_project_code(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_project_assert_status(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_project_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_project_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_project_create(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_project_update(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_project_document_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_document_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_project_checklist(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_checklist(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/010-rpc-workers.sql b/db/core/changesets/010-rpc-workers.sql
new file mode 100644
index 0000000..9fac86d
--- /dev/null
+++ b/db/core/changesets/010-rpc-workers.sql
@@ -0,0 +1,1453 @@
+--liquibase formatted sql
+-- PANELS · core · RPC trabajadores (personal)
+
+--changeset panel:core-010a-worker-helpers splitStatements:false
+CREATE OR REPLACE FUNCTION core._worker_trim_text(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT btrim(COALESCE(p_value, ''));
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_norm_upper(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g'));
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_format_nss(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT regexp_replace(COALESCE(p_value, ''), '\D', '', 'g');
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_canonical_risk(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT CASE lower(btrim(COALESCE(p_value, '')))
+ WHEN 'alto' THEN 'rojo'
+ WHEN 'medio' THEN 'amarillo'
+ WHEN 'bajo' THEN 'verde'
+ ELSE lower(btrim(COALESCE(p_value, '')))
+ END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_validate_curp(p_raw text)
+RETURNS text
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_curp text := core._worker_norm_upper(p_raw);
+BEGIN
+ IF v_curp !~ '^[A-Z][AEIOUX][A-Z]{2}\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[HMX](?:AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[A-Z\d]\d$' THEN
+ RETURN 'CURP inválida (18 caracteres, formato oficial)';
+ END IF;
+ RETURN NULL;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_validate_rfc(p_raw text)
+RETURNS text
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_rfc text := core._worker_norm_upper(p_raw);
+BEGIN
+ IF v_rfc !~ '^[A-ZÑ&]{4}\d{6}[A-Z0-9]{3}$'
+ AND v_rfc !~ '^[A-ZÑ&]{3}\d{6}[A-Z0-9]{3}$' THEN
+ RETURN 'RFC inválido (12 o 13 caracteres, formato SAT)';
+ END IF;
+ RETURN NULL;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_validate_nss(p_raw text)
+RETURNS text
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_nss text := core._worker_format_nss(p_raw);
+ v_sum integer := 0;
+ v_n integer;
+ v_check integer;
+ i integer;
+BEGIN
+ IF length(v_nss) <> 11 THEN
+ RETURN 'NSS inválido (11 dígitos)';
+ END IF;
+ FOR i IN 1..10 LOOP
+ v_n := substring(v_nss, i, 1)::integer * CASE WHEN (i - 1) % 2 = 1 THEN 2 ELSE 1 END;
+ IF v_n > 9 THEN
+ v_n := v_n - 9;
+ END IF;
+ v_sum := v_sum + v_n;
+ END LOOP;
+ v_check := (10 - (v_sum % 10)) % 10;
+ IF v_check <> substring(v_nss, 11, 1)::integer THEN
+ RETURN 'NSS inválido (dígito verificador)';
+ END IF;
+ RETURN NULL;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_validate_fields(p_payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_errors jsonb := '{}'::jsonb;
+ v_err text;
+ v_wt text := upper(core._worker_trim_text(p_payload->>'work_type'));
+BEGIN
+ IF core._worker_trim_text(p_payload->>'first_name') = '' THEN
+ v_errors := v_errors || jsonb_build_object('first_name', 'Nombre obligatorio');
+ END IF;
+ IF core._worker_trim_text(p_payload->>'last_name_p') = '' THEN
+ v_errors := v_errors || jsonb_build_object('last_name_p', 'Apellido paterno obligatorio');
+ END IF;
+ IF core._worker_trim_text(p_payload->>'last_name_m') = '' THEN
+ v_errors := v_errors || jsonb_build_object('last_name_m', 'Apellido materno obligatorio');
+ END IF;
+ v_err := core._worker_validate_curp(p_payload->>'curp');
+ IF v_err IS NOT NULL THEN
+ v_errors := v_errors || jsonb_build_object('curp', v_err);
+ END IF;
+ v_err := core._worker_validate_rfc(p_payload->>'rfc');
+ IF v_err IS NOT NULL THEN
+ v_errors := v_errors || jsonb_build_object('rfc', v_err);
+ END IF;
+ v_err := core._worker_validate_nss(p_payload->>'nss');
+ IF v_err IS NOT NULL THEN
+ v_errors := v_errors || jsonb_build_object('nss', v_err);
+ END IF;
+ IF core._worker_trim_text(p_payload->>'phone') = '' THEN
+ v_errors := v_errors || jsonb_build_object('phone', 'Teléfono obligatorio');
+ END IF;
+ IF core._worker_trim_text(p_payload->>'email') = '' THEN
+ v_errors := v_errors || jsonb_build_object('email', 'Correo obligatorio');
+ END IF;
+ IF core._worker_trim_text(p_payload->>'address') = '' THEN
+ v_errors := v_errors || jsonb_build_object('address', 'Dirección obligatoria');
+ END IF;
+ IF core._worker_trim_text(p_payload->>'position') = '' THEN
+ v_errors := v_errors || jsonb_build_object('position', 'Cargo obligatorio');
+ END IF;
+ IF core._worker_trim_text(p_payload->>'risk_code') = '' THEN
+ v_errors := v_errors || jsonb_build_object('risk_code', 'Riesgo obligatorio');
+ END IF;
+ IF v_wt NOT IN ('N', 'D') THEN
+ v_errors := v_errors || jsonb_build_object('work_type', 'Tipo de trabajo N o D');
+ END IF;
+ IF p_payload->>'daily_wage' IS NULL OR btrim(p_payload->>'daily_wage') = '' THEN
+ v_errors := v_errors || jsonb_build_object('daily_wage', 'Jornal obligatorio');
+ ELSIF (p_payload->>'daily_wage')::numeric IS NULL THEN
+ v_errors := v_errors || jsonb_build_object('daily_wage', 'Jornal obligatorio');
+ END IF;
+ RETURN v_errors;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_normalize(p_payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_middle text := core._worker_trim_text(p_payload->>'middle_name');
+ v_blood text := core._worker_norm_upper(p_payload->>'blood_type');
+ v_needs_badge boolean := NOT (
+ p_payload->>'needs_badge' = 'false'
+ OR p_payload->>'needs_badge' = '0'
+ OR p_payload->'needs_badge' = 'false'::jsonb
+ OR p_payload->'needs_badge' = '0'::jsonb
+ );
+BEGIN
+ RETURN jsonb_build_object(
+ 'first_name', initcap(lower(core._worker_trim_text(p_payload->>'first_name'))),
+ 'middle_name', CASE WHEN v_middle = '' THEN NULL ELSE initcap(lower(v_middle)) END,
+ 'last_name_p', initcap(lower(core._worker_trim_text(p_payload->>'last_name_p'))),
+ 'last_name_m', initcap(lower(core._worker_trim_text(p_payload->>'last_name_m'))),
+ 'curp', core._worker_norm_upper(p_payload->>'curp'),
+ 'rfc', core._worker_norm_upper(p_payload->>'rfc'),
+ 'nss', core._worker_format_nss(p_payload->>'nss'),
+ 'phone', core._worker_trim_text(p_payload->>'phone'),
+ 'email', lower(core._worker_trim_text(p_payload->>'email')),
+ 'address', core._worker_trim_text(p_payload->>'address'),
+ 'blood_type', CASE WHEN v_blood = '' THEN NULL ELSE v_blood END,
+ 'hire_type', upper(core._worker_trim_text(p_payload->>'hire_type')),
+ 'position', initcap(lower(core._worker_trim_text(p_payload->>'position'))),
+ 'risk_code', core._worker_canonical_risk(p_payload->>'risk_code'),
+ 'work_type', upper(core._worker_trim_text(p_payload->>'work_type')),
+ 'daily_wage', COALESCE((p_payload->>'daily_wage')::numeric(12,2), 0),
+ 'needs_badge', v_needs_badge,
+ 'status', CASE WHEN p_payload->>'status' = 'baja' THEN 'baja' ELSE 'activo' END
+ );
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_full_name(
+ p_first_name text,
+ p_middle_name text,
+ p_last_name_p text,
+ p_last_name_m text
+)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT array_to_string(
+ array_remove(ARRAY[p_first_name, p_middle_name, p_last_name_p, p_last_name_m], NULL),
+ ' ');
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_tenant_timezone(p_tenant_id integer)
+RETURNS text
+LANGUAGE plpgsql
+STABLE
+SET search_path = core
+AS $$
+DECLARE
+ v_tz text;
+BEGIN
+ IF p_tenant_id IS NULL THEN
+ RETURN 'America/Mexico_City';
+ END IF;
+ SELECT timezone INTO v_tz
+ FROM tenant_settings
+ WHERE tenant_id = p_tenant_id;
+ RETURN COALESCE(v_tz, 'America/Mexico_City');
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_today(p_tenant_id integer)
+RETURNS date
+LANGUAGE sql
+STABLE
+SET search_path = core
+AS $$
+ SELECT (timezone(core._worker_tenant_timezone(p_tenant_id), now()))::date;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_imss_context(p_worker_id bigint)
+RETURNS jsonb
+LANGUAGE plpgsql
+STABLE
+SET search_path = core
+AS $$
+DECLARE
+ v_ctx jsonb;
+BEGIN
+ SELECT jsonb_build_object(
+ 'imss_status', COALESCE(w.imss_status, 'sin_alta'),
+ 'last_rehire_at', w.last_rehire_at,
+ 'current_alta_at', COALESCE(d.imss_alta_at, w.imss_alta_at, d.uploaded_at::date)
+ )
+ INTO v_ctx
+ FROM workers w
+ LEFT JOIN LATERAL (
+ SELECT doc.imss_alta_at, doc.uploaded_at
+ FROM documents doc
+ WHERE doc.worker_id = w.id
+ AND doc.type_code = 'alta_imss'
+ AND doc.is_current = true
+ LIMIT 1
+ ) d ON true
+ WHERE w.id = p_worker_id;
+ RETURN COALESCE(v_ctx, jsonb_build_object(
+ 'imss_status', 'sin_alta',
+ 'last_rehire_at', NULL,
+ 'current_alta_at', NULL
+ ));
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_freshness_required(p_ctx jsonb)
+RETURNS boolean
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_rehire date;
+ v_alta date;
+BEGIN
+ IF COALESCE(p_ctx->>'imss_status', 'sin_alta') <> 'alta' THEN
+ RETURN true;
+ END IF;
+ IF p_ctx->>'last_rehire_at' IS NULL OR btrim(p_ctx->>'last_rehire_at') = '' THEN
+ RETURN false;
+ END IF;
+ v_rehire := (p_ctx->>'last_rehire_at')::date;
+ IF p_ctx->>'current_alta_at' IS NULL OR btrim(p_ctx->>'current_alta_at') = '' THEN
+ RETURN true;
+ END IF;
+ v_alta := (p_ctx->>'current_alta_at')::date;
+ RETURN v_alta < v_rehire;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_eval_doc_validity(
+ p_policy jsonb,
+ p_doc jsonb,
+ p_ctx jsonb,
+ p_today date
+)
+RETURNS jsonb
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_mode text := COALESCE(p_policy->>'validity_mode', 'none');
+ v_issued date;
+ v_expires date;
+ v_need_fresh boolean;
+ v_days integer;
+BEGIN
+ IF p_doc IS NULL THEN
+ RETURN jsonb_build_object(
+ 'present', false,
+ 'valid', false,
+ 'validity_status', 'missing',
+ 'issued_at', NULL,
+ 'expires_at', NULL
+ );
+ END IF;
+ v_issued := NULLIF(p_doc->>'issued_at', '')::date;
+ v_expires := NULLIF(p_doc->>'expires_at', '')::date;
+ v_need_fresh := core._worker_freshness_required(p_ctx);
+ IF v_mode = 'expiry' THEN
+ IF COALESCE((p_policy->>'requires_expires_at')::boolean, false) AND v_expires IS NULL THEN
+ RETURN jsonb_build_object(
+ 'present', true,
+ 'valid', false,
+ 'validity_status', 'missing_dates',
+ 'issued_at', v_issued,
+ 'expires_at', v_expires
+ );
+ END IF;
+ IF v_expires IS NOT NULL AND v_expires < p_today THEN
+ RETURN jsonb_build_object(
+ 'present', true,
+ 'valid', false,
+ 'validity_status', 'expired',
+ 'issued_at', v_issued,
+ 'expires_at', v_expires
+ );
+ END IF;
+ RETURN jsonb_build_object(
+ 'present', true,
+ 'valid', true,
+ 'validity_status', 'ok',
+ 'issued_at', v_issued,
+ 'expires_at', v_expires
+ );
+ END IF;
+ IF v_mode = 'freshness' THEN
+ IF COALESCE((p_policy->>'requires_issued_at')::boolean, false) AND v_issued IS NULL THEN
+ RETURN jsonb_build_object(
+ 'present', true,
+ 'valid', false,
+ 'validity_status', 'missing_dates',
+ 'issued_at', v_issued,
+ 'expires_at', v_expires
+ );
+ END IF;
+ IF v_need_fresh AND v_issued IS NOT NULL THEN
+ v_days := COALESCE((p_policy->>'freshness_days')::integer, 90);
+ IF (p_today - v_issued) > v_days THEN
+ RETURN jsonb_build_object(
+ 'present', true,
+ 'valid', false,
+ 'validity_status', 'stale_for_alta',
+ 'issued_at', v_issued,
+ 'expires_at', v_expires
+ );
+ END IF;
+ END IF;
+ RETURN jsonb_build_object(
+ 'present', true,
+ 'valid', true,
+ 'validity_status', 'ok',
+ 'issued_at', v_issued,
+ 'expires_at', v_expires
+ );
+ END IF;
+ RETURN jsonb_build_object(
+ 'present', true,
+ 'valid', true,
+ 'validity_status', 'ok',
+ 'issued_at', v_issued,
+ 'expires_at', v_expires
+ );
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_checklist_items(
+ p_worker_id bigint,
+ p_tenant_id integer DEFAULT NULL
+)
+RETURNS jsonb
+LANGUAGE plpgsql
+STABLE
+SET search_path = core
+AS $$
+DECLARE
+ v_today date := core._worker_today(p_tenant_id);
+ v_ctx jsonb := core._worker_imss_context(p_worker_id);
+ v_items jsonb := '[]'::jsonb;
+ v_freshness_required boolean := core._worker_freshness_required(v_ctx);
+ v_rec record;
+ v_doc jsonb;
+ v_policy jsonb;
+ v_evaled jsonb;
+BEGIN
+ FOR v_rec IN
+ SELECT code, label, required, validity_mode, freshness_days,
+ requires_issued_at, requires_expires_at, category
+ FROM document_types
+ ORDER BY required DESC, label
+ LOOP
+ SELECT jsonb_build_object(
+ 'type_code', d.type_code,
+ 'issued_at', d.issued_at,
+ 'expires_at', d.expires_at,
+ 'uploaded_at', d.uploaded_at,
+ 'imss_alta_at', d.imss_alta_at
+ )
+ INTO v_doc
+ FROM documents d
+ WHERE d.worker_id = p_worker_id
+ AND d.type_code = v_rec.code
+ AND d.is_current = true
+ LIMIT 1;
+ v_policy := jsonb_build_object(
+ 'code', v_rec.code,
+ 'label', v_rec.label,
+ 'required', v_rec.required,
+ 'validity_mode', COALESCE(v_rec.validity_mode, 'none'),
+ 'freshness_days', v_rec.freshness_days,
+ 'requires_issued_at', v_rec.requires_issued_at,
+ 'requires_expires_at', v_rec.requires_expires_at
+ );
+ v_evaled := core._worker_eval_doc_validity(v_policy, v_doc, v_ctx, v_today);
+ v_items := v_items || jsonb_build_array(
+ jsonb_build_object(
+ 'code', v_rec.code,
+ 'label', v_rec.label,
+ 'required', v_rec.required,
+ 'validity_mode', COALESCE(v_rec.validity_mode, 'none'),
+ 'freshness_days', v_rec.freshness_days,
+ 'requires_issued_at', v_rec.requires_issued_at,
+ 'requires_expires_at', v_rec.requires_expires_at,
+ 'category', COALESCE(v_rec.category, 'otro'),
+ 'present', COALESCE((v_evaled->>'present')::boolean, false),
+ 'valid', COALESCE((v_evaled->>'valid')::boolean, false),
+ 'validity_status', v_evaled->>'validity_status',
+ 'issued_at', v_evaled->>'issued_at',
+ 'expires_at', v_evaled->>'expires_at'
+ )
+ );
+ END LOOP;
+ RETURN jsonb_build_object(
+ 'ctx', v_ctx,
+ 'freshness_required', v_freshness_required,
+ 'items', v_items
+ );
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_imss_flags(
+ p_worker_id bigint,
+ p_tenant_id integer DEFAULT NULL
+)
+RETURNS jsonb
+LANGUAGE plpgsql
+STABLE
+SET search_path = core
+AS $$
+DECLARE
+ v_pack jsonb := core._worker_checklist_items(p_worker_id, p_tenant_id);
+ v_items jsonb := v_pack->'items';
+ v_ctx jsonb := v_pack->'ctx';
+ v_required_ok boolean;
+ v_in_project boolean;
+ v_has_imss boolean;
+BEGIN
+ SELECT COALESCE(bool_and(
+ COALESCE((elem->>'present')::boolean, false)
+ AND COALESCE((elem->>'valid')::boolean, false)
+ ), false)
+ INTO v_required_ok
+ FROM jsonb_array_elements(v_items) AS elem
+ WHERE COALESCE((elem->>'required')::boolean, false);
+ SELECT EXISTS (
+ SELECT 1 FROM assignments a
+ WHERE a.worker_id = p_worker_id AND a.active = true
+ LIMIT 1
+ ) INTO v_in_project;
+ v_has_imss := COALESCE(v_ctx->>'imss_status', 'sin_alta') = 'alta';
+ RETURN jsonb_build_object(
+ 'imss_status', COALESCE(v_ctx->>'imss_status', 'sin_alta'),
+ 'freshness_required', COALESCE((v_pack->>'freshness_required')::boolean, false),
+ 'imss_ready', v_required_ok AND NOT v_has_imss,
+ 'in_project_without_imss', v_in_project AND NOT v_has_imss,
+ 'expediente_ok', v_required_ok
+ );
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._worker_conflict_message(
+ p_row workers,
+ p_curp text,
+ p_rfc text,
+ p_nss text
+)
+RETURNS text
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_field text;
+BEGIN
+ IF p_row.curp::text = p_curp THEN
+ v_field := 'CURP';
+ ELSIF p_row.rfc::text = p_rfc THEN
+ v_field := 'RFC';
+ ELSE
+ v_field := 'NSS';
+ END IF;
+ RETURN format(
+ 'Este %s ya pertenece a %s %s',
+ v_field, p_row.first_name, p_row.last_name_p
+ );
+END;
+$$;
+
+--changeset panel:core-010b-fn-worker-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_status text := nullif(btrim(payload->>'status'), '');
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_q text := nullif(btrim(payload->>'q'), '');
+ v_like text;
+ v_workers jsonb;
+ v_count integer;
+ v_imss_stats jsonb;
+BEGIN
+ IF v_q IS NOT NULL THEN
+ v_like := '%' || v_q || '%';
+ END IF;
+ SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.last_name_p, x.first_name), '[]'::jsonb),
+ COUNT(*)::integer
+ INTO v_workers, v_count
+ FROM (
+ SELECT
+ w.*,
+ r.label AS risk_label,
+ r.color AS risk_color,
+ r.text_color AS risk_text,
+ c.name AS company_name,
+ c.kind AS company_kind,
+ ic.name AS imss_company_name,
+ ic.code AS imss_company_code,
+ ic.registro_patronal AS imss_registro_patronal,
+ (SELECT string_agg(p.name, ', ')
+ FROM assignments a
+ JOIN projects p ON p.id = a.project_id
+ WHERE a.worker_id = w.id AND a.active = true) AS proyectos,
+ (SELECT string_agg(p.code || '|' || replace(p.name, '|', '/'), ';;')
+ FROM assignments a
+ JOIN projects p ON p.id = a.project_id
+ WHERE a.worker_id = w.id AND a.active = true) AS proyecto_pairs,
+ (SELECT string_agg(p.id::text, ',')
+ FROM assignments a
+ JOIN projects p ON p.id = a.project_id
+ WHERE a.worker_id = w.id AND a.active = true) AS project_ids,
+ (SELECT COUNT(*)
+ FROM document_types t
+ WHERE t.required = true
+ AND NOT EXISTS (
+ SELECT 1 FROM documents d
+ WHERE d.worker_id = w.id
+ AND d.type_code = t.code
+ AND d.is_current = true
+ )) AS missing_docs,
+ (SELECT COALESCE(SUM(l.balance), 0)
+ FROM loans l
+ WHERE l.worker_id = w.id AND l.balance > 0) AS loan_balance,
+ EXISTS (
+ SELECT 1 FROM assignments a
+ WHERE a.worker_id = w.id AND a.active = true
+ ) AS in_project,
+ flags.data->>'imss_status' AS imss_status,
+ (flags.data->>'imss_ready')::boolean AS imss_ready,
+ (flags.data->>'in_project_without_imss')::boolean AS in_project_without_imss,
+ (flags.data->>'expediente_ok')::boolean AS expediente_ok,
+ (flags.data->>'freshness_required')::boolean AS freshness_required
+ FROM workers w
+ JOIN risk_levels r ON r.code = w.risk_code
+ LEFT JOIN companies c ON c.id = w.company_id
+ LEFT JOIN companies ic ON ic.id = w.imss_company_id
+ LEFT JOIN LATERAL (
+ SELECT core._worker_imss_flags(w.id, v_tid) AS data
+ ) flags ON true
+ WHERE (v_tid IS NULL OR w.tenant_id = v_tid)
+ AND (v_status IS NULL OR w.status = v_status)
+ AND (v_status IS DISTINCT FROM 'activo' OR w.pipeline_status <> 'baja')
+ AND (v_project_id IS NULL OR EXISTS (
+ SELECT 1 FROM assignments a
+ WHERE a.worker_id = w.id
+ AND a.project_id = v_project_id
+ AND a.active = true
+ ))
+ AND (v_q IS NULL OR (
+ w.first_name ILIKE v_like
+ OR w.last_name_p ILIKE v_like
+ OR w.curp::text ILIKE v_like
+ OR w.rfc::text ILIKE v_like
+ OR w.nss ILIKE v_like
+ ))
+ ) x;
+ SELECT jsonb_build_object(
+ 'with_imss', COUNT(*) FILTER (
+ WHERE elem->>'status' = 'activo'
+ AND elem->>'pipeline_status' <> 'baja'
+ AND elem->>'imss_status' = 'alta'
+ ),
+ 'without_imss', COUNT(*) FILTER (
+ WHERE elem->>'status' = 'activo'
+ AND elem->>'pipeline_status' <> 'baja'
+ AND elem->>'imss_status' IS DISTINCT FROM 'alta'
+ ),
+ 'in_project_without_imss', COUNT(*) FILTER (
+ WHERE elem->>'status' = 'activo'
+ AND elem->>'pipeline_status' <> 'baja'
+ AND COALESCE((elem->>'in_project_without_imss')::boolean, false)
+ ),
+ 'imss_ready_count', COUNT(*) FILTER (
+ WHERE elem->>'status' = 'activo'
+ AND elem->>'pipeline_status' <> 'baja'
+ AND COALESCE((elem->>'imss_ready')::boolean, false)
+ )
+ )
+ INTO v_imss_stats
+ FROM jsonb_array_elements(v_workers) AS elem;
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'workers', v_workers,
+ 'imss_stats', v_imss_stats
+ ),
+ format('Listado de trabajadores: %s registro(s)%s',
+ v_count,
+ CASE WHEN v_tid IS NOT NULL THEN format(' para tenant_id=%s', v_tid) ELSE '' END),
+ jsonb_build_object('fn', 'fn_worker_list', 'tenant_id', v_tid, 'count', v_count)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010c-fn-worker-get splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_worker jsonb;
+ v_documents jsonb;
+ v_assignments jsonb;
+ v_loans jsonb;
+ v_checklist_pack jsonb;
+ v_flags jsonb;
+ v_doc_types jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_get: id es obligatorio para consultar un trabajador',
+ jsonb_build_object('fn', 'fn_worker_get', 'field', 'id')
+ );
+ END IF;
+ SELECT row_to_json(x)::jsonb
+ INTO v_worker
+ FROM (
+ SELECT
+ w.*,
+ r.label AS risk_label,
+ r.color AS risk_color,
+ r.text_color AS risk_text,
+ c.name AS company_name,
+ c.kind AS company_kind,
+ ic.name AS imss_company_name,
+ ic.code AS imss_company_code,
+ ic.registro_patronal AS imss_registro_patronal,
+ core._worker_full_name(w.first_name, w.middle_name, w.last_name_p, w.last_name_m) AS full_name
+ FROM workers w
+ JOIN risk_levels r ON r.code = w.risk_code
+ LEFT JOIN companies c ON c.id = w.company_id
+ LEFT JOIN companies ic ON ic.id = w.imss_company_id
+ WHERE w.id = v_id
+ AND (v_tid IS NULL OR w.tenant_id = v_tid)
+ ) x;
+ IF v_worker IS NULL THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_get: trabajador id=%s no encontrado%s', v_id,
+ CASE WHEN v_tid IS NOT NULL THEN format(' en tenant_id=%s', v_tid) ELSE '' END),
+ jsonb_build_object('fn', 'fn_worker_get', 'id', v_id, 'tenant_id', v_tid)
+ );
+ END IF;
+ SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.uploaded_at DESC), '[]'::jsonb)
+ INTO v_documents
+ FROM (
+ SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at,
+ issued_at, expires_at, imss_company_id, imss_alta_at
+ FROM documents
+ WHERE worker_id = v_id
+ ) d;
+ SELECT COALESCE(jsonb_agg(row_to_json(a)::jsonb ORDER BY a.active DESC, a.start_date DESC, a.id DESC), '[]'::jsonb)
+ INTO v_assignments
+ FROM (
+ SELECT asn.*, p.name AS project_name, p.code AS project_code
+ FROM assignments asn
+ JOIN projects p ON p.id = asn.project_id
+ WHERE asn.worker_id = v_id
+ ) a;
+ SELECT COALESCE(jsonb_agg(to_jsonb(l) ORDER BY l.id DESC), '[]'::jsonb)
+ INTO v_loans
+ FROM loans l
+ WHERE l.worker_id = v_id;
+ v_checklist_pack := core._worker_checklist_items(v_id, v_tid);
+ v_flags := core._worker_imss_flags(v_id, v_tid);
+ v_worker := v_worker || v_flags || jsonb_build_object('full_name', v_worker->>'full_name');
+ SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.required DESC, t.label), '[]'::jsonb)
+ INTO v_doc_types
+ FROM document_types t;
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'worker', v_worker,
+ 'documents', v_documents,
+ 'assignments', v_assignments,
+ 'loans', v_loans,
+ 'checklist', v_checklist_pack->'items',
+ 'document_types', v_doc_types,
+ 'freshness_required', v_checklist_pack->'freshness_required'
+ ),
+ format('Trabajador id=%s cargado (%s)', v_id, v_worker->>'full_name'),
+ jsonb_build_object('fn', 'fn_worker_get', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010d-fn-worker-create splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_create(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_errors jsonb;
+ v_norm jsonb;
+ v_existing workers%ROWTYPE;
+ v_row workers%ROWTYPE;
+ v_project_status text;
+ v_refresh jsonb;
+BEGIN
+ v_errors := core._worker_validate_fields(payload);
+ IF v_errors <> '{}'::jsonb THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_create: datos del trabajador incompletos o inválidos',
+ jsonb_build_object('fn', 'fn_worker_create', 'field', 'payload'),
+ v_errors
+ );
+ END IF;
+ v_norm := core._worker_normalize(payload);
+ SELECT * INTO v_existing
+ FROM workers
+ WHERE curp = v_norm->>'curp'
+ OR rfc = v_norm->>'rfc'
+ OR nss = v_norm->>'nss'
+ LIMIT 1;
+ IF FOUND THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ core._worker_conflict_message(v_existing, v_norm->>'curp', v_norm->>'rfc', v_norm->>'nss'),
+ jsonb_build_object('fn', 'fn_worker_create', 'worker_id', v_existing.id)
+ );
+ END IF;
+ IF v_project_id IS NOT NULL THEN
+ SELECT status INTO v_project_status FROM projects WHERE id = v_project_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_create: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_worker_create', 'project_id', v_project_id)
+ );
+ END IF;
+ IF v_project_status <> 'activo' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_create: solo se asigna personal a proyectos activos',
+ jsonb_build_object('fn', 'fn_worker_create', 'project_id', v_project_id, 'status', v_project_status)
+ );
+ END IF;
+ END IF;
+ 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 (
+ v_norm->>'first_name',
+ v_norm->>'middle_name',
+ v_norm->>'last_name_p',
+ v_norm->>'last_name_m',
+ v_norm->>'curp',
+ v_norm->>'rfc',
+ v_norm->>'nss',
+ v_norm->>'phone',
+ v_norm->>'email',
+ v_norm->>'address',
+ v_norm->>'blood_type',
+ '',
+ NULL,
+ v_norm->>'position',
+ v_norm->>'risk_code',
+ v_norm->>'work_type',
+ (v_norm->>'daily_wage')::numeric(12,2),
+ (v_norm->>'needs_badge')::boolean,
+ v_norm->>'status',
+ v_tid
+ )
+ RETURNING * INTO v_row;
+ IF v_project_id IS NOT NULL THEN
+ INSERT INTO assignments (worker_id, project_id, active, start_date)
+ VALUES (v_row.id, v_project_id, true, current_date)
+ ON CONFLICT (worker_id, project_id) DO UPDATE SET
+ active = true,
+ start_date = CASE
+ WHEN assignments.active = false THEN excluded.start_date
+ ELSE assignments.start_date
+ END,
+ end_date = NULL;
+ END IF;
+ v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object(
+ 'worker_id', v_row.id,
+ 'tenant_id', v_tid
+ ));
+ IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN
+ RETURN v_refresh;
+ END IF;
+ SELECT pipeline_status INTO v_row.pipeline_status FROM workers WHERE id = v_row.id;
+ RETURN core.rpc_created(
+ jsonb_build_object('id', v_row.id, 'status', v_row.status, 'pipeline_status', v_row.pipeline_status),
+ format('Trabajador %s %s registrado (id=%s)',
+ v_row.first_name, v_row.last_name_p, v_row.id),
+ jsonb_build_object('fn', 'fn_worker_create', 'id', v_row.id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010e-fn-worker-update splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_cur workers%ROWTYPE;
+ v_merged jsonb;
+ v_errors jsonb;
+ v_norm jsonb;
+ v_existing workers%ROWTYPE;
+ v_refresh jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_update: id es obligatorio para actualizar un trabajador',
+ jsonb_build_object('fn', 'fn_worker_update', 'field', 'id')
+ );
+ END IF;
+ SELECT * INTO v_cur FROM workers WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_update: trabajador id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_worker_update', 'id', v_id)
+ );
+ END IF;
+ v_merged := to_jsonb(v_cur) || payload;
+ v_errors := core._worker_validate_fields(v_merged);
+ IF v_errors <> '{}'::jsonb THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_worker_update: datos inválidos para trabajador id=%s', v_id),
+ jsonb_build_object('fn', 'fn_worker_update', 'id', v_id),
+ v_errors
+ );
+ END IF;
+ v_norm := core._worker_normalize(v_merged);
+ SELECT * INTO v_existing
+ FROM workers
+ WHERE (curp = v_norm->>'curp' OR rfc = v_norm->>'rfc' OR nss = v_norm->>'nss')
+ AND id <> v_id
+ LIMIT 1;
+ IF FOUND THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ core._worker_conflict_message(v_existing, v_norm->>'curp', v_norm->>'rfc', v_norm->>'nss'),
+ jsonb_build_object('fn', 'fn_worker_update', 'id', v_id, 'worker_id', v_existing.id)
+ );
+ END IF;
+ UPDATE workers SET
+ first_name = v_norm->>'first_name',
+ middle_name = v_norm->>'middle_name',
+ last_name_p = v_norm->>'last_name_p',
+ last_name_m = v_norm->>'last_name_m',
+ curp = v_norm->>'curp',
+ rfc = v_norm->>'rfc',
+ nss = v_norm->>'nss',
+ phone = v_norm->>'phone',
+ email = v_norm->>'email',
+ address = v_norm->>'address',
+ blood_type = v_norm->>'blood_type',
+ hire_type = v_cur.hire_type,
+ company_id = v_cur.company_id,
+ position = v_norm->>'position',
+ risk_code = v_norm->>'risk_code',
+ work_type = v_norm->>'work_type',
+ daily_wage = (v_norm->>'daily_wage')::numeric(12,2),
+ needs_badge = (v_norm->>'needs_badge')::boolean,
+ status = v_norm->>'status',
+ updated_at = now()
+ WHERE id = v_id;
+ v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object(
+ 'worker_id', v_id,
+ 'tenant_id', v_tid
+ ));
+ IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN
+ RETURN v_refresh;
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id, 'status', v_norm->>'status'),
+ format('Trabajador id=%s actualizado correctamente', v_id),
+ jsonb_build_object('fn', 'fn_worker_update', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_update', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010f-fn-worker-set-pipeline splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_set_pipeline(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := COALESCE(
+ NULLIF(payload->>'id', '')::bigint,
+ NULLIF(payload->>'worker_id', '')::bigint
+ );
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_pipeline text := nullif(btrim(payload->>'pipeline_status'), '');
+ v_prev workers%ROWTYPE;
+ v_worker jsonb;
+ v_refresh jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_set_pipeline: id o worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_set_pipeline', 'field', 'id')
+ );
+ END IF;
+ IF v_pipeline IS NULL OR v_pipeline NOT IN ('incompleto', 'listo_gafete', 'impreso', 'activo', 'baja') THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_worker_set_pipeline: estado de pipeline inválido (%s)', COALESCE(v_pipeline, 'NULL')),
+ jsonb_build_object('fn', 'fn_worker_set_pipeline', 'field', 'pipeline_status', 'value', v_pipeline)
+ );
+ END IF;
+ SELECT * INTO v_prev FROM workers WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_set_pipeline: trabajador id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_worker_set_pipeline', 'id', v_id)
+ );
+ END IF;
+ IF v_pipeline = 'baja' THEN
+ UPDATE workers
+ SET status = 'baja',
+ pipeline_status = 'baja',
+ updated_at = now()
+ WHERE id = v_id;
+ ELSE
+ IF v_prev.status = 'baja' THEN
+ UPDATE workers
+ SET status = 'activo',
+ last_rehire_at = current_date,
+ updated_at = now()
+ WHERE id = v_id;
+ ELSE
+ UPDATE workers
+ SET status = 'activo',
+ updated_at = now()
+ WHERE id = v_id;
+ END IF;
+ v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object(
+ 'worker_id', v_id,
+ 'tenant_id', v_tid
+ ));
+ IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN
+ RETURN v_refresh;
+ END IF;
+ END IF;
+ SELECT to_jsonb(w) INTO v_worker
+ FROM (
+ SELECT status, pipeline_status, last_rehire_at, imss_status
+ FROM workers
+ WHERE id = v_id
+ ) w;
+ RETURN core.rpc_ok(
+ jsonb_build_object('worker', v_worker),
+ format('Pipeline del trabajador id=%s actualizado a %s', v_id, v_worker->>'pipeline_status'),
+ jsonb_build_object('fn', 'fn_worker_set_pipeline', 'id', v_id, 'pipeline_status', v_worker->>'pipeline_status')
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_set_pipeline', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010g-fn-worker-assign splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_assign(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := COALESCE(
+ NULLIF(payload->>'id', '')::bigint,
+ NULLIF(payload->>'worker_id', '')::bigint
+ );
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_project_status text;
+ v_refresh jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_assign: id o worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_assign', 'field', 'worker_id')
+ );
+ END IF;
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_assign: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_assign', 'field', 'project_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_assign: trabajador id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_worker_assign', 'worker_id', v_id)
+ );
+ END IF;
+ SELECT status INTO v_project_status FROM projects WHERE id = v_project_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_assign: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_worker_assign', 'project_id', v_project_id)
+ );
+ END IF;
+ IF v_project_status <> 'activo' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_assign: solo se asigna personal a proyectos activos',
+ jsonb_build_object('fn', 'fn_worker_assign', 'project_id', v_project_id, 'status', v_project_status)
+ );
+ END IF;
+ INSERT INTO assignments (worker_id, project_id, active, start_date)
+ VALUES (v_id, v_project_id, true, current_date)
+ ON CONFLICT (worker_id, project_id) DO UPDATE SET
+ active = true,
+ start_date = CASE
+ WHEN assignments.active = false THEN excluded.start_date
+ ELSE assignments.start_date
+ END,
+ end_date = NULL;
+ v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object(
+ 'worker_id', v_id,
+ 'tenant_id', v_tid
+ ));
+ IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN
+ RETURN v_refresh;
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('worker_id', v_id, 'project_id', v_project_id, 'active', true),
+ format('Trabajador id=%s asignado al proyecto id=%s', v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_worker_assign', 'worker_id', v_id, 'project_id', v_project_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_assign', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010h-fn-worker-unassign splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_unassign(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := COALESCE(
+ NULLIF(payload->>'id', '')::bigint,
+ NULLIF(payload->>'worker_id', '')::bigint
+ );
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_updated integer;
+ v_refresh jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_unassign: id o worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_unassign', 'field', 'worker_id')
+ );
+ END IF;
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_unassign: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_unassign', 'field', 'project_id')
+ );
+ END IF;
+ UPDATE assignments
+ SET active = false,
+ end_date = current_date
+ WHERE worker_id = v_id
+ AND project_id = v_project_id
+ AND active = true;
+ GET DIAGNOSTICS v_updated = ROW_COUNT;
+ IF v_updated = 0 THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_unassign: no hay asignación activa del trabajador id=%s al proyecto id=%s', v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_worker_unassign', 'worker_id', v_id, 'project_id', v_project_id)
+ );
+ END IF;
+ v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object(
+ 'worker_id', v_id,
+ 'tenant_id', v_tid
+ ));
+ IF NOT COALESCE((v_refresh->>'ok')::boolean, false) THEN
+ RETURN v_refresh;
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('worker_id', v_id, 'project_id', v_project_id, 'active', false),
+ format('Trabajador id=%s desasignado del proyecto id=%s', v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_worker_unassign', 'worker_id', v_id, 'project_id', v_project_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_unassign', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010i-fn-worker-find-existing splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_find_existing(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_curp text := core._worker_norm_upper(payload->>'curp');
+ v_rfc text := core._worker_norm_upper(payload->>'rfc');
+ v_nss text := core._worker_format_nss(payload->>'nss');
+ v_exclude bigint := COALESCE(NULLIF(payload->>'exclude_id', '')::bigint, 0);
+ v_worker jsonb;
+BEGIN
+ IF v_curp = '' AND v_rfc = '' AND v_nss = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_find_existing: indique al menos curp, rfc o nss para buscar duplicados',
+ jsonb_build_object('fn', 'fn_worker_find_existing', 'field', 'curp')
+ );
+ END IF;
+ SELECT to_jsonb(w) INTO v_worker
+ FROM (
+ SELECT id, first_name, last_name_p, curp, rfc, nss
+ FROM workers
+ WHERE id <> v_exclude
+ AND (
+ (v_curp <> '' AND curp = v_curp)
+ OR (v_rfc <> '' AND rfc = v_rfc)
+ OR (v_nss <> '' AND nss = v_nss)
+ )
+ LIMIT 1
+ ) w;
+ IF v_worker IS NULL THEN
+ RETURN core.rpc_ok(
+ jsonb_build_object('worker', NULL, 'found', false),
+ 'No se encontró trabajador con la misma CURP, RFC o NSS',
+ jsonb_build_object('fn', 'fn_worker_find_existing', 'found', false)
+ );
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('worker', v_worker, 'found', true),
+ format('Trabajador existente encontrado: %s %s (id=%s)',
+ v_worker->>'first_name', v_worker->>'last_name_p', v_worker->>'id'),
+ jsonb_build_object('fn', 'fn_worker_find_existing', 'found', true, 'id', v_worker->>'id')
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_find_existing', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010j-fn-worker-refresh-pipeline splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_refresh_pipeline(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := COALESCE(
+ NULLIF(payload->>'worker_id', '')::bigint,
+ NULLIF(payload->>'id', '')::bigint
+ );
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_status text;
+ v_pack jsonb;
+ v_items jsonb;
+ v_photo jsonb;
+ v_photo_ok boolean;
+ v_required_ok boolean;
+ v_printed boolean;
+ v_assigned boolean;
+ v_pipeline text := 'incompleto';
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_refresh_pipeline: worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_refresh_pipeline', 'field', 'worker_id')
+ );
+ END IF;
+ SELECT status INTO v_status FROM workers WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_refresh_pipeline: trabajador id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_worker_refresh_pipeline', 'worker_id', v_id)
+ );
+ END IF;
+ IF v_status = 'baja' THEN
+ UPDATE workers SET pipeline_status = 'baja', updated_at = now() WHERE id = v_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('worker_id', v_id, 'pipeline_status', 'baja'),
+ format('Pipeline del trabajador id=%s fijado en baja', v_id),
+ jsonb_build_object('fn', 'fn_worker_refresh_pipeline', 'worker_id', v_id, 'pipeline_status', 'baja')
+ );
+ END IF;
+ v_pack := core._worker_checklist_items(v_id, v_tid);
+ v_items := v_pack->'items';
+ SELECT elem INTO v_photo
+ FROM jsonb_array_elements(v_items) AS elem
+ WHERE elem->>'code' = 'foto'
+ LIMIT 1;
+ v_photo_ok := COALESCE((v_photo->>'present')::boolean, false)
+ AND COALESCE((v_photo->>'valid')::boolean, false);
+ SELECT COALESCE(bool_and(
+ COALESCE((elem->>'present')::boolean, false)
+ AND COALESCE((elem->>'valid')::boolean, false)
+ ), false)
+ INTO v_required_ok
+ FROM jsonb_array_elements(v_items) AS elem
+ WHERE COALESCE((elem->>'required')::boolean, false);
+ SELECT EXISTS (
+ SELECT 1
+ FROM badge_job_people p
+ JOIN badge_jobs j ON j.id = p.job_id
+ WHERE p.worker_id = v_id
+ LIMIT 1
+ ) INTO v_printed;
+ IF v_required_ok AND v_photo_ok THEN
+ v_pipeline := CASE WHEN v_printed THEN 'impreso' ELSE 'listo_gafete' END;
+ END IF;
+ SELECT EXISTS (
+ SELECT 1 FROM assignments a
+ WHERE a.worker_id = v_id AND a.active = true
+ LIMIT 1
+ ) INTO v_assigned;
+ IF v_pipeline <> 'incompleto' AND v_assigned THEN
+ v_pipeline := CASE WHEN v_printed THEN 'activo' ELSE 'listo_gafete' END;
+ END IF;
+ UPDATE workers
+ SET pipeline_status = v_pipeline,
+ updated_at = now()
+ WHERE id = v_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('worker_id', v_id, 'pipeline_status', v_pipeline),
+ format('Pipeline del trabajador id=%s recalculado: %s', v_id, v_pipeline),
+ jsonb_build_object('fn', 'fn_worker_refresh_pipeline', 'worker_id', v_id, 'pipeline_status', v_pipeline)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_refresh_pipeline', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010k-fn-worker-checklist-raw splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_checklist_raw(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := COALESCE(
+ NULLIF(payload->>'worker_id', '')::bigint,
+ NULLIF(payload->>'id', '')::bigint
+ );
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_doc_types jsonb;
+ v_current_docs jsonb;
+ v_pack jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_checklist_raw: worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_checklist_raw', 'field', 'worker_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_checklist_raw: trabajador id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_worker_checklist_raw', 'worker_id', v_id)
+ );
+ END IF;
+ SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.required DESC, t.label), '[]'::jsonb)
+ INTO v_doc_types
+ FROM document_types t;
+ SELECT COALESCE(jsonb_agg(to_jsonb(d)), '[]'::jsonb)
+ INTO v_current_docs
+ FROM (
+ SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
+ FROM documents
+ WHERE worker_id = v_id AND is_current = true
+ ) d;
+ v_pack := core._worker_checklist_items(v_id, v_tid);
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'document_types', v_doc_types,
+ 'current_documents', v_current_docs,
+ 'imss_context', v_pack->'ctx',
+ 'freshness_required', v_pack->'freshness_required',
+ 'checklist', v_pack->'items'
+ ),
+ format('Checklist documental del trabajador id=%s: %s tipo(s)',
+ v_id, jsonb_array_length(v_pack->'items')),
+ jsonb_build_object('fn', 'fn_worker_checklist_raw', 'worker_id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_checklist_raw', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-010l-fn-workers-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core._worker_trim_text(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_norm_upper(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_format_nss(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_canonical_risk(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_validate_curp(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_validate_rfc(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_validate_nss(text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_validate_fields(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_normalize(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_full_name(text, text, text, text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_tenant_timezone(integer) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_today(integer) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_imss_context(bigint) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_freshness_required(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_eval_doc_validity(jsonb, jsonb, jsonb, date) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_checklist_items(bigint, integer) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_imss_flags(bigint, integer) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core._worker_conflict_message(workers, text, text, text) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_create(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_update(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_set_pipeline(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_assign(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_unassign(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_find_existing(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_refresh_pipeline(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_checklist_raw(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/011-rpc-budget.sql b/db/core/changesets/011-rpc-budget.sql
new file mode 100644
index 0000000..b7824be
--- /dev/null
+++ b/db/core/changesets/011-rpc-budget.sql
@@ -0,0 +1,697 @@
+--liquibase formatted sql
+-- PANELS · core · RPC presupuesto (capítulos, partidas, importación atómica)
+
+--changeset panel:core-011a-fn-budget-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_budget_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_chapters jsonb;
+ v_items jsonb;
+ v_subtotal numeric(14,2);
+ v_iva numeric(14,2);
+ v_item_count integer;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_list: project_id es obligatorio para consultar el presupuesto',
+ jsonb_build_object('fn', 'fn_budget_list', 'field', 'project_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_budget_list: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_budget_list', 'project_id', v_project_id)
+ );
+ END IF;
+
+ SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.sort_order, c.id), '[]'::jsonb)
+ INTO v_chapters
+ FROM (
+ SELECT
+ bc.id,
+ bc.project_id,
+ bc.parent_id,
+ bc.code,
+ bc.name,
+ COALESCE(bc.wbs, '') AS wbs,
+ bc.sort_order
+ FROM budget_chapters bc
+ WHERE bc.project_id = v_project_id
+ ) c;
+
+ SELECT
+ COALESCE(jsonb_agg(to_jsonb(i) ORDER BY COALESCE(i.ch_sort, 9999), i.sort_order, i.id), '[]'::jsonb),
+ COALESCE(SUM(i.amount), 0),
+ COUNT(*)::integer
+ INTO v_items, v_subtotal, v_item_count
+ FROM (
+ SELECT
+ bi.*,
+ ch.name AS chapter_name,
+ ch.code AS chapter_code,
+ ch.sort_order AS ch_sort
+ FROM budget_items bi
+ LEFT JOIN budget_chapters ch ON ch.id = bi.chapter_id
+ WHERE bi.project_id = v_project_id
+ ) i;
+
+ v_subtotal := round(COALESCE(v_subtotal, 0), 2);
+ v_iva := round(v_subtotal * 0.16, 2);
+
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'chapters', v_chapters,
+ 'items', v_items,
+ 'totals', jsonb_build_object(
+ 'subtotal', v_subtotal,
+ 'iva', v_iva,
+ 'total', round(v_subtotal + v_iva, 2),
+ 'item_count', v_item_count
+ )
+ ),
+ format('Presupuesto del proyecto %s: %s capítulo(s), %s partida(s), subtotal %s',
+ v_project_id, jsonb_array_length(v_chapters), v_item_count, v_subtotal),
+ jsonb_build_object(
+ 'fn', 'fn_budget_list',
+ 'project_id', v_project_id,
+ 'chapter_count', jsonb_array_length(v_chapters),
+ 'item_count', v_item_count
+ )
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_budget_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-011b-fn-budget-chapter-create splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_budget_chapter_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_parent_id bigint := NULLIF(payload->>'parent_id', '')::bigint;
+ v_name text := nullif(btrim(payload->>'name'), '');
+ v_code text := coalesce(nullif(btrim(payload->>'code'), ''), '');
+ v_wbs text := coalesce(nullif(btrim(payload->>'wbs'), ''), '');
+ v_sort integer;
+ v_row budget_chapters%ROWTYPE;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_chapter_create: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_chapter_create', 'field', 'project_id')
+ );
+ END IF;
+ IF v_name IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_chapter_create: nombre de capítulo obligatorio',
+ jsonb_build_object('fn', 'fn_budget_chapter_create', 'field', 'name', 'project_id', v_project_id)
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_budget_chapter_create: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id)
+ );
+ END IF;
+ IF v_parent_id IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM budget_chapters WHERE id = v_parent_id AND project_id = v_project_id
+ ) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_chapter_create: capítulo padre id=%s no pertenece al proyecto %s', v_parent_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id, 'parent_id', v_parent_id)
+ );
+ END IF;
+
+ SELECT COALESCE(MAX(sort_order), 0) + 1
+ INTO v_sort
+ FROM budget_chapters
+ WHERE project_id = v_project_id;
+
+ INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order)
+ VALUES (v_project_id, v_parent_id, v_code, v_name, v_wbs, v_sort)
+ RETURNING * INTO v_row;
+
+ RETURN core.rpc_created(
+ jsonb_build_object('chapter', to_jsonb(v_row)),
+ format('Capítulo "%s" creado en proyecto %s (id=%s)', v_row.name, v_project_id, v_row.id),
+ jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id, 'id', v_row.id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_budget_chapter_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-011c-fn-budget-chapter-update splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_budget_chapter_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_cur budget_chapters%ROWTYPE;
+ v_name text;
+ v_code text;
+ v_wbs text;
+ v_parent_id bigint;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_chapter_update: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_chapter_update', 'field', 'project_id')
+ );
+ END IF;
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_chapter_update: id del capítulo es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_chapter_update', 'field', 'id', 'project_id', v_project_id)
+ );
+ END IF;
+
+ SELECT * INTO v_cur FROM budget_chapters WHERE id = v_id AND project_id = v_project_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_budget_chapter_update: capítulo id=%s no encontrado en proyecto %s', v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id)
+ );
+ END IF;
+
+ v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name);
+ v_code := CASE WHEN payload ? 'code' THEN coalesce(nullif(btrim(payload->>'code'), ''), '') ELSE v_cur.code END;
+ v_wbs := CASE WHEN payload ? 'wbs' THEN coalesce(nullif(btrim(payload->>'wbs'), ''), '') ELSE v_cur.wbs END;
+ v_parent_id := CASE
+ WHEN payload ? 'parent_id' THEN NULLIF(payload->>'parent_id', '')::bigint
+ ELSE v_cur.parent_id
+ END;
+
+ IF v_parent_id IS NOT NULL AND v_parent_id = v_id THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_chapter_update: un capítulo no puede ser padre de sí mismo (id=%s)', v_id),
+ jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id)
+ );
+ END IF;
+ IF v_parent_id IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM budget_chapters WHERE id = v_parent_id AND project_id = v_project_id
+ ) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_chapter_update: capítulo padre id=%s no pertenece al proyecto %s', v_parent_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'parent_id', v_parent_id)
+ );
+ END IF;
+
+ UPDATE budget_chapters
+ SET name = v_name,
+ code = v_code,
+ wbs = v_wbs,
+ parent_id = v_parent_id
+ WHERE id = v_id;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id, 'name', v_name, 'code', v_code),
+ format('Capítulo id=%s actualizado en proyecto %s', v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_budget_chapter_update', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-011d-fn-budget-chapter-delete splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_budget_chapter_delete(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_name text;
+ v_orphans integer;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_chapter_delete: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_chapter_delete', 'field', 'project_id')
+ );
+ END IF;
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_chapter_delete: id del capítulo es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_chapter_delete', 'field', 'id', 'project_id', v_project_id)
+ );
+ END IF;
+
+ SELECT name INTO v_name FROM budget_chapters WHERE id = v_id AND project_id = v_project_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_budget_chapter_delete: capítulo id=%s no encontrado en proyecto %s', v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_chapter_delete', 'project_id', v_project_id, 'id', v_id)
+ );
+ END IF;
+
+ UPDATE budget_items
+ SET chapter_id = NULL
+ WHERE project_id = v_project_id
+ AND chapter_id = v_id;
+ GET DIAGNOSTICS v_orphans = ROW_COUNT;
+
+ DELETE FROM budget_chapters WHERE id = v_id AND project_id = v_project_id;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id, 'orphaned_items', v_orphans),
+ format('Capítulo "%s" (id=%s) eliminado del proyecto %s; %s partida(s) quedaron sin capítulo',
+ v_name, v_id, v_project_id, v_orphans),
+ jsonb_build_object('fn', 'fn_budget_chapter_delete', 'project_id', v_project_id, 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_budget_chapter_delete', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-011e-fn-budget-item-create splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_budget_item_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_chapter_id bigint := NULLIF(payload->>'chapter_id', '')::bigint;
+ v_description text := nullif(btrim(payload->>'description'), '');
+ v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), ''));
+ v_unit text := upper(coalesce(nullif(btrim(payload->>'unit'), ''), ''));
+ v_qty numeric(14,4) := coalesce((payload->>'quantity')::numeric(14,4), 0);
+ v_price numeric(14,4) := coalesce((payload->>'unit_price')::numeric(14,4), 0);
+ v_wbs text := coalesce(nullif(btrim(payload->>'wbs'), ''), '');
+ v_amount numeric(14,2);
+ v_sort integer;
+ v_row budget_items%ROWTYPE;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_item_create: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_item_create', 'field', 'project_id')
+ );
+ END IF;
+ IF v_description IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_item_create: descripción de partida obligatoria',
+ jsonb_build_object('fn', 'fn_budget_item_create', 'field', 'description', 'project_id', v_project_id)
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_budget_item_create: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id)
+ );
+ END IF;
+ IF v_chapter_id IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id
+ ) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_item_create: capítulo id=%s no pertenece al proyecto %s', v_chapter_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id, 'chapter_id', v_chapter_id)
+ );
+ END IF;
+
+ v_amount := round(v_qty * v_price, 2);
+ SELECT COALESCE(MAX(sort_order), 0) + 1
+ INTO v_sort
+ FROM budget_items
+ WHERE project_id = v_project_id;
+
+ INSERT INTO budget_items (
+ project_id, chapter_id, code, description, unit, quantity, unit_price, amount, wbs, sort_order
+ ) VALUES (
+ v_project_id, v_chapter_id, v_code, v_description, v_unit, v_qty, v_price, v_amount, v_wbs, v_sort
+ )
+ RETURNING * INTO v_row;
+
+ RETURN core.rpc_created(
+ jsonb_build_object('item', to_jsonb(v_row)),
+ format('Partida "%s" creada en proyecto %s (id=%s, importe=%s)',
+ v_row.description, v_project_id, v_row.id, v_row.amount),
+ jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id, 'id', v_row.id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_budget_item_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-011f-fn-budget-item-update splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_budget_item_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_cur budget_items%ROWTYPE;
+ v_chapter_id bigint;
+ v_description text;
+ v_code text;
+ v_unit text;
+ v_qty numeric(14,4);
+ v_price numeric(14,4);
+ v_wbs text;
+ v_amount numeric(14,2);
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_item_update: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_item_update', 'field', 'project_id')
+ );
+ END IF;
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_item_update: id de partida es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_item_update', 'field', 'id', 'project_id', v_project_id)
+ );
+ END IF;
+
+ SELECT * INTO v_cur FROM budget_items WHERE id = v_id AND project_id = v_project_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_budget_item_update: partida id=%s no encontrada en proyecto %s', v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'id', v_id)
+ );
+ END IF;
+
+ v_chapter_id := CASE
+ WHEN payload ? 'chapter_id' THEN NULLIF(payload->>'chapter_id', '')::bigint
+ ELSE v_cur.chapter_id
+ END;
+ v_description := coalesce(nullif(btrim(payload->>'description'), ''), v_cur.description);
+ v_code := upper(CASE WHEN payload ? 'code' THEN coalesce(nullif(btrim(payload->>'code'), ''), '') ELSE v_cur.code END);
+ v_unit := upper(CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE v_cur.unit END);
+ v_qty := CASE WHEN payload ? 'quantity' THEN coalesce((payload->>'quantity')::numeric(14,4), 0) ELSE v_cur.quantity END;
+ v_price := CASE WHEN payload ? 'unit_price' THEN coalesce((payload->>'unit_price')::numeric(14,4), 0) ELSE v_cur.unit_price END;
+ v_wbs := CASE WHEN payload ? 'wbs' THEN coalesce(nullif(btrim(payload->>'wbs'), ''), '') ELSE v_cur.wbs END;
+
+ IF v_chapter_id IS NOT NULL AND NOT EXISTS (
+ SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id
+ ) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_item_update: capítulo id=%s no pertenece al proyecto %s', v_chapter_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'chapter_id', v_chapter_id)
+ );
+ END IF;
+
+ v_amount := round(v_qty * v_price, 2);
+
+ UPDATE budget_items
+ SET chapter_id = v_chapter_id,
+ code = v_code,
+ description = v_description,
+ unit = v_unit,
+ quantity = v_qty,
+ unit_price = v_price,
+ amount = v_amount,
+ wbs = v_wbs
+ WHERE id = v_id;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id, 'amount', v_amount),
+ format('Partida id=%s actualizada en proyecto %s (importe=%s)', v_id, v_project_id, v_amount),
+ jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_budget_item_update', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-011g-fn-budget-item-delete splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_budget_item_delete(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_description text;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_item_delete: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_item_delete', 'field', 'project_id')
+ );
+ END IF;
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_item_delete: id de partida es obligatorio',
+ jsonb_build_object('fn', 'fn_budget_item_delete', 'field', 'id', 'project_id', v_project_id)
+ );
+ END IF;
+
+ DELETE FROM budget_items
+ WHERE id = v_id
+ AND project_id = v_project_id
+ RETURNING description INTO v_description;
+
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_budget_item_delete: partida id=%s no encontrada en proyecto %s', v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_item_delete', 'project_id', v_project_id, 'id', v_id)
+ );
+ END IF;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object('id', v_id),
+ format('Partida "%s" (id=%s) eliminada del proyecto %s', v_description, v_id, v_project_id),
+ jsonb_build_object('fn', 'fn_budget_item_delete', 'project_id', v_project_id, 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_budget_item_delete', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-011h-fn-budget-replace splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_budget_replace(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_chapters jsonb := COALESCE(payload->'chapters', '[]'::jsonb);
+ v_items jsonb := COALESCE(payload->'items', '[]'::jsonb);
+ v_ch jsonb;
+ v_it jsonb;
+ v_wbs text;
+ v_parent_wbs text;
+ v_chapter_wbs text;
+ v_parent_id bigint;
+ v_chapter_id bigint;
+ v_new_id bigint;
+ v_sort integer;
+ v_item_sort integer;
+ v_qty numeric(14,4);
+ v_price numeric(14,4);
+ v_amount numeric(14,2);
+ v_chapter_count integer := 0;
+ v_item_count integer := 0;
+ v_wbs_map jsonb := '{}'::jsonb;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_replace: project_id es obligatorio para reemplazar el presupuesto',
+ jsonb_build_object('fn', 'fn_budget_replace', 'field', 'project_id')
+ );
+ END IF;
+ IF jsonb_typeof(v_chapters) <> 'array' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_replace: chapters debe ser un arreglo JSON',
+ jsonb_build_object('fn', 'fn_budget_replace', 'field', 'chapters', 'project_id', v_project_id)
+ );
+ END IF;
+ IF jsonb_typeof(v_items) <> 'array' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_budget_replace: items debe ser un arreglo JSON',
+ jsonb_build_object('fn', 'fn_budget_replace', 'field', 'items', 'project_id', v_project_id)
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_budget_replace: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id)
+ );
+ END IF;
+
+ DELETE FROM budget_items WHERE project_id = v_project_id;
+ DELETE FROM budget_chapters WHERE project_id = v_project_id;
+
+ v_sort := 0;
+ FOR v_ch IN
+ SELECT elem
+ FROM jsonb_array_elements(v_chapters) AS elem
+ ORDER BY coalesce(nullif(btrim(elem->>'wbs'), ''), elem->>'sort_order', elem->>'name')
+ LOOP
+ v_sort := v_sort + 1;
+ v_wbs := coalesce(nullif(btrim(v_ch->>'wbs'), ''), v_sort::text);
+ v_parent_wbs := nullif(btrim(v_ch->>'parent_wbs'), '');
+ v_parent_id := NULL;
+ IF v_parent_wbs IS NOT NULL THEN
+ v_parent_id := NULLIF(v_wbs_map->>v_parent_wbs, '')::bigint;
+ IF v_parent_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_replace: parent_wbs=%s no encontrado al insertar capítulo wbs=%s', v_parent_wbs, v_wbs),
+ jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'wbs', v_wbs, 'parent_wbs', v_parent_wbs)
+ );
+ END IF;
+ END IF;
+
+ IF nullif(btrim(v_ch->>'name'), '') IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_replace: nombre de capítulo obligatorio (wbs=%s)', v_wbs),
+ jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'wbs', v_wbs)
+ );
+ END IF;
+
+ INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order)
+ VALUES (
+ v_project_id,
+ v_parent_id,
+ coalesce(nullif(btrim(v_ch->>'code'), ''), ''),
+ btrim(v_ch->>'name'),
+ v_wbs,
+ coalesce((v_ch->>'sort_order')::integer, v_sort)
+ )
+ RETURNING id INTO v_new_id;
+
+ v_wbs_map := v_wbs_map || jsonb_build_object(v_wbs, v_new_id::text);
+ v_chapter_count := v_chapter_count + 1;
+ END LOOP;
+
+ v_item_sort := 0;
+ FOR v_it IN SELECT elem FROM jsonb_array_elements(v_items) AS elem
+ LOOP
+ v_item_sort := v_item_sort + 1;
+ IF nullif(btrim(v_it->>'description'), '') IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_replace: descripción obligatoria en partida #%s', v_item_sort),
+ jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'item_index', v_item_sort)
+ );
+ END IF;
+
+ v_chapter_wbs := nullif(btrim(v_it->>'chapter_wbs'), '');
+ v_chapter_id := NULL;
+ IF v_chapter_wbs IS NOT NULL THEN
+ v_chapter_id := NULLIF(v_wbs_map->>v_chapter_wbs, '')::bigint;
+ IF v_chapter_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_replace: chapter_wbs=%s no encontrado para partida #%s', v_chapter_wbs, v_item_sort),
+ jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_wbs', v_chapter_wbs)
+ );
+ END IF;
+ ELSIF v_it ? 'chapter_id' AND nullif(v_it->>'chapter_id', '') IS NOT NULL THEN
+ v_chapter_id := (v_it->>'chapter_id')::bigint;
+ IF NOT EXISTS (SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_budget_replace: chapter_id=%s inválido para partida #%s', v_chapter_id, v_item_sort),
+ jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_id', v_chapter_id)
+ );
+ END IF;
+ END IF;
+
+ v_qty := coalesce((v_it->>'quantity')::numeric(14,4), 0);
+ v_price := coalesce((v_it->>'unit_price')::numeric(14,4), 0);
+ v_amount := round(coalesce((v_it->>'amount')::numeric(14,2), v_qty * v_price), 2);
+
+ INSERT INTO budget_items (
+ project_id, chapter_id, code, description, unit, quantity, unit_price, amount, wbs, sort_order
+ ) VALUES (
+ v_project_id,
+ v_chapter_id,
+ upper(coalesce(nullif(btrim(v_it->>'code'), ''), '')),
+ btrim(v_it->>'description'),
+ upper(coalesce(nullif(btrim(v_it->>'unit'), ''), '')),
+ v_qty,
+ v_price,
+ v_amount,
+ coalesce(nullif(btrim(v_it->>'wbs'), ''), ''),
+ coalesce((v_it->>'sort_order')::integer, v_item_sort)
+ );
+ v_item_count := v_item_count + 1;
+ END LOOP;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'project_id', v_project_id,
+ 'chapter_count', v_chapter_count,
+ 'item_count', v_item_count
+ ),
+ format('Presupuesto del proyecto %s reemplazado: %s capítulo(s) y %s partida(s) importados',
+ v_project_id, v_chapter_count, v_item_count),
+ jsonb_build_object(
+ 'fn', 'fn_budget_replace',
+ 'project_id', v_project_id,
+ 'chapter_count', v_chapter_count,
+ 'item_count', v_item_count
+ )
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_budget_replace', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-011i-fn-budget-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_budget_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_create(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_update(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_delete(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_budget_item_create(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_budget_item_update(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_budget_item_delete(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_budget_replace(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/012-rpc-badges.sql b/db/core/changesets/012-rpc-badges.sql
new file mode 100644
index 0000000..a453cf8
--- /dev/null
+++ b/db/core/changesets/012-rpc-badges.sql
@@ -0,0 +1,333 @@
+--liquibase formatted sql
+-- PANELS · core · RPC gafetes (jobs de impresión y entrega)
+
+--changeset panel:core-012a-fn-badge-job-create splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_badge_job_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_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer;
+ v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
+ v_worker_ids integer[];
+ v_wid integer;
+ v_job_id bigint;
+ v_project record;
+ v_missing integer;
+ v_count integer := 0;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_badge_job_create: project_id es obligatorio para generar gafetes',
+ jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'project_id')
+ );
+ END IF;
+
+ SELECT p.id, p.code, p.name, p.status
+ INTO v_project
+ FROM projects p
+ WHERE p.id = v_project_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_badge_job_create: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id)
+ );
+ END IF;
+ IF v_project.status <> 'activo' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_badge_job_create: solo se generan gafetes de proyectos activos (proyecto %s está en %s)',
+ v_project.code, v_project.status),
+ jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id, 'status', v_project.status)
+ );
+ END IF;
+
+ IF payload->'worker_ids' IS NULL OR jsonb_typeof(payload->'worker_ids') <> 'array' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_badge_job_create: worker_ids debe ser un arreglo de enteros',
+ jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'worker_ids', 'project_id', v_project_id)
+ );
+ END IF;
+
+ SELECT array_agg(DISTINCT (elem::text)::integer ORDER BY (elem::text)::integer)
+ INTO v_worker_ids
+ FROM jsonb_array_elements(payload->'worker_ids') AS elem
+ WHERE nullif(btrim(elem::text), '') IS NOT NULL;
+
+ IF v_worker_ids IS NULL OR cardinality(v_worker_ids) = 0 THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_badge_job_create: debe indicar al menos un trabajador en worker_ids',
+ jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'worker_ids', 'project_id', v_project_id)
+ );
+ END IF;
+
+ SELECT COUNT(*)
+ INTO v_missing
+ FROM unnest(v_worker_ids) AS wid
+ WHERE NOT EXISTS (SELECT 1 FROM workers w WHERE w.id = wid);
+ IF v_missing > 0 THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_badge_job_create: %s trabajador(es) en worker_ids no existen en el catálogo', v_missing),
+ jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id, 'missing_count', v_missing)
+ );
+ END IF;
+
+ INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name)
+ VALUES (v_project_id, 'done', v_created_by_id, v_created_by_name)
+ RETURNING id INTO v_job_id;
+
+ FOREACH v_wid IN ARRAY v_worker_ids
+ LOOP
+ INSERT INTO badge_job_people (job_id, worker_id)
+ VALUES (v_job_id::integer, v_wid)
+ ON CONFLICT (job_id, worker_id) DO NOTHING;
+ v_count := v_count + 1;
+ END LOOP;
+
+ RETURN core.rpc_created(
+ jsonb_build_object(
+ 'id', v_job_id,
+ 'project_id', v_project_id,
+ 'count', v_count,
+ 'worker_ids', to_jsonb(v_worker_ids)
+ ),
+ format('Job de gafetes #%s creado para proyecto %s (%s): %s persona(s)',
+ v_job_id, v_project.code, v_project.name, v_count),
+ jsonb_build_object(
+ 'fn', 'fn_badge_job_create',
+ 'job_id', v_job_id,
+ 'project_id', v_project_id,
+ 'count', v_count,
+ 'created_by_id', v_created_by_id
+ )
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_badge_job_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-012b-fn-badge-job-list splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_badge_job_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_jobs jsonb;
+ v_count integer;
+BEGIN
+ SELECT
+ COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.id DESC), '[]'::jsonb),
+ COUNT(*)::integer
+ INTO v_jobs, v_count
+ FROM (
+ SELECT
+ j.id,
+ j.project_id,
+ j.status,
+ j.pdf_path,
+ j.created_by_id,
+ j.created_by_name,
+ j.created_at,
+ p.code AS project_code,
+ p.name AS project_name,
+ (SELECT COUNT(*) FROM badge_job_people bp WHERE bp.job_id = j.id) AS people,
+ (SELECT COUNT(*) FROM badge_job_people bp WHERE bp.job_id = j.id AND bp.delivered = true) AS delivered
+ FROM badge_jobs j
+ JOIN projects p ON p.id = j.project_id
+ WHERE v_project_id IS NULL OR j.project_id = v_project_id
+ ) x;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object('jobs', v_jobs),
+ format('Listado de jobs de gafetes: %s registro(s)%s',
+ v_count,
+ CASE WHEN v_project_id IS NOT NULL THEN format(' para proyecto %s', v_project_id) ELSE '' END),
+ jsonb_build_object(
+ 'fn', 'fn_badge_job_list',
+ 'project_id', v_project_id,
+ 'count', v_count
+ )
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_badge_job_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-012c-fn-badge-job-get splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_badge_job_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_job_id bigint := COALESCE(
+ NULLIF(payload->>'job_id', '')::bigint,
+ NULLIF(payload->>'id', '')::bigint
+ );
+ v_job jsonb;
+ v_people jsonb;
+BEGIN
+ IF v_job_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_badge_job_get: job_id es obligatorio',
+ jsonb_build_object('fn', 'fn_badge_job_get', 'field', 'job_id')
+ );
+ END IF;
+
+ SELECT to_jsonb(x)
+ INTO v_job
+ FROM (
+ SELECT
+ j.*,
+ p.code AS project_code,
+ p.name AS project_name
+ FROM badge_jobs j
+ JOIN projects p ON p.id = j.project_id
+ WHERE j.id = v_job_id
+ ) x;
+
+ IF v_job IS NULL THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_badge_job_get: job de gafetes id=%s no encontrado', v_job_id),
+ jsonb_build_object('fn', 'fn_badge_job_get', 'job_id', v_job_id)
+ );
+ END IF;
+
+ SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY w.last_name_p, w.first_name), '[]'::jsonb)
+ INTO v_people
+ FROM (
+ SELECT
+ bp.job_id,
+ bp.worker_id,
+ bp.delivered,
+ bp.delivered_at,
+ w.first_name,
+ w.last_name_p,
+ w.last_name_m,
+ w.position
+ FROM badge_job_people bp
+ JOIN workers w ON w.id = bp.worker_id
+ WHERE bp.job_id = v_job_id::integer
+ ) p;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object('job', v_job, 'people', v_people),
+ format('Job de gafetes #%s cargado: %s persona(s) en el lote',
+ v_job_id, jsonb_array_length(v_people)),
+ jsonb_build_object(
+ 'fn', 'fn_badge_job_get',
+ 'job_id', v_job_id,
+ 'people_count', jsonb_array_length(v_people)
+ )
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_badge_job_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-012d-fn-badge-job-set-delivered splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_badge_job_set_delivered(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_job_id bigint := NULLIF(payload->>'job_id', '')::bigint;
+ v_worker_id integer := NULLIF(payload->>'worker_id', '')::integer;
+ v_delivered boolean;
+ v_updated boolean;
+BEGIN
+ IF v_job_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_badge_job_set_delivered: job_id es obligatorio',
+ jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'job_id')
+ );
+ END IF;
+ IF v_worker_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_badge_job_set_delivered: worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'worker_id', 'job_id', v_job_id)
+ );
+ END IF;
+ IF payload->>'delivered' IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_badge_job_set_delivered: delivered (boolean) es obligatorio',
+ jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'delivered', 'job_id', v_job_id, 'worker_id', v_worker_id)
+ );
+ END IF;
+
+ v_delivered := (payload->>'delivered')::boolean;
+
+ IF NOT EXISTS (SELECT 1 FROM badge_jobs WHERE id = v_job_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_badge_job_set_delivered: job de gafetes id=%s no encontrado', v_job_id),
+ jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'job_id', v_job_id)
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_badge_job_set_delivered: trabajador id=%s no encontrado', v_worker_id),
+ jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'worker_id', v_worker_id, 'job_id', v_job_id)
+ );
+ END IF;
+
+ UPDATE badge_job_people
+ SET delivered = v_delivered,
+ delivered_at = CASE WHEN v_delivered THEN now() ELSE NULL END
+ WHERE job_id = v_job_id::integer
+ AND worker_id = v_worker_id
+ RETURNING true INTO v_updated;
+
+ IF NOT COALESCE(v_updated, false) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_badge_job_set_delivered: trabajador id=%s no pertenece al job %s', v_worker_id, v_job_id),
+ jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'job_id', v_job_id, 'worker_id', v_worker_id)
+ );
+ END IF;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'job_id', v_job_id,
+ 'worker_id', v_worker_id,
+ 'delivered', v_delivered
+ ),
+ format('Entrega de gafete actualizada: trabajador %s en job #%s → %s',
+ v_worker_id, v_job_id, CASE WHEN v_delivered THEN 'entregado' ELSE 'pendiente' END),
+ jsonb_build_object(
+ 'fn', 'fn_badge_job_set_delivered',
+ 'job_id', v_job_id,
+ 'worker_id', v_worker_id,
+ 'delivered', v_delivered
+ )
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_badge_job_set_delivered', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-012e-fn-badge-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_badge_job_create(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_badge_job_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_badge_job_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_badge_job_set_delivered(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/013-rpc-payroll.sql b/db/core/changesets/013-rpc-payroll.sql
new file mode 100644
index 0000000..0e82325
--- /dev/null
+++ b/db/core/changesets/013-rpc-payroll.sql
@@ -0,0 +1,2032 @@
+--liquibase formatted sql
+-- PANELS · core · RPC nómina (port de api/payroll.ts)
+
+--changeset panel:core-013a-payroll-helpers splitStatements:false
+CREATE OR REPLACE FUNCTION core.round_money(p_value numeric)
+RETURNS numeric
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT round(COALESCE(p_value, 0)::numeric, 2);
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_tenant_id(payload jsonb)
+RETURNS integer
+LANGUAGE sql
+STABLE
+AS $$
+ SELECT COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer,
+ 0
+ );
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_add_days(p_iso date, p_days integer)
+RETURNS date
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT (p_iso + make_interval(days => p_days))::date;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_weekday_mon0(p_iso date)
+RETURNS integer
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT (extract(isodow FROM p_iso)::integer - 1);
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_week_containing(p_iso date)
+RETURNS TABLE(week_start date, week_end date)
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ WITH ws AS (
+ SELECT core._payroll_add_days(p_iso, -core._payroll_weekday_mon0(p_iso)) AS week_start
+ )
+ SELECT ws.week_start, core._payroll_add_days(ws.week_start, 5) AS week_end
+ FROM ws;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_destajo_period_bounds(p_week_start date)
+RETURNS TABLE(period_start date, period_end date)
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ WITH pe AS (
+ SELECT core._payroll_add_days(p_week_start, 3) AS period_end
+ )
+ SELECT core._payroll_add_days(pe.period_end, -7), pe.period_end
+ FROM pe;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_this_saturday(p_iso date)
+RETURNS date
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT week_end FROM core._payroll_week_containing(p_iso);
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_next_saturday_after_week(p_iso date)
+RETURNS date
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT core._payroll_add_days(core._payroll_this_saturday(p_iso), 7);
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_week_assemble_date(p_week_start date)
+RETURNS date
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT core._payroll_add_days(p_week_start, 3);
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_can_assemble_week(p_week_start date, p_today date)
+RETURNS boolean
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT p_today >= core._payroll_week_assemble_date(p_week_start);
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_tenant_today(p_tenant_id integer)
+RETURNS date
+LANGUAGE sql
+STABLE
+SET search_path = core
+AS $$
+ SELECT (now() AT TIME ZONE COALESCE(
+ (SELECT timezone FROM tenant_settings WHERE tenant_id = p_tenant_id),
+ 'America/Mexico_City'
+ ))::date;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_money_label(p_amount numeric)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT '$' || trim(to_char(core.round_money(p_amount), 'FM999,999,999,990.00'));
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_commission_amount(p_delivered numeric, p_pct numeric)
+RETURNS numeric
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT core.round_money(COALESCE(p_delivered, 0) * COALESCE(p_pct, 0) / 100.0);
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_loan_condition_label(
+ p_delivered numeric,
+ p_commission_pct numeric,
+ p_pay_amount numeric,
+ p_installment_n integer,
+ p_installments_n integer
+)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT format(
+ '%s · %s/%s · %s',
+ core._payroll_money_label(p_pay_amount),
+ p_installment_n,
+ GREATEST(COALESCE(p_installments_n, 1), 1),
+ CASE
+ WHEN COALESCE(p_commission_pct, 0) > 0 THEN
+ format('préstamo %s + comisión %s%%',
+ core._payroll_money_label(p_delivered),
+ trim(to_char(p_commission_pct, 'FM999990.##')))
+ ELSE format('préstamo %s', core._payroll_money_label(p_delivered))
+ END
+ );
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_seed_defaults(p_tenant_id integer)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+BEGIN
+ INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max)
+ VALUES (p_tenant_id, true, 10, 500)
+ ON CONFLICT (tenant_id) DO NOTHING;
+
+ IF NOT EXISTS (SELECT 1 FROM destajo_units WHERE tenant_id = p_tenant_id) THEN
+ INSERT INTO destajo_units (tenant_id, code, label) VALUES
+ (p_tenant_id, 'm', 'Metro (m)'),
+ (p_tenant_id, 'm2', 'Metro cuadrado (m²)'),
+ (p_tenant_id, 'm3', 'Metro cúbico (m³)'),
+ (p_tenant_id, 'ml', 'Metro lineal (ml)'),
+ (p_tenant_id, 'pza', 'Pieza');
+ END IF;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_sheet_line(
+ p_sheet_id bigint,
+ p_worker_id bigint,
+ p_destajo_cut_line_id bigint
+)
+RETURNS bigint
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint;
+BEGIN
+ IF p_destajo_cut_line_id IS NOT NULL THEN
+ SELECT id INTO v_id
+ FROM payroll_week_lines
+ WHERE sheet_id = p_sheet_id
+ AND destajo_cut_line_id = p_destajo_cut_line_id;
+ ELSE
+ SELECT id INTO v_id
+ FROM payroll_week_lines
+ WHERE sheet_id = p_sheet_id
+ AND worker_id = p_worker_id
+ AND destajo_cut_line_id IS NULL;
+ END IF;
+
+ IF v_id IS NOT NULL THEN
+ RETURN v_id;
+ END IF;
+
+ INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id)
+ VALUES (p_sheet_id, p_worker_id, p_destajo_cut_line_id)
+ RETURNING id INTO v_id;
+
+ RETURN v_id;
+END;
+$$;
+
+--changeset panel:core-013b-payroll-recalc splitStatements:false
+CREATE OR REPLACE FUNCTION core._payroll_recalc_week(p_week_id bigint)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_week record;
+ v_sheet record;
+ v_worker record;
+ v_cut record;
+ v_line record;
+ v_loan record;
+ v_line_id bigint;
+ v_att_n integer;
+ v_wage numeric;
+ v_gross numeric;
+ v_payable_qty numeric;
+ v_required_gross numeric;
+ v_keep bigint[];
+ v_stale record;
+ v_tot numeric;
+ v_worker_id bigint;
+ v_lines record;
+ v_gross_sum numeric;
+ v_remaining numeric;
+ v_placed boolean;
+ v_n integer;
+ v_is_last boolean;
+ v_want numeric;
+ v_take numeric;
+ v_label text;
+ v_host_id bigint;
+ v_host_gross numeric;
+ v_host_discounts numeric;
+BEGIN
+ SELECT * INTO v_week FROM payroll_weeks WHERE id = p_week_id;
+ IF NOT FOUND OR v_week.status = 'paid' THEN
+ RETURN;
+ END IF;
+
+ UPDATE payroll_week_lines
+ SET loan_id = NULL, loan_discount = 0, loan_label = NULL
+ WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = p_week_id);
+
+ FOR v_sheet IN SELECT * FROM payroll_sheets WHERE week_id = p_week_id LOOP
+ IF v_sheet.kind = 'obra' AND v_sheet.project_id IS NOT NULL THEN
+ FOR v_worker IN
+ SELECT w.id, w.daily_wage
+ FROM workers w
+ WHERE w.status = 'activo'
+ AND w.pipeline_status != 'baja'
+ AND (
+ EXISTS (
+ SELECT 1 FROM assignments a
+ WHERE a.worker_id = w.id AND a.project_id = v_sheet.project_id
+ AND a.active = true AND w.work_type = 'N'
+ )
+ OR EXISTS (
+ SELECT 1 FROM attendance att
+ WHERE att.worker_id = w.id AND att.project_id = v_sheet.project_id
+ AND att.work_date BETWEEN v_week.week_start AND v_week.week_end
+ )
+ OR EXISTS (
+ SELECT 1 FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE l.worker_id = w.id AND s.project_id = v_sheet.project_id
+ AND s.kind = 'obra' AND s.week_id = p_week_id
+ )
+ )
+ ORDER BY w.last_name_p, w.first_name
+ LOOP
+ v_line_id := core._payroll_sheet_line(v_sheet.id, v_worker.id, NULL);
+ SELECT COUNT(*)::integer INTO v_att_n
+ FROM attendance
+ WHERE worker_id = v_worker.id
+ AND project_id = v_sheet.project_id
+ AND work_date BETWEEN v_week.week_start AND v_week.week_end
+ AND present = true;
+ SELECT daily_wage INTO v_wage FROM payroll_week_lines WHERE id = v_line_id;
+ IF v_week.status = 'assembled' AND COALESCE(v_wage, 0) > 0 THEN
+ NULL;
+ ELSE
+ v_wage := v_worker.daily_wage;
+ END IF;
+ v_gross := core.round_money(v_att_n * v_wage);
+ UPDATE payroll_week_lines
+ SET days = v_att_n,
+ daily_wage = v_wage,
+ amount = v_gross,
+ gross = v_gross,
+ payable_net = v_gross
+ WHERE id = v_line_id;
+ END LOOP;
+ END IF;
+
+ IF v_sheet.kind = 'destajo' AND v_sheet.project_id IS NOT NULL THEN
+ SELECT id INTO v_line_id FROM destajo_periods WHERE week_id = p_week_id;
+ IF v_line_id IS NULL THEN
+ CONTINUE;
+ END IF;
+ v_keep := ARRAY[]::bigint[];
+ FOR v_cut IN
+ SELECT c.id, c.qty_planned, c.qty_actual, c.qty_extra,
+ j.worker_id, j.unit_price, j.unit_code, j.concepto
+ FROM destajo_cut_lines c
+ JOIN destajo_jobs j ON j.id = c.job_id
+ WHERE c.period_id = v_line_id
+ AND j.project_id = v_sheet.project_id
+ LOOP
+ v_line_id := core._payroll_sheet_line(v_sheet.id, v_cut.worker_id, v_cut.id);
+ v_keep := array_append(v_keep, v_line_id);
+ IF v_week.status = 'draft'
+ AND v_cut.qty_actual = 0 AND v_cut.qty_extra = 0 THEN
+ v_payable_qty := core.round_money(v_cut.qty_planned);
+ ELSE
+ v_payable_qty := core.round_money(v_cut.qty_actual + v_cut.qty_extra);
+ END IF;
+ v_gross := core.round_money(v_payable_qty * v_cut.unit_price);
+ v_required_gross := core.round_money(v_cut.qty_planned * v_cut.unit_price);
+ UPDATE payroll_week_lines
+ SET destajo_cut_line_id = v_cut.id,
+ qty_planned = v_cut.qty_planned,
+ qty_actual = v_cut.qty_actual,
+ qty_extra = v_cut.qty_extra,
+ unit_price = v_cut.unit_price,
+ unit_code = v_cut.unit_code,
+ concepto = v_cut.concepto,
+ amount = v_required_gross,
+ gross = v_gross,
+ payable_net = v_gross
+ WHERE id = v_line_id;
+ END LOOP;
+ FOR v_stale IN
+ SELECT id FROM payroll_week_lines WHERE sheet_id = v_sheet.id
+ LOOP
+ IF NOT (v_stale.id = ANY (v_keep)) THEN
+ DELETE FROM payroll_week_lines WHERE id = v_stale.id;
+ END IF;
+ END LOOP;
+ END IF;
+
+ IF v_sheet.kind = 'admin' THEN
+ FOR v_line IN SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id = v_sheet.id LOOP
+ v_gross := core.round_money(v_line.amount);
+ UPDATE payroll_week_lines
+ SET gross = v_gross,
+ payable_net = core.round_money(v_gross - v_line.discounts)
+ WHERE id = v_line.id;
+ END LOOP;
+ END IF;
+ END LOOP;
+
+ FOR v_worker_id IN
+ SELECT DISTINCT l.worker_id
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id
+ LOOP
+ SELECT core.round_money(COALESCE(SUM(l.gross - l.discounts), 0))
+ INTO v_gross_sum
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id
+ AND l.worker_id = v_worker_id;
+
+ v_remaining := GREATEST(0, v_gross_sum);
+ v_placed := false;
+
+ FOR v_loan IN
+ SELECT *
+ FROM loans
+ WHERE worker_id = v_worker_id
+ AND balance > 0
+ AND first_due IS NOT NULL
+ AND first_due <= v_week.week_end
+ ORDER BY first_due, id
+ LOOP
+ EXIT WHEN v_remaining <= 0;
+ SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id;
+ v_is_last := v_n >= GREATEST(COALESCE(v_loan.installments_n, 1), 1);
+ IF v_is_last THEN
+ v_want := v_loan.balance;
+ ELSE
+ v_want := LEAST(COALESCE(NULLIF(v_loan.weekly_payment, 0), v_loan.balance), v_loan.balance);
+ END IF;
+ v_take := core.round_money(LEAST(v_want, v_remaining));
+ IF v_take <= 0 THEN
+ CONTINUE;
+ END IF;
+ v_remaining := core.round_money(v_remaining - v_take);
+ IF NOT v_placed THEN
+ SELECT l.id, l.gross, l.discounts
+ INTO v_host_id, v_host_gross, v_host_discounts
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id
+ AND l.worker_id = v_worker_id
+ ORDER BY l.gross DESC, l.id
+ LIMIT 1;
+ v_label := core._payroll_loan_condition_label(
+ v_loan.delivered, v_loan.commission_pct, v_take, v_n, v_loan.installments_n
+ );
+ UPDATE payroll_week_lines
+ SET loan_id = v_loan.id,
+ loan_discount = v_take,
+ loan_label = v_label,
+ payable_net = core.round_money(v_host_gross - v_host_discounts - v_take)
+ WHERE id = v_host_id;
+ v_placed := true;
+ END IF;
+ EXIT;
+ END LOOP;
+
+ IF NOT v_placed THEN
+ UPDATE payroll_week_lines l
+ SET payable_net = core.round_money(l.gross - l.discounts)
+ FROM payroll_sheets s
+ WHERE s.id = l.sheet_id
+ AND s.week_id = p_week_id
+ AND l.worker_id = v_worker_id;
+ ELSE
+ UPDATE payroll_week_lines l
+ SET payable_net = core.round_money(l.gross - l.discounts)
+ FROM payroll_sheets s
+ WHERE s.id = l.sheet_id
+ AND s.week_id = p_week_id
+ AND l.worker_id = v_worker_id
+ AND l.id <> v_host_id;
+ END IF;
+ END LOOP;
+
+ SELECT COALESCE(SUM(l.payable_net), 0) INTO v_tot
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = p_week_id;
+
+ UPDATE payroll_weeks
+ SET payable_net = core.round_money(v_tot)
+ WHERE id = p_week_id;
+
+ IF v_week.status = 'draft' THEN
+ UPDATE payroll_weeks
+ SET required_net = core.round_money(v_tot)
+ WHERE id = p_week_id;
+ UPDATE payroll_week_lines
+ SET required_net = payable_net
+ WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = p_week_id);
+ END IF;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_ensure_cut_for_open_jobs(p_week_id bigint)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_period record;
+ v_job record;
+BEGIN
+ SELECT id, tenant_id INTO v_period FROM destajo_periods WHERE week_id = p_week_id;
+ IF NOT FOUND THEN
+ RETURN;
+ END IF;
+ FOR v_job IN
+ SELECT id FROM destajo_jobs WHERE tenant_id = v_period.tenant_id AND status = 'open'
+ LOOP
+ INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
+ VALUES (v_period.id, v_job.id, 0, 0, 0)
+ ON CONFLICT (period_id, job_id) DO NOTHING;
+ END LOOP;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_ensure_week(p_tenant_id integer, p_week_start date)
+RETURNS bigint
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_week_start date;
+ v_week_end date;
+ v_week_id bigint;
+ v_bounds record;
+ v_period_id bigint;
+ v_proj record;
+BEGIN
+ PERFORM core._payroll_seed_defaults(p_tenant_id);
+ SELECT wc.week_start, wc.week_end
+ INTO v_week_start, v_week_end
+ FROM core._payroll_week_containing(p_week_start) wc;
+
+ SELECT id INTO v_week_id
+ FROM payroll_weeks
+ WHERE tenant_id = p_tenant_id AND week_start = v_week_start;
+
+ IF v_week_id IS NULL THEN
+ INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
+ VALUES (v_week_start, v_week_end, 'draft', p_tenant_id)
+ RETURNING id INTO v_week_id;
+ END IF;
+
+ SELECT * INTO v_bounds FROM core._payroll_destajo_period_bounds(v_week_start);
+ SELECT id INTO v_period_id
+ FROM destajo_periods
+ WHERE tenant_id = p_tenant_id AND period_end = v_bounds.period_end;
+ IF v_period_id IS NULL THEN
+ INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
+ VALUES (v_bounds.period_start, v_bounds.period_end, v_week_id, p_tenant_id);
+ END IF;
+
+ FOR v_proj IN
+ SELECT id FROM projects
+ WHERE status IN ('activo', 'pausado')
+ AND (p_tenant_id = 0 OR tenant_id = p_tenant_id OR tenant_id IS NULL)
+ ORDER BY name
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1 FROM payroll_sheets
+ WHERE week_id = v_week_id AND kind = 'obra' AND COALESCE(project_id, 0) = v_proj.id
+ ) THEN
+ INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'obra', v_proj.id);
+ END IF;
+ IF NOT EXISTS (
+ SELECT 1 FROM payroll_sheets
+ WHERE week_id = v_week_id AND kind = 'destajo' AND COALESCE(project_id, 0) = v_proj.id
+ ) THEN
+ INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'destajo', v_proj.id);
+ END IF;
+ END LOOP;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM payroll_sheets
+ WHERE week_id = v_week_id AND kind = 'admin' AND project_id IS NULL
+ ) THEN
+ INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (v_week_id, 'admin', NULL);
+ END IF;
+
+ RETURN v_week_id;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_first_due_for_loan(
+ p_delivered numeric,
+ p_small_max numeric,
+ p_plan text,
+ p_grant_iso date,
+ p_week_assembled boolean,
+ p_weekday_mon0 integer
+)
+RETURNS date
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+BEGIN
+ IF p_delivered <= p_small_max AND p_plan = 'single' THEN
+ RETURN core._payroll_next_saturday_after_week(p_grant_iso);
+ END IF;
+ IF p_week_assembled AND p_weekday_mon0 >= 4 THEN
+ RETURN core._payroll_next_saturday_after_week(p_grant_iso);
+ END IF;
+ RETURN core._payroll_this_saturday(p_grant_iso);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._payroll_prefill_thu_sat(
+ p_project_id bigint,
+ p_week_start date,
+ p_worker_id bigint
+)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_offset integer;
+ v_day date;
+ v_other record;
+ v_existing record;
+BEGIN
+ FOR v_offset IN SELECT unnest(ARRAY[3, 4, 5]) LOOP
+ v_day := core._payroll_add_days(p_week_start, v_offset);
+ SELECT a.project_id, p.name AS project_name
+ INTO v_other
+ FROM attendance a
+ JOIN projects p ON p.id = a.project_id
+ WHERE a.worker_id = p_worker_id
+ AND a.work_date = v_day
+ AND a.present = true
+ AND a.project_id <> p_project_id
+ LIMIT 1;
+ IF FOUND THEN
+ CONTINUE;
+ END IF;
+ SELECT id, present INTO v_existing
+ FROM attendance
+ WHERE worker_id = p_worker_id
+ AND project_id = p_project_id
+ AND work_date = v_day;
+ IF FOUND THEN
+ CONTINUE;
+ END IF;
+ INSERT INTO attendance (worker_id, project_id, work_date, present)
+ VALUES (p_worker_id, p_project_id, v_day, true);
+ END LOOP;
+END;
+$$;
+
+--changeset panel:core-013c-fn-payroll-settings splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_payroll_settings_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_settings jsonb;
+ v_units jsonb;
+BEGIN
+ PERFORM core._payroll_seed_defaults(v_tid);
+ SELECT to_jsonb(s) INTO v_settings FROM payroll_settings s WHERE tenant_id = v_tid;
+ SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.id), '[]'::jsonb)
+ INTO v_units
+ FROM destajo_units u
+ WHERE u.tenant_id = v_tid;
+ RETURN core.rpc_ok(
+ jsonb_build_object('settings', v_settings, 'units', v_units),
+ format('Configuración de nómina del tenant %s cargada', v_tid),
+ jsonb_build_object('fn', 'fn_payroll_settings_get', 'tenant_id', v_tid)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_settings_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_settings_save(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_cur payroll_settings%ROWTYPE;
+ v_enabled boolean;
+ v_pct numeric;
+ v_small_max numeric;
+ v_settings jsonb;
+BEGIN
+ PERFORM core._payroll_seed_defaults(v_tid);
+ SELECT * INTO v_cur FROM payroll_settings WHERE tenant_id = v_tid;
+ v_enabled := COALESCE((payload->>'loan_commission_enabled')::boolean, v_cur.loan_commission_enabled);
+ v_pct := COALESCE((payload->>'loan_commission_pct')::numeric, v_cur.loan_commission_pct, 0);
+ v_small_max := COALESCE((payload->>'loan_small_max')::numeric, v_cur.loan_small_max, 0);
+ UPDATE payroll_settings
+ SET loan_commission_enabled = v_enabled,
+ loan_commission_pct = COALESCE(v_pct, 0),
+ loan_small_max = COALESCE(v_small_max, 0)
+ WHERE tenant_id = v_tid;
+ SELECT to_jsonb(s) INTO v_settings FROM payroll_settings s WHERE tenant_id = v_tid;
+ RETURN core.rpc_ok(
+ jsonb_build_object('settings', v_settings),
+ format('Configuración de nómina del tenant %s guardada', v_tid),
+ jsonb_build_object('fn', 'fn_payroll_settings_save', 'tenant_id', v_tid)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_settings_save', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-013d-fn-payroll-units splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_payroll_unit_list(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_units jsonb;
+BEGIN
+ PERFORM core._payroll_seed_defaults(v_tid);
+ SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.id), '[]'::jsonb)
+ INTO v_units
+ FROM destajo_units u
+ WHERE u.tenant_id = v_tid;
+ RETURN core.rpc_ok(
+ jsonb_build_object('units', v_units),
+ format('Unidades de destajo del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_units)),
+ jsonb_build_object('fn', 'fn_payroll_unit_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_units))
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_unit_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_unit_add(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_code text := lower(regexp_replace(btrim(COALESCE(payload->>'code', '')), '\s+', '', 'g'));
+ v_label text := COALESCE(NULLIF(btrim(payload->>'label'), ''), v_code);
+ v_id bigint;
+BEGIN
+ IF v_code = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_unit_add: unidad requerida',
+ jsonb_build_object('fn', 'fn_payroll_unit_add', 'field', 'code')
+ );
+ END IF;
+ PERFORM core._payroll_seed_defaults(v_tid);
+ INSERT INTO destajo_units (tenant_id, code, label)
+ VALUES (v_tid, v_code, v_label)
+ RETURNING id INTO v_id;
+ RETURN core.rpc_created(
+ jsonb_build_object('id', v_id, 'code', v_code, 'label', v_label),
+ format('Unidad de destajo %s (%s) agregada', v_label, v_code),
+ jsonb_build_object('fn', 'fn_payroll_unit_add', 'tenant_id', v_tid, 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_unit_add', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-013e-fn-attendance splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_attendance_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_from date := NULLIF(btrim(payload->>'from'), '')::date;
+ v_to date := NULLIF(btrim(payload->>'to'), '')::date;
+ v_rows jsonb;
+BEGIN
+ IF v_project_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_attendance_list: project_id es obligatorio',
+ jsonb_build_object('fn', 'fn_attendance_list', 'field', 'project_id')
+ );
+ END IF;
+ IF v_from IS NULL OR v_to IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_attendance_list: from y to son obligatorios (fechas ISO)',
+ jsonb_build_object('fn', 'fn_attendance_list', 'fields', jsonb_build_array('from', 'to'))
+ );
+ END IF;
+ SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.work_date, x.last_name_p), '[]'::jsonb)
+ INTO v_rows
+ FROM (
+ 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 = v_project_id
+ AND a.work_date BETWEEN v_from AND v_to
+ ) x;
+ RETURN core.rpc_ok(
+ jsonb_build_object('attendance', v_rows),
+ format('Asistencia del proyecto %s: %s registro(s) entre %s y %s',
+ v_project_id, jsonb_array_length(v_rows), v_from, v_to),
+ jsonb_build_object('fn', 'fn_attendance_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_rows))
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_attendance_list', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_attendance_set(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_work_date date := NULLIF(btrim(payload->>'work_date'), '')::date;
+ v_present boolean := COALESCE((payload->>'present')::boolean, false);
+ v_other record;
+ v_week_start date;
+ v_week record;
+BEGIN
+ IF v_project_id IS NULL OR v_worker_id IS NULL OR v_work_date IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_attendance_set: project_id, worker_id y work_date son obligatorios',
+ jsonb_build_object('fn', 'fn_attendance_set')
+ );
+ END IF;
+ IF v_present THEN
+ SELECT a.project_id, p.name AS project_name
+ INTO v_other
+ FROM attendance a
+ JOIN projects p ON p.id = a.project_id
+ WHERE a.worker_id = v_worker_id
+ AND a.work_date = v_work_date
+ AND a.present = true
+ AND a.project_id <> v_project_id
+ LIMIT 1;
+ IF FOUND THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ format('Ese día ya está marcado en %s', v_other.project_name),
+ jsonb_build_object(
+ 'fn', 'fn_attendance_set',
+ 'worker_id', v_worker_id,
+ 'work_date', v_work_date,
+ 'other_project_id', v_other.project_id
+ ),
+ jsonb_build_object(
+ 'other', jsonb_build_object(
+ 'project_id', v_other.project_id,
+ 'project_name', v_other.project_name
+ )
+ )
+ );
+ END IF;
+ END IF;
+ INSERT INTO attendance (worker_id, project_id, work_date, present)
+ VALUES (v_worker_id, v_project_id, v_work_date, v_present)
+ ON CONFLICT (worker_id, project_id, work_date)
+ DO UPDATE SET present = EXCLUDED.present;
+
+ SELECT week_start INTO v_week_start
+ FROM core._payroll_week_containing(v_work_date);
+ SELECT id, status INTO v_week
+ FROM payroll_weeks
+ WHERE tenant_id = v_tid AND week_start = v_week_start;
+ IF FOUND AND v_week.status <> 'paid' THEN
+ PERFORM core._payroll_recalc_week(v_week.id);
+ END IF;
+ RETURN core.rpc_ok(
+ jsonb_build_object('ok', true),
+ format('Asistencia registrada para trabajador %s el %s', v_worker_id, v_work_date),
+ jsonb_build_object('fn', 'fn_attendance_set', 'worker_id', v_worker_id, 'work_date', v_work_date)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_attendance_set', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-013f-fn-loan-create splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_loan_create(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_delivered numeric;
+ v_settings payroll_settings%ROWTYPE;
+ v_enabled boolean;
+ v_pct numeric;
+ v_comm numeric;
+ v_balance numeric;
+ v_small boolean;
+ v_plan text;
+ v_installments integer;
+ v_weekly numeric;
+ v_grant_iso date;
+ v_week record;
+ v_assembled boolean;
+ v_first_due date;
+ v_loan_id bigint;
+BEGIN
+ IF v_worker_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_loan_create: worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_loan_create', 'field', 'worker_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_loan_create: persona id=%s no encontrada', v_worker_id),
+ jsonb_build_object('fn', 'fn_loan_create', 'worker_id', v_worker_id)
+ );
+ END IF;
+ PERFORM core._payroll_seed_defaults(v_tid);
+ SELECT * INTO v_settings FROM payroll_settings WHERE tenant_id = v_tid;
+ v_delivered := core.round_money(COALESCE(
+ (payload->>'delivered')::numeric,
+ (payload->>'amount')::numeric,
+ 0
+ ));
+ IF v_delivered <= 0 THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_loan_create: monto entregado requerido y mayor a cero',
+ jsonb_build_object('fn', 'fn_loan_create', 'field', 'delivered')
+ );
+ END IF;
+ v_enabled := COALESCE(
+ (payload->>'commission_enabled')::boolean,
+ v_settings.loan_commission_enabled
+ );
+ IF v_enabled THEN
+ v_pct := COALESCE((payload->>'commission_pct')::numeric, v_settings.loan_commission_pct, 0);
+ ELSE
+ v_pct := 0;
+ END IF;
+ v_comm := core._payroll_commission_amount(v_delivered, v_pct);
+ v_balance := core.round_money(v_delivered + v_comm);
+ v_small := v_delivered <= v_settings.loan_small_max;
+ v_plan := COALESCE(NULLIF(btrim(payload->>'plan'), ''), 'single');
+ v_installments := GREATEST(1, floor(COALESCE((payload->>'installments_n')::numeric, 1))::integer);
+ IF v_small THEN
+ v_plan := 'single';
+ v_installments := 1;
+ END IF;
+ IF v_plan = 'installments' AND v_installments < 2 THEN
+ v_installments := 2;
+ END IF;
+ IF v_plan = 'single' THEN
+ v_installments := 1;
+ END IF;
+ v_weekly := core.round_money(v_balance / v_installments);
+ v_grant_iso := COALESCE(
+ NULLIF(btrim(payload->>'grant_iso'), '')::date,
+ core._payroll_tenant_today(v_tid)
+ );
+ SELECT id, status INTO v_week
+ FROM payroll_weeks
+ WHERE tenant_id = v_tid
+ AND week_start = (SELECT week_start FROM core._payroll_week_containing(v_grant_iso));
+ v_assembled := FOUND AND v_week.status IN ('assembled', 'paid');
+ v_first_due := core._payroll_first_due_for_loan(
+ v_delivered,
+ v_settings.loan_small_max,
+ v_plan,
+ v_grant_iso,
+ v_assembled,
+ core._payroll_weekday_mon0(v_grant_iso)
+ );
+ INSERT INTO loans (
+ worker_id, amount, delivered, balance, weekly_payment, note,
+ commission_pct, commission_amount, plan, installments_n, first_due
+ ) VALUES (
+ v_worker_id, v_delivered, v_delivered, v_balance, v_weekly,
+ NULLIF(btrim(payload->>'note'), ''),
+ v_pct, v_comm, v_plan, v_installments, v_first_due
+ )
+ RETURNING id INTO v_loan_id;
+ IF FOUND AND v_week.id IS NOT NULL AND v_week.status <> 'paid' THEN
+ PERFORM core._payroll_recalc_week(v_week.id);
+ END IF;
+ RETURN core.rpc_created(
+ jsonb_build_object('id', v_loan_id),
+ format('Préstamo de %s registrado para trabajador %s', core._payroll_money_label(v_delivered), v_worker_id),
+ jsonb_build_object('fn', 'fn_loan_create', 'id', v_loan_id, 'worker_id', v_worker_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_loan_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-013g-fn-payroll-week splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_payroll_week_ensure(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_week_start date := NULLIF(btrim(payload->>'week_start'), '')::date;
+ v_week_id bigint;
+BEGIN
+ IF v_week_start IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_week_ensure: week_start es obligatorio (fecha ISO)',
+ jsonb_build_object('fn', 'fn_payroll_week_ensure', 'field', 'week_start')
+ );
+ END IF;
+ v_week_id := core._payroll_ensure_week(v_tid, v_week_start);
+ RETURN core.rpc_ok(
+ jsonb_build_object('week_id', v_week_id),
+ format('Semana de nómina asegurada (id=%s) para inicio %s', v_week_id, v_week_start),
+ jsonb_build_object('fn', 'fn_payroll_week_ensure', 'week_id', v_week_id, 'tenant_id', v_tid)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_week_ensure', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_week_recalc(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+BEGIN
+ IF v_week_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_week_recalc: week_id es obligatorio',
+ jsonb_build_object('fn', 'fn_payroll_week_recalc', 'field', 'week_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM payroll_weeks WHERE id = v_week_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_payroll_week_recalc: semana id=%s no encontrada', v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_week_recalc', 'week_id', v_week_id)
+ );
+ END IF;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object('week_id', v_week_id),
+ format('Semana de nómina %s recalculada', v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_week_recalc', 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_week_recalc', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_week_assemble(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_now_iso date := COALESCE(
+ NULLIF(btrim(payload->>'now_iso'), '')::date,
+ core._payroll_tenant_today(v_tid)
+ );
+ v_week record;
+ v_sheet record;
+ v_worker record;
+ v_assigned boolean;
+ v_has_line boolean;
+ v_period_id bigint;
+ v_payable numeric;
+BEGIN
+ IF v_week_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_week_assemble: week_id es obligatorio',
+ jsonb_build_object('fn', 'fn_payroll_week_assemble', 'field', 'week_id')
+ );
+ END IF;
+ SELECT * INTO v_week FROM payroll_weeks WHERE id = v_week_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_payroll_week_assemble: semana id=%s no encontrada', v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_week_assemble', 'week_id', v_week_id)
+ );
+ END IF;
+ IF v_week.status = 'paid' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_week_assemble: la semana ya está pagada',
+ jsonb_build_object('fn', 'fn_payroll_week_assemble', 'week_id', v_week_id, 'status', v_week.status)
+ );
+ END IF;
+ IF NOT core._payroll_can_assemble_week(v_week.week_start, v_now_iso) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_week_assemble: la nómina se arma el jueves o después; aún no es jueves de esta semana',
+ jsonb_build_object('fn', 'fn_payroll_week_assemble', 'week_id', v_week_id, 'today', v_now_iso)
+ );
+ END IF;
+ FOR v_sheet IN
+ SELECT * FROM payroll_sheets WHERE week_id = v_week_id AND kind = 'obra'
+ LOOP
+ IF v_sheet.project_id IS NULL THEN
+ CONTINUE;
+ END IF;
+ FOR v_worker IN
+ SELECT w.id, w.work_type
+ FROM workers w
+ WHERE w.status = 'activo'
+ AND w.pipeline_status != 'baja'
+ AND (
+ EXISTS (
+ SELECT 1 FROM assignments a
+ WHERE a.worker_id = w.id AND a.project_id = v_sheet.project_id
+ AND a.active = true AND w.work_type = 'N'
+ )
+ OR EXISTS (
+ SELECT 1 FROM attendance att
+ WHERE att.worker_id = w.id AND att.project_id = v_sheet.project_id
+ AND att.work_date BETWEEN v_week.week_start AND core._payroll_add_days(v_week.week_start, 5)
+ )
+ OR EXISTS (
+ SELECT 1 FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE l.worker_id = w.id AND s.project_id = v_sheet.project_id
+ AND s.kind = 'obra' AND s.week_id = v_week_id
+ )
+ )
+ ORDER BY w.last_name_p, w.first_name
+ LOOP
+ SELECT EXISTS (
+ SELECT 1 FROM assignments
+ WHERE worker_id = v_worker.id AND project_id = v_sheet.project_id AND active = true
+ ) INTO v_assigned;
+ IF NOT v_assigned THEN
+ CONTINUE;
+ END IF;
+ IF v_worker.work_type <> 'N' THEN
+ SELECT EXISTS (
+ SELECT 1 FROM payroll_week_lines
+ WHERE sheet_id = v_sheet.id AND worker_id = v_worker.id
+ ) INTO v_has_line;
+ IF NOT v_has_line THEN
+ CONTINUE;
+ END IF;
+ END IF;
+ PERFORM core._payroll_prefill_thu_sat(v_sheet.project_id, v_week.week_start, v_worker.id);
+ END LOOP;
+ END LOOP;
+ SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
+ IF v_period_id IS NOT NULL THEN
+ UPDATE destajo_cut_lines
+ SET qty_actual = qty_planned
+ WHERE period_id = v_period_id
+ AND qty_actual = 0
+ AND qty_planned > 0;
+ END IF;
+ UPDATE payroll_weeks
+ SET status = 'assembled', assembled_at = now()
+ WHERE id = v_week_id;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ SELECT payable_net INTO v_payable FROM payroll_weeks WHERE id = v_week_id;
+ UPDATE payroll_weeks SET required_net = v_payable WHERE id = v_week_id;
+ UPDATE payroll_week_lines
+ SET required_net = payable_net
+ WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id = v_week_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object('week_id', v_week_id, 'status', 'assembled'),
+ format('Semana de nómina %s armada; neto a pagar %s', v_week_id, core._payroll_money_label(v_payable)),
+ jsonb_build_object('fn', 'fn_payroll_week_assemble', 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_week_assemble', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_week_pay(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_now_iso date := COALESCE(
+ NULLIF(btrim(payload->>'now_iso'), '')::date,
+ core._payroll_tenant_today(v_tid)
+ );
+ v_week record;
+ v_line record;
+ v_loan record;
+ v_take numeric;
+ v_n integer;
+ v_period_id bigint;
+ v_job record;
+ v_paid numeric;
+ v_env jsonb;
+BEGIN
+ IF v_week_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_week_pay: week_id es obligatorio',
+ jsonb_build_object('fn', 'fn_payroll_week_pay', 'field', 'week_id')
+ );
+ END IF;
+ SELECT * INTO v_week FROM payroll_weeks WHERE id = v_week_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_payroll_week_pay: semana id=%s no encontrada', v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
+ );
+ END IF;
+ IF v_week.status = 'paid' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_week_pay: la semana ya está pagada',
+ jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
+ );
+ END IF;
+ IF v_week.status <> 'assembled' THEN
+ v_env := core.fn_payroll_week_assemble(jsonb_build_object(
+ 'week_id', v_week_id,
+ 'tenant_id', v_tid,
+ 'now_iso', v_now_iso::text
+ ));
+ IF NOT COALESCE((v_env->>'ok')::boolean, false) THEN
+ RETURN v_env;
+ END IF;
+ END IF;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ FOR v_line IN
+ SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
+ FROM payroll_week_lines l
+ JOIN payroll_sheets s ON s.id = l.sheet_id
+ WHERE s.week_id = v_week_id
+ AND l.loan_id IS NOT NULL
+ AND l.loan_discount > 0
+ LOOP
+ SELECT * INTO v_loan FROM loans WHERE id = v_line.loan_id;
+ IF NOT FOUND OR v_loan.balance <= 0 THEN
+ CONTINUE;
+ END IF;
+ v_take := core.round_money(LEAST(v_line.loan_discount, v_loan.balance));
+ IF v_take <= 0 THEN
+ CONTINUE;
+ END IF;
+ SELECT COUNT(*)::integer + 1 INTO v_n FROM loan_payments WHERE loan_id = v_loan.id;
+ INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label)
+ VALUES (v_loan.id, v_week_id, v_take, v_n, v_line.loan_label);
+ UPDATE loans
+ SET balance = core.round_money(balance - v_take)
+ WHERE id = v_loan.id;
+ END LOOP;
+ SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
+ IF v_period_id IS NOT NULL THEN
+ FOR v_job IN SELECT id, qty_total_estimated FROM destajo_jobs LOOP
+ SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) INTO v_paid
+ FROM destajo_cut_lines c
+ JOIN destajo_periods p ON p.id = c.period_id
+ JOIN payroll_weeks w ON w.id = p.week_id
+ WHERE c.job_id = v_job.id
+ AND (w.status = 'paid' OR w.id = v_week_id);
+ IF v_paid >= v_job.qty_total_estimated THEN
+ UPDATE destajo_jobs SET status = 'done' WHERE id = v_job.id;
+ END IF;
+ END LOOP;
+ END IF;
+ UPDATE payroll_weeks SET status = 'paid', paid_at = now() WHERE id = v_week_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('week_id', v_week_id, 'status', 'paid'),
+ format('Semana de nómina %s marcada como pagada', v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_week_pay', 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_week_pay', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_week_bundle(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_week_start date := COALESCE(
+ NULLIF(btrim(payload->>'week_start'), '')::date,
+ (SELECT week_start FROM core._payroll_week_containing(core._payroll_tenant_today(v_tid)))
+ );
+ v_week_id bigint;
+ v_week jsonb;
+ v_sheets jsonb;
+BEGIN
+ v_week_id := core._payroll_ensure_week(v_tid, v_week_start);
+ PERFORM core._payroll_ensure_cut_for_open_jobs(v_week_id);
+ PERFORM core._payroll_recalc_week(v_week_id);
+ SELECT to_jsonb(w) INTO v_week FROM payroll_weeks w WHERE id = v_week_id;
+ SELECT COALESCE(jsonb_agg(
+ to_jsonb(s)::jsonb || jsonb_build_object(
+ 'project_name', p.name,
+ 'project_code', p.code,
+ 'lines', COALESCE((
+ SELECT jsonb_agg(row_to_json(lx)::jsonb ORDER BY lx.last_name_p, lx.first_name, lx.id)
+ FROM (
+ SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m,
+ w.position, w.work_type
+ FROM payroll_week_lines l
+ JOIN workers w ON w.id = l.worker_id
+ WHERE l.sheet_id = s.id
+ ) lx
+ ), '[]'::jsonb)
+ )
+ ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name
+ ), '[]'::jsonb)
+ INTO v_sheets
+ FROM payroll_sheets s
+ LEFT JOIN projects p ON p.id = s.project_id
+ WHERE s.week_id = v_week_id;
+ RETURN core.rpc_ok(
+ jsonb_build_object('week', v_week, 'sheets', v_sheets),
+ format('Paquete de semana de nómina %s cargado (inicio %s)', v_week_id, v_week->>'week_start'),
+ jsonb_build_object('fn', 'fn_payroll_week_bundle', 'week_id', v_week_id, 'tenant_id', v_tid)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_week_bundle', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-013h-fn-destajo splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_destajo_job_create(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._payroll_tenant_id(payload);
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_concepto text := btrim(COALESCE(payload->>'concepto', ''));
+ v_unit_code text := btrim(COALESCE(payload->>'unit_code', ''));
+ v_qty_total numeric := COALESCE((payload->>'qty_total_estimated')::numeric, 0);
+ v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0);
+ v_qty_planned numeric := COALESCE((payload->>'qty_planned')::numeric, 0);
+ v_qty_extra numeric := COALESCE((payload->>'qty_extra')::numeric, 0);
+ v_week_status text;
+ v_period_id bigint;
+ v_job_id bigint;
+BEGIN
+ IF v_week_id IS NULL OR v_project_id IS NULL OR v_worker_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_destajo_job_create: week_id, project_id y worker_id son obligatorios',
+ jsonb_build_object('fn', 'fn_destajo_job_create')
+ );
+ END IF;
+ SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
+ IF NOT FOUND OR v_week_status = 'paid' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_destajo_job_create: no se puede editar una semana pagada o inexistente',
+ jsonb_build_object('fn', 'fn_destajo_job_create', 'week_id', v_week_id)
+ );
+ END IF;
+ IF v_concepto = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_destajo_job_create: concepto requerido',
+ jsonb_build_object('fn', 'fn_destajo_job_create', 'field', 'concepto')
+ );
+ END IF;
+ INSERT INTO destajo_jobs (
+ project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id
+ ) VALUES (
+ v_project_id, v_worker_id, v_concepto, v_unit_code, v_qty_total, v_unit_price, v_tid
+ )
+ RETURNING id INTO v_job_id;
+ SELECT id INTO v_period_id FROM destajo_periods WHERE week_id = v_week_id;
+ IF v_period_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_destajo_job_create: periodo destajo no encontrado para semana %s', v_week_id),
+ jsonb_build_object('fn', 'fn_destajo_job_create', 'week_id', v_week_id)
+ );
+ END IF;
+ INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
+ VALUES (v_period_id, v_job_id, v_qty_planned, 0, v_qty_extra);
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_created(
+ jsonb_build_object('id', v_job_id),
+ format('Trabajo de destajo %s creado para semana %s', v_job_id, v_week_id),
+ jsonb_build_object('fn', 'fn_destajo_job_create', 'id', v_job_id, 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_destajo_job_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_destajo_cut_patch(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_cut_id bigint := NULLIF(payload->>'cut_id', '')::bigint;
+ v_week_status text;
+ v_cut record;
+ v_planned numeric;
+ v_actual numeric;
+ v_extra numeric;
+BEGIN
+ IF v_week_id IS NULL OR v_cut_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_destajo_cut_patch: week_id y cut_id son obligatorios',
+ jsonb_build_object('fn', 'fn_destajo_cut_patch')
+ );
+ END IF;
+ SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
+ IF NOT FOUND OR v_week_status = 'paid' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_destajo_cut_patch: no se puede editar una semana pagada o inexistente',
+ jsonb_build_object('fn', 'fn_destajo_cut_patch', 'week_id', v_week_id)
+ );
+ END IF;
+ SELECT * INTO v_cut FROM destajo_cut_lines WHERE id = v_cut_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_destajo_cut_patch: partida id=%s no encontrada', v_cut_id),
+ jsonb_build_object('fn', 'fn_destajo_cut_patch', 'cut_id', v_cut_id)
+ );
+ END IF;
+ v_planned := COALESCE((payload->>'qty_planned')::numeric, v_cut.qty_planned);
+ v_actual := COALESCE((payload->>'qty_actual')::numeric, v_cut.qty_actual);
+ v_extra := COALESCE((payload->>'qty_extra')::numeric, v_cut.qty_extra);
+ UPDATE destajo_cut_lines
+ SET qty_planned = v_planned, qty_actual = v_actual, qty_extra = v_extra
+ WHERE id = v_cut_id;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object('cut_id', v_cut_id, 'week_id', v_week_id),
+ format('Partida de destajo %s actualizada', v_cut_id),
+ jsonb_build_object('fn', 'fn_destajo_cut_patch', 'cut_id', v_cut_id, 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_destajo_cut_patch', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-013i-fn-payroll-admin-jornal splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_payroll_admin_line_add(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_amount numeric := core.round_money(COALESCE((payload->>'amount')::numeric, 0));
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_week_status text;
+ v_sheet_id bigint;
+ v_line_id bigint;
+BEGIN
+ IF v_week_id IS NULL OR v_worker_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_admin_line_add: week_id y worker_id son obligatorios',
+ jsonb_build_object('fn', 'fn_payroll_admin_line_add')
+ );
+ END IF;
+ SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
+ IF NOT FOUND OR v_week_status = 'paid' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_admin_line_add: no se puede editar una semana pagada o inexistente',
+ jsonb_build_object('fn', 'fn_payroll_admin_line_add', 'week_id', v_week_id)
+ );
+ END IF;
+ SELECT id INTO v_sheet_id
+ FROM payroll_sheets
+ WHERE week_id = v_week_id AND kind = 'admin' AND project_id IS NULL;
+ IF v_sheet_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_payroll_admin_line_add: hoja administrativa no encontrada para semana %s', v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_admin_line_add', 'week_id', v_week_id)
+ );
+ END IF;
+ v_line_id := core._payroll_sheet_line(v_sheet_id, v_worker_id, NULL);
+ UPDATE payroll_week_lines
+ SET amount = v_amount,
+ project_id = v_project_id,
+ gross = v_amount,
+ payable_net = v_amount
+ WHERE id = v_line_id;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object('line_id', v_line_id, 'week_id', v_week_id),
+ format('Línea administrativa %s agregada por %s', v_line_id, core._payroll_money_label(v_amount)),
+ jsonb_build_object('fn', 'fn_payroll_admin_line_add', 'line_id', v_line_id, 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_admin_line_add', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_admin_line_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
+ v_amount numeric := core.round_money(COALESCE((payload->>'amount')::numeric, 0));
+ v_week_status text;
+BEGIN
+ IF v_week_id IS NULL OR v_line_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_admin_line_update: week_id y line_id son obligatorios',
+ jsonb_build_object('fn', 'fn_payroll_admin_line_update')
+ );
+ END IF;
+ SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
+ IF NOT FOUND OR v_week_status = 'paid' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_admin_line_update: no se puede editar una semana pagada o inexistente',
+ jsonb_build_object('fn', 'fn_payroll_admin_line_update', 'week_id', v_week_id)
+ );
+ END IF;
+ UPDATE payroll_week_lines SET amount = v_amount WHERE id = v_line_id;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object('line_id', v_line_id, 'amount', v_amount),
+ format('Línea administrativa %s actualizada a %s', v_line_id, core._payroll_money_label(v_amount)),
+ jsonb_build_object('fn', 'fn_payroll_admin_line_update', 'line_id', v_line_id, 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_admin_line_update', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_admin_line_remove(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_line_id bigint := NULLIF(payload->>'line_id', '')::bigint;
+ v_week_status text;
+BEGIN
+ IF v_week_id IS NULL OR v_line_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_admin_line_remove: week_id y line_id son obligatorios',
+ jsonb_build_object('fn', 'fn_payroll_admin_line_remove')
+ );
+ END IF;
+ SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
+ IF NOT FOUND OR v_week_status = 'paid' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_admin_line_remove: no se puede editar una semana pagada o inexistente',
+ jsonb_build_object('fn', 'fn_payroll_admin_line_remove', 'week_id', v_week_id)
+ );
+ END IF;
+ DELETE FROM payroll_week_lines WHERE id = v_line_id;
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object('line_id', v_line_id, 'week_id', v_week_id),
+ format('Línea administrativa %s eliminada de la semana %s', v_line_id, v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_admin_line_remove', 'line_id', v_line_id, 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_admin_line_remove', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_payroll_jornal_worker_add(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_week_id bigint := NULLIF(payload->>'week_id', '')::bigint;
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_week_status text;
+ v_sheet_id bigint;
+ v_line_id bigint;
+BEGIN
+ IF v_week_id IS NULL OR v_project_id IS NULL OR v_worker_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_jornal_worker_add: week_id, project_id y worker_id son obligatorios',
+ jsonb_build_object('fn', 'fn_payroll_jornal_worker_add')
+ );
+ END IF;
+ SELECT status INTO v_week_status FROM payroll_weeks WHERE id = v_week_id;
+ IF NOT FOUND OR v_week_status = 'paid' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_payroll_jornal_worker_add: no se puede editar una semana pagada o inexistente',
+ jsonb_build_object('fn', 'fn_payroll_jornal_worker_add', 'week_id', v_week_id)
+ );
+ END IF;
+ SELECT id INTO v_sheet_id
+ FROM payroll_sheets
+ WHERE week_id = v_week_id AND kind = 'obra' AND project_id = v_project_id;
+ IF v_sheet_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_payroll_jornal_worker_add: hoja de obra no encontrada (semana %s, proyecto %s)', v_week_id, v_project_id),
+ jsonb_build_object('fn', 'fn_payroll_jornal_worker_add', 'week_id', v_week_id, 'project_id', v_project_id)
+ );
+ END IF;
+ v_line_id := core._payroll_sheet_line(v_sheet_id, v_worker_id, NULL);
+ PERFORM core._payroll_recalc_week(v_week_id);
+ RETURN core.rpc_ok(
+ jsonb_build_object('line_id', v_line_id, 'week_id', v_week_id),
+ format('Trabajador %s agregado a jornal (proyecto %s, semana %s)', v_worker_id, v_project_id, v_week_id),
+ jsonb_build_object('fn', 'fn_payroll_jornal_worker_add', 'line_id', v_line_id, 'week_id', v_week_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_payroll_jornal_worker_add', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-013i2-fn-payroll-week-list-open splitStatements:false
+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 splitStatements:false
+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 splitStatements:false
+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 splitStatements:false
+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;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_settings_save(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_unit_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_unit_add(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_attendance_list(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_attendance_set(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_loan_create(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_week_ensure(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_week_recalc(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_week_assemble(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_week_pay(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_week_bundle(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_destajo_job_create(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_destajo_cut_patch(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_payroll_admin_line_add(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_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;
diff --git a/db/core/changesets/014-rpc-import.sql b/db/core/changesets/014-rpc-import.sql
new file mode 100644
index 0000000..f518d72
--- /dev/null
+++ b/db/core/changesets/014-rpc-import.sql
@@ -0,0 +1,566 @@
+--liquibase formatted sql
+-- PANELS · core · RPC importación de personal y metadatos documentales
+
+--changeset panel:core-014a-import-helpers splitStatements:false
+CREATE OR REPLACE FUNCTION core._import_tenant_id(payload jsonb)
+RETURNS integer
+LANGUAGE sql
+STABLE
+AS $$
+ SELECT COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer,
+ 0
+ );
+$$;
+
+CREATE OR REPLACE FUNCTION core._import_norm_upper(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g'));
+$$;
+
+CREATE OR REPLACE FUNCTION core._import_norm_name(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT regexp_replace(btrim(COALESCE(p_value, '')), '\s+', ' ', 'g');
+$$;
+
+CREATE OR REPLACE FUNCTION core._import_format_nss(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT regexp_replace(COALESCE(p_value, ''), '\D', '', 'g');
+$$;
+
+CREATE OR REPLACE FUNCTION core._import_canonical_risk(p_value text)
+RETURNS text
+LANGUAGE sql
+IMMUTABLE
+AS $$
+ SELECT CASE lower(btrim(COALESCE(p_value, '')))
+ WHEN 'alto' THEN 'rojo'
+ WHEN 'medio' THEN 'amarillo'
+ WHEN 'bajo' THEN 'verde'
+ ELSE lower(btrim(COALESCE(p_value, '')))
+ END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._import_validate_worker_row(p_row jsonb)
+RETURNS text[]
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_errors text[] := ARRAY[]::text[];
+ v_email text := lower(btrim(COALESCE(p_row->>'email', '')));
+ v_wt text := upper(btrim(COALESCE(p_row->>'work_type', '')));
+ v_nss text := core._import_format_nss(p_row->>'nss');
+BEGIN
+ IF core._import_norm_name(p_row->>'first_name') = '' THEN
+ v_errors := array_append(v_errors, 'Nombre obligatorio');
+ END IF;
+ IF core._import_norm_name(p_row->>'last_name_p') = '' THEN
+ v_errors := array_append(v_errors, 'Apellido paterno obligatorio');
+ END IF;
+ IF core._import_norm_name(p_row->>'last_name_m') = '' THEN
+ v_errors := array_append(v_errors, 'Apellido materno obligatorio');
+ END IF;
+ IF length(core._import_norm_upper(p_row->>'curp')) <> 18 THEN
+ v_errors := array_append(v_errors, 'CURP inválida (18 caracteres, formato oficial)');
+ END IF;
+ IF length(core._import_norm_upper(p_row->>'rfc')) NOT IN (12, 13) THEN
+ v_errors := array_append(v_errors, 'RFC inválido (12 o 13 caracteres, formato SAT)');
+ END IF;
+ IF length(v_nss) <> 11 THEN
+ v_errors := array_append(v_errors, 'NSS inválido (11 dígitos)');
+ END IF;
+ IF core._import_norm_name(p_row->>'phone') = '' THEN
+ v_errors := array_append(v_errors, 'Teléfono obligatorio');
+ END IF;
+ IF v_email = '' THEN
+ v_errors := array_append(v_errors, 'Correo obligatorio');
+ ELSIF v_email !~ '^[^\s@]+@[^\s@]+\.[^\s@]+$' THEN
+ v_errors := array_append(v_errors, 'Correo inválido');
+ END IF;
+ IF core._import_norm_name(p_row->>'address') = '' THEN
+ v_errors := array_append(v_errors, 'Dirección obligatoria');
+ END IF;
+ IF core._import_norm_name(p_row->>'position') = '' THEN
+ v_errors := array_append(v_errors, 'Cargo obligatorio');
+ END IF;
+ IF core._import_canonical_risk(p_row->>'risk_code') = '' THEN
+ v_errors := array_append(v_errors, 'Riesgo obligatorio');
+ END IF;
+ IF v_wt NOT IN ('N', 'D') THEN
+ v_errors := array_append(v_errors, 'Tipo de trabajo N o D');
+ END IF;
+ IF p_row->>'daily_wage' IS NULL OR (p_row->>'daily_wage')::numeric IS NULL THEN
+ v_errors := array_append(v_errors, 'Jornal obligatorio');
+ END IF;
+ RETURN v_errors;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._import_find_worker(p_curp text, p_rfc text, p_nss text)
+RETURNS TABLE(id bigint, curp text, rfc text, nss text)
+LANGUAGE sql
+STABLE
+SET search_path = core
+AS $$
+ SELECT w.id, w.curp::text, w.rfc::text, w.nss
+ FROM workers w
+ WHERE w.curp = p_curp OR w.rfc = p_rfc OR w.nss = p_nss
+ LIMIT 1;
+$$;
+
+CREATE OR REPLACE FUNCTION core._import_assign_worker(p_worker_id bigint, p_project_id bigint)
+RETURNS void
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+BEGIN
+ IF p_project_id IS NULL THEN
+ RETURN;
+ END IF;
+ INSERT INTO assignments (worker_id, project_id, active, start_date)
+ VALUES (p_worker_id, p_project_id, true, current_date)
+ ON CONFLICT (worker_id, project_id) DO UPDATE SET
+ active = true,
+ start_date = CASE
+ WHEN assignments.active = false THEN excluded.start_date
+ ELSE assignments.start_date
+ END,
+ end_date = NULL;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core._import_upsert_worker(
+ p_row jsonb,
+ p_company_id bigint,
+ p_tenant_id integer
+)
+RETURNS TABLE(worker_id bigint, action text, matched text)
+LANGUAGE plpgsql
+SET search_path = core
+AS $$
+DECLARE
+ v_curp text := core._import_norm_upper(p_row->>'curp');
+ v_rfc text := core._import_norm_upper(p_row->>'rfc');
+ v_nss text := core._import_format_nss(p_row->>'nss');
+ v_existing record;
+ v_status text := CASE WHEN lower(btrim(COALESCE(p_row->>'status', 'activo'))) = 'baja' THEN 'baja' ELSE 'activo' END;
+ v_needs_badge boolean := CASE
+ WHEN lower(COALESCE(p_row->>'needs_badge', 'true')) IN ('false', '0', 'no') THEN false
+ ELSE true
+ END;
+ v_id bigint;
+BEGIN
+ SELECT * INTO v_existing FROM core._import_find_worker(v_curp, v_rfc, v_nss);
+ IF FOUND THEN
+ UPDATE workers SET
+ first_name = initcap(lower(core._import_norm_name(p_row->>'first_name'))),
+ middle_name = NULLIF(initcap(lower(core._import_norm_name(p_row->>'middle_name'))), ''),
+ last_name_p = initcap(lower(core._import_norm_name(p_row->>'last_name_p'))),
+ last_name_m = initcap(lower(core._import_norm_name(p_row->>'last_name_m'))),
+ curp = v_curp,
+ rfc = v_rfc,
+ nss = v_nss,
+ phone = core._import_norm_name(p_row->>'phone'),
+ email = lower(btrim(COALESCE(p_row->>'email', ''))),
+ address = core._import_norm_name(p_row->>'address'),
+ blood_type = NULLIF(core._import_norm_upper(p_row->>'blood_type'), ''),
+ hire_type = upper(btrim(COALESCE(p_row->>'hire_type', ''))),
+ company_id = p_company_id,
+ tenant_id = COALESCE(p_tenant_id, tenant_id),
+ position = initcap(lower(core._import_norm_name(p_row->>'position'))),
+ risk_code = core._import_canonical_risk(p_row->>'risk_code'),
+ work_type = upper(btrim(COALESCE(p_row->>'work_type', ''))),
+ daily_wage = COALESCE((p_row->>'daily_wage')::numeric, 0),
+ needs_badge = v_needs_badge,
+ status = v_status,
+ updated_at = now()
+ WHERE id = v_existing.id;
+ worker_id := v_existing.id;
+ action := 'existed';
+ matched := CASE
+ WHEN v_existing.curp = v_curp THEN 'CURP'
+ WHEN v_existing.rfc = v_rfc THEN 'RFC'
+ ELSE 'NSS'
+ END;
+ RETURN NEXT;
+ RETURN;
+ END IF;
+ 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 (
+ initcap(lower(core._import_norm_name(p_row->>'first_name'))),
+ NULLIF(initcap(lower(core._import_norm_name(p_row->>'middle_name'))), ''),
+ initcap(lower(core._import_norm_name(p_row->>'last_name_p'))),
+ initcap(lower(core._import_norm_name(p_row->>'last_name_m'))),
+ v_curp, v_rfc, v_nss,
+ core._import_norm_name(p_row->>'phone'),
+ lower(btrim(COALESCE(p_row->>'email', ''))),
+ core._import_norm_name(p_row->>'address'),
+ NULLIF(core._import_norm_upper(p_row->>'blood_type'), ''),
+ upper(btrim(COALESCE(p_row->>'hire_type', ''))),
+ p_company_id,
+ initcap(lower(core._import_norm_name(p_row->>'position'))),
+ core._import_canonical_risk(p_row->>'risk_code'),
+ upper(btrim(COALESCE(p_row->>'work_type', ''))),
+ COALESCE((p_row->>'daily_wage')::numeric, 0),
+ v_needs_badge,
+ v_status,
+ p_tenant_id
+ )
+ RETURNING id INTO v_id;
+ worker_id := v_id;
+ action := 'inserted';
+ matched := NULL;
+ RETURN NEXT;
+END;
+$$;
+
+--changeset panel:core-014b-fn-workers-import-batch splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_workers_import_batch(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := core._import_tenant_id(payload);
+ v_default_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_rows jsonb := COALESCE(payload->'rows', '[]'::jsonb);
+ v_row jsonb;
+ v_idx integer := 0;
+ v_sheet text;
+ v_row_no integer;
+ v_nombre text;
+ v_errors text[];
+ v_company record;
+ v_company_code text;
+ v_company_id bigint;
+ v_project_id bigint;
+ v_status text;
+ v_curp text;
+ v_rfc text;
+ v_nss text;
+ v_seen_curp jsonb := '{}'::jsonb;
+ v_seen_rfc jsonb := '{}'::jsonb;
+ v_seen_nss jsonb := '{}'::jsonb;
+ v_dup_row integer;
+ v_upsert record;
+ v_inserted integer := 0;
+ v_existed integer := 0;
+ v_error_count integer := 0;
+ v_error_rows jsonb := '[]'::jsonb;
+ v_existed_rows jsonb := '[]'::jsonb;
+BEGIN
+ IF jsonb_typeof(v_rows) <> 'array' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_workers_import_batch: rows debe ser un arreglo JSON',
+ jsonb_build_object('fn', 'fn_workers_import_batch', 'field', 'rows')
+ );
+ END IF;
+ IF jsonb_array_length(v_rows) = 0 THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_workers_import_batch: el arreglo rows está vacío',
+ jsonb_build_object('fn', 'fn_workers_import_batch', 'field', 'rows')
+ );
+ END IF;
+
+ FOR v_row IN SELECT value FROM jsonb_array_elements(v_rows) LOOP
+ v_idx := v_idx + 1;
+ v_sheet := COALESCE(NULLIF(btrim(v_row->>'sheet'), ''), 'IMPORT');
+ v_row_no := COALESCE((v_row->>'row')::integer, v_idx);
+ v_nombre := trim(concat_ws(' ', v_row->>'first_name', v_row->>'last_name_p', v_row->>'last_name_m'));
+ v_errors := core._import_validate_worker_row(v_row);
+
+ v_company_code := upper(btrim(COALESCE(v_row->>'hire_type', v_row->>'company_code', '')));
+ v_company_id := NULLIF(v_row->>'company_id', '')::bigint;
+ IF v_company_id IS NOT NULL THEN
+ SELECT id, code, tenant_id INTO v_company FROM companies WHERE id = v_company_id;
+ ELSIF v_company_code <> '' THEN
+ SELECT id, code, tenant_id INTO v_company FROM companies WHERE upper(code) = v_company_code;
+ ELSE
+ v_company := NULL;
+ END IF;
+ IF v_company.id IS NULL THEN
+ v_errors := array_append(
+ v_errors,
+ format('Empresa no encontrada (%s)', COALESCE(NULLIF(v_company_code, ''), '—'))
+ );
+ ELSE
+ v_company_id := v_company.id;
+ IF v_tid = 0 AND v_company.tenant_id IS NOT NULL THEN
+ v_tid := v_company.tenant_id;
+ END IF;
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM risk_levels WHERE code = core._import_canonical_risk(v_row->>'risk_code')
+ ) THEN
+ v_errors := array_append(
+ v_errors,
+ format('Riesgo inválido (%s). Use alto, medio o bajo', COALESCE(v_row->>'risk_code', '—'))
+ );
+ END IF;
+
+ v_curp := core._import_norm_upper(v_row->>'curp');
+ v_rfc := core._import_norm_upper(v_row->>'rfc');
+ v_nss := core._import_format_nss(v_row->>'nss');
+
+ IF cardinality(v_errors) = 0 THEN
+ IF v_seen_curp ? v_curp THEN
+ v_dup_row := (v_seen_curp->>v_curp)::integer;
+ v_errors := array_append(v_errors, format('CURP duplicada en el lote (fila %s)', v_dup_row));
+ END IF;
+ IF v_seen_rfc ? v_rfc THEN
+ v_dup_row := (v_seen_rfc->>v_rfc)::integer;
+ v_errors := array_append(v_errors, format('RFC duplicado en el lote (fila %s)', v_dup_row));
+ END IF;
+ IF v_seen_nss ? v_nss THEN
+ v_dup_row := (v_seen_nss->>v_nss)::integer;
+ v_errors := array_append(v_errors, format('NSS duplicado en el lote (fila %s)', v_dup_row));
+ END IF;
+ END IF;
+
+ IF cardinality(v_errors) > 0 THEN
+ v_error_count := v_error_count + 1;
+ v_error_rows := v_error_rows || jsonb_build_array(jsonb_build_object(
+ 'sheet', v_sheet,
+ 'row', v_row_no,
+ 'nombre', v_nombre,
+ 'curp', COALESCE(v_row->>'curp', ''),
+ 'messages', to_jsonb(v_errors)
+ ));
+ CONTINUE;
+ END IF;
+
+ v_seen_curp := v_seen_curp || jsonb_build_object(v_curp, v_row_no);
+ v_seen_rfc := v_seen_rfc || jsonb_build_object(v_rfc, v_row_no);
+ v_seen_nss := v_seen_nss || jsonb_build_object(v_nss, v_row_no);
+
+ v_status := CASE WHEN lower(btrim(COALESCE(v_row->>'status', 'activo'))) = 'baja' THEN 'baja' ELSE 'activo' END;
+ v_project_id := COALESCE(NULLIF(v_row->>'project_id', '')::bigint, v_default_project_id);
+ IF v_status <> 'activo' THEN
+ v_project_id := NULL;
+ END IF;
+
+ SELECT * INTO v_upsert
+ FROM core._import_upsert_worker(
+ v_row || jsonb_build_object('hire_type', v_company.code),
+ v_company_id,
+ COALESCE(v_tid, v_company.tenant_id, 1)
+ );
+
+ IF v_project_id IS NOT NULL THEN
+ PERFORM core._import_assign_worker(v_upsert.worker_id, v_project_id);
+ END IF;
+
+ IF v_status = 'baja' THEN
+ UPDATE workers SET pipeline_status = 'baja' WHERE id = v_upsert.worker_id;
+ END IF;
+
+ IF v_upsert.action = 'inserted' THEN
+ v_inserted := v_inserted + 1;
+ ELSE
+ v_existed := v_existed + 1;
+ v_existed_rows := v_existed_rows || jsonb_build_array(jsonb_build_object(
+ 'sheet', v_sheet,
+ 'row', v_row_no,
+ 'nombre', v_nombre,
+ 'curp', v_curp,
+ 'matched', COALESCE(v_upsert.matched, 'CURP')
+ ));
+ END IF;
+ END LOOP;
+
+ RETURN core.rpc_ok(
+ jsonb_build_object(
+ 'inserted', v_inserted,
+ 'existed', v_existed,
+ 'errors', v_error_count,
+ 'existed_rows', v_existed_rows,
+ 'error_rows', v_error_rows
+ ),
+ format('Importación por lote: %s nuevo(s), %s existente(s), %s error(es)',
+ v_inserted, v_existed, v_error_count),
+ jsonb_build_object(
+ 'fn', 'fn_workers_import_batch',
+ 'tenant_id', v_tid,
+ 'inserted', v_inserted,
+ 'existed', v_existed,
+ 'errors', v_error_count
+ )
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_workers_import_batch', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-014c-fn-worker-document-store splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_document_store(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
+ v_type_code text := btrim(COALESCE(payload->>'type_code', payload->>'type', ''));
+ v_original_name text := btrim(COALESCE(payload->>'original_name', payload->>'filename', ''));
+ v_mime text := btrim(COALESCE(payload->>'mime', ''));
+ v_size_bytes bigint := NULLIF(payload->>'size_bytes', '')::bigint;
+ v_sha256 text := btrim(COALESCE(payload->>'sha256', ''));
+ v_iv text := btrim(COALESCE(payload->>'iv', ''));
+ v_storage_name text := btrim(COALESCE(payload->>'storage_name', ''));
+ v_uploaded_by_id integer := NULLIF(payload->>'uploaded_by_id', '')::integer;
+ v_uploaded_by_name text := COALESCE(NULLIF(btrim(payload->>'uploaded_by_name'), ''), '');
+ v_issued_at date := NULLIF(btrim(payload->>'issued_at'), '')::date;
+ v_expires_at date := NULLIF(btrim(payload->>'expires_at'), '')::date;
+ v_imss_company_id integer := NULLIF(payload->>'imss_company_id', '')::integer;
+ v_imss_alta_at date := NULLIF(btrim(payload->>'imss_alta_at'), '')::date;
+ v_imss_baja_at date := NULLIF(btrim(payload->>'imss_baja_at'), '')::date;
+ v_today date := current_date;
+ v_movement_date date;
+ v_doc_id bigint;
+ v_company record;
+BEGIN
+ IF v_worker_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: worker_id es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'worker_id')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_document_store: trabajador id=%s no encontrado', v_worker_id),
+ jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id)
+ );
+ END IF;
+ IF v_type_code = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: type_code es obligatorio',
+ jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'type_code')
+ );
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM document_types WHERE code = v_type_code) THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_worker_document_store: tipo de documento no válido (%s)', v_type_code),
+ jsonb_build_object('fn', 'fn_worker_document_store', 'type_code', v_type_code)
+ );
+ END IF;
+ IF v_original_name = '' OR v_mime = '' OR v_sha256 = '' OR v_iv = '' OR v_storage_name = '' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: original_name, mime, sha256, iv y storage_name son obligatorios tras la carga a almacenamiento',
+ jsonb_build_object('fn', 'fn_worker_document_store')
+ );
+ END IF;
+ IF v_size_bytes IS NULL OR v_size_bytes < 0 THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: size_bytes debe ser un entero no negativo',
+ jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'size_bytes')
+ );
+ END IF;
+
+ v_imss_alta_at := COALESCE(v_imss_alta_at, CASE WHEN v_type_code = 'alta_imss' THEN v_today END);
+ v_imss_baja_at := COALESCE(v_imss_baja_at, CASE WHEN v_type_code = 'baja_imss' THEN v_today END);
+ v_movement_date := CASE WHEN v_type_code = 'baja_imss' THEN v_imss_baja_at ELSE v_imss_alta_at END;
+
+ UPDATE documents
+ SET is_current = false
+ WHERE worker_id = v_worker_id
+ AND type_code = v_type_code
+ AND is_current = true;
+
+ 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 (
+ v_worker_id, v_type_code, v_original_name, v_mime, v_size_bytes, v_sha256, v_iv, v_storage_name,
+ true, 'manual',
+ COALESCE(v_issued_at, v_movement_date),
+ v_expires_at,
+ v_imss_company_id,
+ v_movement_date,
+ v_uploaded_by_id,
+ v_uploaded_by_name
+ )
+ RETURNING id INTO v_doc_id;
+
+ IF v_type_code = 'alta_imss' THEN
+ v_imss_company_id := COALESCE(
+ v_imss_company_id,
+ (SELECT company_id FROM workers WHERE id = v_worker_id)
+ );
+ SELECT id, code INTO v_company FROM companies WHERE id = v_imss_company_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_document_store: empresa patrón no válida para alta IMSS',
+ jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id)
+ );
+ END IF;
+ UPDATE workers SET
+ imss_status = 'alta',
+ imss_company_id = v_company.id,
+ imss_alta_at = v_imss_alta_at,
+ imss_baja_at = NULL,
+ company_id = v_company.id,
+ hire_type = v_company.code,
+ updated_at = now()
+ WHERE id = v_worker_id;
+ END IF;
+
+ IF v_type_code = 'baja_imss' THEN
+ UPDATE workers SET
+ imss_status = 'baja_imss',
+ imss_baja_at = v_imss_baja_at,
+ imss_company_id = NULL,
+ company_id = NULL,
+ hire_type = '',
+ updated_at = now()
+ WHERE id = v_worker_id;
+ END IF;
+
+ IF (SELECT status FROM workers WHERE id = v_worker_id) = 'baja' THEN
+ UPDATE workers SET pipeline_status = 'baja' WHERE id = v_worker_id;
+ END IF;
+
+ RETURN core.rpc_created(
+ jsonb_build_object(
+ 'id', v_doc_id,
+ 'worker_id', v_worker_id,
+ 'type_code', v_type_code,
+ 'is_current', true,
+ 'storage_name', v_storage_name
+ ),
+ format('Metadatos del documento %s guardados para trabajador %s (versión actual)', v_type_code, v_worker_id),
+ jsonb_build_object('fn', 'fn_worker_document_store', 'id', v_doc_id, 'worker_id', v_worker_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_document_store', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-014d-fn-import-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_workers_import_batch(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_document_store(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/015-rpc-documents.sql b/db/core/changesets/015-rpc-documents.sql
new file mode 100644
index 0000000..52127d2
--- /dev/null
+++ b/db/core/changesets/015-rpc-documents.sql
@@ -0,0 +1,164 @@
+--liquibase formatted sql
+-- PANELS · core · RPC documentos (metadata post-S3)
+
+--changeset panel:core-015a-fn-project-document-store splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_document_store(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := (payload->>'project_id')::bigint;
+ v_type text := btrim(payload->>'type_code');
+ v_doc_id bigint;
+BEGIN
+ IF v_project_id IS NULL OR v_type IS NULL OR v_type = '' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_project_document_store: project_id y type_code son obligatorios',
+ jsonb_build_object('fn', 'fn_project_document_store'));
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM project_document_types WHERE code = v_type) THEN
+ RETURN core.rpc_err('VALIDATION',
+ format('fn_project_document_store: tipo de documento %s no válido para proyectos', v_type),
+ jsonb_build_object('fn', 'fn_project_document_store', 'type_code', v_type));
+ END IF;
+ UPDATE project_documents SET is_current = false WHERE project_id = v_project_id AND type_code = v_type;
+ INSERT INTO project_documents (
+ project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name,
+ is_current, parse_status, uploaded_by_id, uploaded_by_name
+ ) VALUES (
+ v_project_id, v_type, payload->>'original_name', payload->>'mime',
+ (payload->>'size_bytes')::integer, payload->>'sha256', payload->>'iv', payload->>'storage_name',
+ true, 'manual', NULLIF(payload->>'uploaded_by_id', '')::integer, coalesce(payload->>'uploaded_by_name', '')
+ ) RETURNING id INTO v_doc_id;
+ RETURN core.rpc_ok(jsonb_build_object('id', v_doc_id),
+ format('Documento %s guardado para proyecto id=%s (doc id=%s)', v_type, v_project_id, v_doc_id),
+ jsonb_build_object('fn', 'fn_project_document_store', 'project_id', v_project_id, 'id', v_doc_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_document_store', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-015b-fn-company-document-store splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_company_document_store(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_company_id bigint := (payload->>'company_id')::bigint;
+ v_type text := btrim(payload->>'type_code');
+ v_doc_id bigint;
+BEGIN
+ IF v_company_id IS NULL OR v_type IS NULL OR v_type = '' THEN
+ RETURN core.rpc_err('VALIDATION', 'fn_company_document_store: company_id y type_code son obligatorios',
+ jsonb_build_object('fn', 'fn_company_document_store'));
+ END IF;
+ IF NOT EXISTS (SELECT 1 FROM company_document_types WHERE code = v_type) THEN
+ RETURN core.rpc_err('VALIDATION',
+ format('fn_company_document_store: tipo de documento %s no válido para empresas', v_type),
+ jsonb_build_object('fn', 'fn_company_document_store', 'type_code', v_type));
+ END IF;
+ UPDATE company_documents SET is_current = false WHERE company_id = v_company_id AND type_code = v_type;
+ INSERT INTO company_documents (
+ company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name,
+ is_current, parse_status, uploaded_by_id, uploaded_by_name
+ ) VALUES (
+ v_company_id, v_type, payload->>'original_name', payload->>'mime',
+ (payload->>'size_bytes')::integer, payload->>'sha256', payload->>'iv', payload->>'storage_name',
+ true, 'manual', NULLIF(payload->>'uploaded_by_id', '')::integer, coalesce(payload->>'uploaded_by_name', '')
+ ) RETURNING id INTO v_doc_id;
+ RETURN core.rpc_ok(jsonb_build_object('id', v_doc_id),
+ format('Documento %s guardado para empresa id=%s (doc id=%s)', v_type, v_company_id, v_doc_id),
+ jsonb_build_object('fn', 'fn_company_document_store', 'company_id', v_company_id, 'id', v_doc_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_document_store', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-015c-fn-document-get splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_project_document_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_project_id bigint := (payload->>'project_id')::bigint;
+ v_doc_id bigint := (payload->>'doc_id')::bigint;
+ v_doc jsonb;
+BEGIN
+ SELECT to_jsonb(d) INTO v_doc FROM project_documents d
+ WHERE d.id = v_doc_id AND d.project_id = v_project_id;
+ IF v_doc IS NULL THEN
+ RETURN core.rpc_err('NOT_FOUND',
+ format('fn_project_document_get: documento id=%s no encontrado en proyecto %s', v_doc_id, v_project_id),
+ jsonb_build_object('fn', 'fn_project_document_get', 'doc_id', v_doc_id, 'project_id', v_project_id));
+ END IF;
+ RETURN core.rpc_ok(jsonb_build_object('document', v_doc),
+ format('Documento id=%s del proyecto %s obtenido', v_doc_id, v_project_id),
+ jsonb_build_object('fn', 'fn_project_document_get', 'doc_id', v_doc_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_project_document_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_company_document_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_company_id bigint := (payload->>'company_id')::bigint;
+ v_doc_id bigint := (payload->>'doc_id')::bigint;
+ v_doc jsonb;
+BEGIN
+ SELECT to_jsonb(d) INTO v_doc FROM company_documents d
+ WHERE d.id = v_doc_id AND d.company_id = v_company_id;
+ IF v_doc IS NULL THEN
+ RETURN core.rpc_err('NOT_FOUND',
+ format('fn_company_document_get: documento id=%s no encontrado en empresa %s', v_doc_id, v_company_id),
+ jsonb_build_object('fn', 'fn_company_document_get', 'doc_id', v_doc_id, 'company_id', v_company_id));
+ END IF;
+ RETURN core.rpc_ok(jsonb_build_object('document', v_doc),
+ format('Documento id=%s de la empresa %s obtenido', v_doc_id, v_company_id),
+ jsonb_build_object('fn', 'fn_company_document_get', 'doc_id', v_doc_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_company_document_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_worker_document_get(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_worker_id bigint := (payload->>'worker_id')::bigint;
+ v_doc_id bigint := (payload->>'doc_id')::bigint;
+ v_doc jsonb;
+BEGIN
+ SELECT to_jsonb(d) INTO v_doc FROM documents d
+ WHERE d.id = v_doc_id AND d.worker_id = v_worker_id;
+ IF v_doc IS NULL THEN
+ RETURN core.rpc_err('NOT_FOUND',
+ format('fn_worker_document_get: documento id=%s no encontrado para trabajador %s', v_doc_id, v_worker_id),
+ jsonb_build_object('fn', 'fn_worker_document_get', 'doc_id', v_doc_id, 'worker_id', v_worker_id));
+ END IF;
+ RETURN core.rpc_ok(jsonb_build_object('document', v_doc),
+ format('Documento id=%s del trabajador %s obtenido', v_doc_id, v_worker_id),
+ jsonb_build_object('fn', 'fn_worker_document_get', 'doc_id', v_doc_id));
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_document_get', SQLSTATE, SQLERRM);
+END;
+$$;
+
+--changeset panel:core-015d-fn-documents-grants endDelimiter:; splitStatements:true
+GRANT EXECUTE ON FUNCTION core.fn_project_document_store(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_document_store(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_project_document_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_company_document_get(jsonb) TO panels_core_app;
+GRANT EXECUTE ON FUNCTION core.fn_worker_document_get(jsonb) TO panels_core_app;
diff --git a/db/core/changesets/016-rpc-worker-needs-badge.sql b/db/core/changesets/016-rpc-worker-needs-badge.sql
new file mode 100644
index 0000000..0a9eaa7
--- /dev/null
+++ b/db/core/changesets/016-rpc-worker-needs-badge.sql
@@ -0,0 +1,200 @@
+--liquibase formatted sql
+-- PANELS · core · fix needs_badge cast en fn_worker_create/update
+
+--changeset panel:core-016a-fn-worker-needs-badge splitStatements:false
+CREATE OR REPLACE FUNCTION core.fn_worker_create(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
+ v_errors jsonb;
+ v_norm jsonb;
+ v_existing workers%ROWTYPE;
+ v_row workers%ROWTYPE;
+ v_project_status text;
+ v_refresh jsonb;
+BEGIN
+ v_errors := core._worker_validate_fields(payload);
+ IF v_errors <> '{}'::jsonb THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_create: datos del trabajador incompletos o inválidos',
+ jsonb_build_object('fn', 'fn_worker_create', 'field', 'payload'),
+ v_errors
+ );
+ END IF;
+ v_norm := core._worker_normalize(payload);
+ SELECT * INTO v_existing
+ FROM workers
+ WHERE curp = v_norm->>'curp'
+ OR rfc = v_norm->>'rfc'
+ OR nss = v_norm->>'nss'
+ LIMIT 1;
+ IF FOUND THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ core._worker_conflict_message(v_existing, v_norm->>'curp', v_norm->>'rfc', v_norm->>'nss'),
+ jsonb_build_object('fn', 'fn_worker_create', 'worker_id', v_existing.id)
+ );
+ END IF;
+ IF v_project_id IS NOT NULL THEN
+ SELECT status INTO v_project_status FROM projects WHERE id = v_project_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_create: proyecto id=%s no encontrado', v_project_id),
+ jsonb_build_object('fn', 'fn_worker_create', 'project_id', v_project_id)
+ );
+ END IF;
+ IF v_project_status <> 'activo' THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_create: solo se asigna personal a proyectos activos',
+ jsonb_build_object('fn', 'fn_worker_create', 'project_id', v_project_id, 'status', v_project_status)
+ );
+ END IF;
+ END IF;
+ 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 (
+ v_norm->>'first_name',
+ v_norm->>'middle_name',
+ v_norm->>'last_name_p',
+ v_norm->>'last_name_m',
+ v_norm->>'curp',
+ v_norm->>'rfc',
+ v_norm->>'nss',
+ v_norm->>'phone',
+ v_norm->>'email',
+ v_norm->>'address',
+ v_norm->>'blood_type',
+ '',
+ NULL,
+ v_norm->>'position',
+ v_norm->>'risk_code',
+ v_norm->>'work_type',
+ (v_norm->>'daily_wage')::numeric(12,2),
+ COALESCE((v_norm->'needs_badge')::boolean, true),
+ v_norm->>'status',
+ v_tid
+ )
+ RETURNING * INTO v_row;
+ IF v_project_id IS NOT NULL THEN
+ INSERT INTO assignments (worker_id, project_id, active, start_date)
+ VALUES (v_row.id, v_project_id, true, current_date)
+ ON CONFLICT (worker_id, project_id) DO UPDATE SET
+ active = true,
+ start_date = CASE
+ WHEN assignments.active = false THEN excluded.start_date
+ ELSE assignments.start_date
+ END,
+ end_date = NULL;
+ END IF;
+ v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object('worker_id', v_row.id, 'tenant_id', v_tid));
+ RETURN core.rpc_created(
+ jsonb_build_object('worker', to_jsonb(v_row), 'pipeline', v_refresh->'data'),
+ format('Trabajador %s %s creado (id=%s)', v_row.first_name, v_row.last_name_p, v_row.id),
+ jsonb_build_object('fn', 'fn_worker_create', 'id', v_row.id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_create', SQLSTATE, SQLERRM);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION core.fn_worker_update(payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY INVOKER
+SET search_path = core
+AS $$
+DECLARE
+ v_id bigint := NULLIF(payload->>'id', '')::bigint;
+ v_tid integer := COALESCE(
+ NULLIF(payload->>'tenant_id', '')::integer,
+ NULLIF(current_setting('app.tenant_id', true), '')::integer
+ );
+ v_cur workers%ROWTYPE;
+ v_merged jsonb;
+ v_errors jsonb;
+ v_norm jsonb;
+ v_existing workers%ROWTYPE;
+ v_refresh jsonb;
+BEGIN
+ IF v_id IS NULL THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ 'fn_worker_update: id es obligatorio para actualizar un trabajador',
+ jsonb_build_object('fn', 'fn_worker_update', 'field', 'id')
+ );
+ END IF;
+ SELECT * INTO v_cur FROM workers WHERE id = v_id;
+ IF NOT FOUND THEN
+ RETURN core.rpc_err(
+ 'NOT_FOUND',
+ format('fn_worker_update: trabajador id=%s no encontrado', v_id),
+ jsonb_build_object('fn', 'fn_worker_update', 'id', v_id)
+ );
+ END IF;
+ v_merged := to_jsonb(v_cur) || payload;
+ v_errors := core._worker_validate_fields(v_merged);
+ IF v_errors <> '{}'::jsonb THEN
+ RETURN core.rpc_err(
+ 'VALIDATION',
+ format('fn_worker_update: datos del trabajador inválidos (id=%s)', v_id),
+ jsonb_build_object('fn', 'fn_worker_update', 'id', v_id),
+ v_errors
+ );
+ END IF;
+ v_norm := core._worker_normalize(v_merged);
+ SELECT * INTO v_existing
+ FROM workers
+ WHERE (curp = v_norm->>'curp' OR rfc = v_norm->>'rfc' OR nss = v_norm->>'nss')
+ AND id <> v_id
+ LIMIT 1;
+ IF FOUND THEN
+ RETURN core.rpc_err(
+ 'CONFLICT',
+ core._worker_conflict_message(v_existing, v_norm->>'curp', v_norm->>'rfc', v_norm->>'nss'),
+ jsonb_build_object('fn', 'fn_worker_update', 'id', v_id, 'worker_id', v_existing.id)
+ );
+ END IF;
+ UPDATE workers SET
+ first_name = v_norm->>'first_name',
+ middle_name = v_norm->>'middle_name',
+ last_name_p = v_norm->>'last_name_p',
+ last_name_m = v_norm->>'last_name_m',
+ curp = v_norm->>'curp',
+ rfc = v_norm->>'rfc',
+ nss = v_norm->>'nss',
+ phone = v_norm->>'phone',
+ email = v_norm->>'email',
+ address = v_norm->>'address',
+ blood_type = v_norm->>'blood_type',
+ hire_type = v_cur.hire_type,
+ company_id = v_cur.company_id,
+ position = v_norm->>'position',
+ risk_code = v_norm->>'risk_code',
+ work_type = v_norm->>'work_type',
+ daily_wage = (v_norm->>'daily_wage')::numeric(12,2),
+ needs_badge = COALESCE((v_norm->'needs_badge')::boolean, v_cur.needs_badge, true),
+ status = v_norm->>'status'
+ WHERE id = v_id
+ RETURNING * INTO v_cur;
+ v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object('worker_id', v_id, 'tenant_id', v_tid));
+ RETURN core.rpc_ok(
+ jsonb_build_object('worker', to_jsonb(v_cur), 'pipeline', v_refresh->'data'),
+ format('Trabajador %s %s actualizado (id=%s)', v_cur.first_name, v_cur.last_name_p, v_id),
+ jsonb_build_object('fn', 'fn_worker_update', 'id', v_id)
+ );
+EXCEPTION WHEN OTHERS THEN
+ RETURN core.rpc_from_exception('fn_worker_update', SQLSTATE, SQLERRM);
+END;
+$$;
diff --git a/db/core/changesets/017-rpc-worker-normalize-badge.sql b/db/core/changesets/017-rpc-worker-normalize-badge.sql
new file mode 100644
index 0000000..9f83ca1
--- /dev/null
+++ b/db/core/changesets/017-rpc-worker-normalize-badge.sql
@@ -0,0 +1,44 @@
+--liquibase formatted sql
+-- PANELS · core · fix needs_badge default cuando el payload no lo envía
+
+--changeset panel:core-017a-worker-needs-badge-default splitStatements:false
+CREATE OR REPLACE FUNCTION core._worker_normalize(p_payload jsonb)
+RETURNS jsonb
+LANGUAGE plpgsql
+IMMUTABLE
+AS $$
+DECLARE
+ v_middle text := core._worker_trim_text(p_payload->>'middle_name');
+ v_blood text := core._worker_norm_upper(p_payload->>'blood_type');
+ v_needs_badge boolean := COALESCE(
+ NOT (
+ p_payload->>'needs_badge' = 'false'
+ OR p_payload->>'needs_badge' = '0'
+ OR p_payload->'needs_badge' = 'false'::jsonb
+ OR p_payload->'needs_badge' = '0'::jsonb
+ ),
+ true
+ );
+BEGIN
+ RETURN jsonb_build_object(
+ 'first_name', initcap(lower(core._worker_trim_text(p_payload->>'first_name'))),
+ 'middle_name', CASE WHEN v_middle = '' THEN NULL ELSE initcap(lower(v_middle)) END,
+ 'last_name_p', initcap(lower(core._worker_trim_text(p_payload->>'last_name_p'))),
+ 'last_name_m', initcap(lower(core._worker_trim_text(p_payload->>'last_name_m'))),
+ 'curp', core._worker_norm_upper(p_payload->>'curp'),
+ 'rfc', core._worker_norm_upper(p_payload->>'rfc'),
+ 'nss', core._worker_format_nss(p_payload->>'nss'),
+ 'phone', core._worker_trim_text(p_payload->>'phone'),
+ 'email', lower(core._worker_trim_text(p_payload->>'email')),
+ 'address', core._worker_trim_text(p_payload->>'address'),
+ 'blood_type', CASE WHEN v_blood = '' THEN NULL ELSE v_blood END,
+ 'hire_type', upper(core._worker_trim_text(p_payload->>'hire_type')),
+ 'position', initcap(lower(core._worker_trim_text(p_payload->>'position'))),
+ 'risk_code', core._worker_canonical_risk(p_payload->>'risk_code'),
+ 'work_type', upper(core._worker_trim_text(p_payload->>'work_type')),
+ 'daily_wage', COALESCE((p_payload->>'daily_wage')::numeric(12,2), 0),
+ 'needs_badge', v_needs_badge,
+ 'status', CASE WHEN p_payload->>'status' = 'baja' THEN 'baja' ELSE 'activo' END
+ );
+END;
+$$;
diff --git a/scripts/crud-smoke-test.sh b/scripts/crud-smoke-test.sh
new file mode 100755
index 0000000..7d940a4
--- /dev/null
+++ b/scripts/crud-smoke-test.sh
@@ -0,0 +1,168 @@
+#!/usr/bin/env bash
+# Smoke test CRUD via RPC-backed API
+set -euo pipefail
+BASE="http://127.0.0.1:8000"
+JAR="/tmp/panels-cookies.txt"
+PASS="${SEED_PASSWORD:-TestAdmin123!}"
+SUFFIX=$((RANDOM % 90000 + 10000))
+WORKER_TAG=$(printf '%02d' $((10 + SUFFIX % 80)))
+
+pass() { echo "✓ $1"; }
+fail() { echo "✗ $1"; exit 1; }
+
+check_status() {
+ local name="$1" expected="$2" actual="$3" body="$4"
+ if [[ "$actual" != "$expected" ]]; then
+ echo "FAIL $name: expected HTTP $expected, got $actual"
+ echo "$body" | head -c 800
+ exit 1
+ fi
+ pass "$name (HTTP $actual)"
+}
+
+echo "=== Health ==="
+H=$(curl -s -w "\n%{http_code}" "$BASE/v1/health")
+BODY=$(echo "$H" | head -n -1)
+CODE=$(echo "$H" | tail -1)
+check_status "health" "200" "$CODE" "$BODY"
+echo "$BODY" | grep -q '"ok":true' || fail "health body not ok"
+
+echo "=== Login ==="
+LOGIN=$(curl -s -w "\n%{http_code}" -c "$JAR" -X POST "$BASE/v1/auth/login" \
+ -H "Content-Type: application/json" \
+ -d "{\"username\":\"arct2608\",\"password\":\"$PASS\"}")
+BODY=$(echo "$LOGIN" | head -n -1)
+CODE=$(echo "$LOGIN" | tail -1)
+check_status "login" "200" "$CODE" "$BODY"
+
+echo "=== Companies LIST ==="
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/companies")
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "companies list" "200" "$CODE" "$BODY"
+echo "$BODY" | grep -q '"ok":true' || fail "companies list envelope"
+echo "$BODY" | grep -q 'ARCT2608' || fail "seed company missing"
+
+echo "=== Companies CREATE ==="
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/companies" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "Subempresa Prueba RPC",
+ "code": "SUBRPC'"$SUFFIX"'",
+ "razon_social": "Subempresa Prueba SA de CV",
+ "rfc": "SPR010101ABC",
+ "regimen_fiscal": "601",
+ "registro_patronal": "A1234567890",
+ "domicilio_fiscal": "Calle Test 123",
+ "codigo_postal": "01000",
+ "ciudad": "Ciudad de México",
+ "estado": "CDMX"
+ }')
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "company create" "201" "$CODE" "$BODY"
+COMPANY_ID=$(echo "$BODY" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
+[[ -n "$COMPANY_ID" ]] || fail "no company id in create response"
+
+echo "=== Companies GET ==="
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/companies/$COMPANY_ID")
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "company get" "200" "$CODE" "$BODY"
+echo "$BODY" | grep -qi "SUBRPC$SUFFIX" || fail "company code mismatch"
+
+echo "=== Companies PATCH ==="
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X PATCH "$BASE/v1/companies/$COMPANY_ID" \
+ -H "Content-Type: application/json" \
+ -d '{"nombre_comercial":"Sub RPC Editada","telefono":"5551234567"}')
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "company patch" "200" "$CODE" "$BODY"
+echo "$BODY" | grep -q 'Sub RPC Editada' || fail "company patch not applied"
+
+echo "=== Projects LIST ==="
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects")
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "projects list" "200" "$CODE" "$BODY"
+
+echo "=== Projects CREATE ==="
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X POST "$BASE/v1/projects" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "Obra Prueba RPC",
+ "company_id": 1,
+ "address": "Av. Reforma 100",
+ "contract_amount": 1500000,
+ "start_date": "2026-01-01"
+ }')
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "project create" "201" "$CODE" "$BODY"
+PROJECT_ID=$(echo "$BODY" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
+[[ -n "$PROJECT_ID" ]] || fail "no project id"
+
+echo "=== Projects GET ==="
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/$PROJECT_ID")
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "project get" "200" "$CODE" "$BODY"
+
+echo "=== Projects PATCH ==="
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" -X PATCH "$BASE/v1/projects/$PROJECT_ID" \
+ -H "Content-Type: application/json" \
+ -d '{"name":"Obra Prueba RPC Editada","resident_name":"Ing. Test"}')
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "project patch" "200" "$CODE" "$BODY"
+R=$(curl -s -w "\n%{http_code}" -b "$JAR" "$BASE/v1/projects/$PROJECT_ID")
+BODY=$(echo "$R" | head -n -1); CODE=$(echo "$R" | tail -1)
+check_status "project get after patch" "200" "$CODE" "$BODY"
+echo "$BODY" | grep -qi 'Obra Prueba RPC Editada' || fail "project patch not applied"
+
+echo "=== Workers CREATE ==="
+WORKER_JSON=$(cat < = {
+ ok: boolean;
+ code: FrontRpcCode | string;
+ layer: "front";
+ message: string;
+ context?: Record;
+ data?: T | null;
+ errors?: Record | string[] | null;
+};
+
+export function buildFrontError(
+ message: string,
+ code: FrontRpcCode = "VALIDATION",
+ context?: Record,
+ errors?: Record | string[] | null,
+): FrontResponse {
+ return {
+ ok: false,
+ code,
+ layer: "front",
+ message,
+ context,
+ data: null,
+ errors: errors ?? null,
+ };
+}
+
+export function buildFrontNetworkError(method: string, path: string): FrontResponse {
+ return buildFrontError(
+ `No se pudo conectar con el servidor en ${method} ${path} — verifique su conexión`,
+ "NETWORK",
+ { route: `${method} ${path}` },
+ );
+}
+
+export function isApiEnvelope(value: unknown): value is {
+ ok: boolean;
+ code: string;
+ status?: number;
+ layer: string;
+ message: string;
+} {
+ return !!value && typeof value === "object"
+ && "ok" in value && "message" in value && "layer" in value;
+}
+
+/** Muestra el mensaje detallado de la API o front sin reescribirlo. */
+export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
+ if (isApiEnvelope(value)) return value.message;
+ if (value && typeof value === "object" && "message" in value) {
+ return String((value as { message: unknown }).message);
+ }
+ if (value && typeof value === "object" && "error" in value) {
+ return String((value as { error: unknown }).error);
+ }
+ return fallback;
+}
diff --git a/web-saas/composables/api-response.ts b/web-saas/composables/api-response.ts
new file mode 100644
index 0000000..8e6943a
--- /dev/null
+++ b/web-saas/composables/api-response.ts
@@ -0,0 +1,58 @@
+export type FrontRpcCode = "VALIDATION" | "NETWORK" | "INTERNAL";
+
+export type FrontResponse = {
+ ok: boolean;
+ code: FrontRpcCode | string;
+ layer: "front";
+ message: string;
+ context?: Record;
+ data?: T | null;
+ errors?: Record | string[] | null;
+};
+
+export function buildFrontError(
+ message: string,
+ code: FrontRpcCode = "VALIDATION",
+ context?: Record,
+ errors?: Record | string[] | null,
+): FrontResponse {
+ return {
+ ok: false,
+ code,
+ layer: "front",
+ message,
+ context,
+ data: null,
+ errors: errors ?? null,
+ };
+}
+
+export function buildFrontNetworkError(method: string, path: string): FrontResponse {
+ return buildFrontError(
+ `No se pudo conectar con el servidor en ${method} ${path} — verifique su conexión`,
+ "NETWORK",
+ { route: `${method} ${path}` },
+ );
+}
+
+export function isApiEnvelope(value: unknown): value is {
+ ok: boolean;
+ code: string;
+ status?: number;
+ layer: string;
+ message: string;
+} {
+ return !!value && typeof value === "object"
+ && "ok" in value && "message" in value && "layer" in value;
+}
+
+export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
+ if (isApiEnvelope(value)) return value.message;
+ if (value && typeof value === "object" && "message" in value) {
+ return String((value as { message: unknown }).message);
+ }
+ if (value && typeof value === "object" && "error" in value) {
+ return String((value as { error: unknown }).error);
+ }
+ return fallback;
+}