Pre-computed merge for change #2

This commit is contained in:
Cursor Origin 1970-01-01 00:00:00 +00:00
commit bf28e36f81
24 changed files with 8266 additions and 2025 deletions

View file

@ -1,5 +1,6 @@
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import type { Db } from "./db.ts"; import type { Db } from "./db.ts";
import { callCoreFn, RpcCallError } from "./rpc.ts";
export const BUDGET_IVA = 0.16; export const BUDGET_IVA = 0.16;
@ -642,17 +643,14 @@ function compareWbs(a: string, b: string) {
} }
export async function listBudget(database: Db, projectId: number) { export async function listBudget(database: Db, projectId: number) {
const chapters = await database.prepare( const env = await callCoreFn<{
`SELECT c.id, c.project_id, c.parent_id, c.code, c.name, COALESCE(c.wbs, '') AS wbs, c.sort_order chapters: ChapterRow[];
FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`, items: (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
).all(projectId) as ChapterRow[]; totals: { subtotal: number; iva: number; total: number; item_count: number };
const rawItems = await database.prepare( }>(database, "core.fn_budget_list", { project_id: projectId });
`SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code if (!env.ok) throw new RpcCallError(env);
FROM budget_items i const chapters = env.data?.chapters ?? [];
LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id const rawItems = env.data?.items ?? [];
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 byParent = new Map<number | null, ChapterRow[]>(); const byParent = new Map<number | null, ChapterRow[]>();
const byId = new Map<number, ChapterRow>(); const byId = new Map<number, ChapterRow>();
@ -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 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 chapterView = chapters.map((chapter) => {
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 }; const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
const chain = ancestorsOf(chapter.id); 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<number> {
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<void> { export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise<void> {
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 groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs));
const ids = new Map<string, number>();
let sort = 1; let sort = 1;
for (const group of groups) { const chapters = groups.map((group) => ({
const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null; wbs: group.wbs,
const id = await insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort); parent_wbs: group.parentWbs || null,
ids.set(group.wbs, id); code: group.code,
sort++; name: group.name,
} sort_order: sort++,
}));
let itemSort = 1; let itemSort = 1;
for (const item of parsed.items) { const items = parsed.items.map((item) => ({
const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null; chapter_wbs: item.parentWbs || null,
const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100; code: item.code,
await database.prepare( description: item.description,
`INSERT INTO budget_items unit: item.unit,
(project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs) quantity: item.quantity,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, unit_price: item.unit_price,
).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs); amount: Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100,
itemSort++; 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<void> { export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
@ -927,7 +915,11 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes:
item_count: parsed.items.length, item_count: parsed.items.length,
}; };
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado. // 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 { return {
replaced: true, replaced: true,
chapters: parsed.groups.length, chapters: parsed.groups.length,

View file

@ -1,4 +1,7 @@
import type { Db } from "./db.ts"; 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 = { export type Company = {
id: number; id: number;
@ -60,43 +63,35 @@ function normRfc(value: unknown): string {
return trimText(value).toUpperCase().replace(/\s+/g, ""); return trimText(value).toUpperCase().replace(/\s+/g, "");
} }
function envelopeError<T>(env: RpcEnvelope<T>): { error: string; status: number } {
return { error: env.message, status: mapRpcToStatus(String(env.code)) };
}
export async function listCompanies(database: Db, tenantId?: number | null): Promise<Company[]> { export async function listCompanies(database: Db, tenantId?: number | null): Promise<Company[]> {
// El filtro tenant_id aquí es defensa adicional/legibilidad -- el const env = await callCoreFn<{ companies: Company[] }>(database, "core.fn_company_list", {
// aislamiento real ya lo garantiza Row Level Security sobre la conexión tenant_id: tenantId ?? null,
// acotada por withCoreScope (ver db/core/changesets/005-rls.sql). });
const where = tenantId != null ? "WHERE c.tenant_id = ?" : ""; if (!env.ok) throw new RpcCallError(env);
const params = tenantId != null ? [tenantId] : []; return (env.data?.companies ?? []) as Company[];
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[];
} }
export async function companyById(database: Db, id: number): Promise<Company | undefined> { export async function companyById(database: Db, id: number): Promise<Company | undefined> {
return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get", { id });
| Company if (!env.ok) return undefined;
| undefined; return env.data?.company as Company | undefined;
} }
export async function companyByCode(database: Db, code: string): Promise<Company | undefined> { export async function companyByCode(database: Db, code: string): Promise<Company | undefined> {
return await database.prepare("SELECT * FROM companies WHERE code = ?").get( const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get_by_code", {
normCompanyCode(code), code: normCompanyCode(code),
) as Company | undefined; });
if (!env.ok) return undefined;
return env.data?.company as Company | undefined;
} }
export async function principalCompany(database: Db, tenantId?: number | null): Promise<Company | undefined> { export async function principalCompany(database: Db, tenantId?: number | null): Promise<Company | undefined> {
if (tenantId != null) { const companies = await listCompanies(database, tenantId);
return await database.prepare( return companies.find((c) => c.kind === "principal");
"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;
} }
export function normCompanyCode(value: string | null | undefined): string { 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<string> { export async function nextCompanyCode(database: Db, name: string): Promise<string> {
const base = companyCodeFromName(name); const env = await callCoreFn<{ code: string }>(database, "core.fn_next_company_code", { name });
if (!await companyByCode(database, base)) return base; if (!env.ok) throw new RpcCallError(env);
const row = await database.prepare( return String(env.data?.code ?? companyCodeFromName(name));
`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")}`;
} }
export async function resolveCompany( 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( export async function createSubcompany(
database: Db, database: Db,
input: CompanyProfileInput, input: CompanyProfileInput,
tenantId?: number | null, tenantId?: number | null,
): Promise<{ company?: Company; error?: string }> { ): Promise<{ company?: Company; error?: string; status?: number }> {
const profileErr = validateProfile(input, { requireLegal: true }); const profileErr = validateCompanyProfile(input, { requireLegal: true });
if (profileErr) return { error: profileErr }; if (profileErr) return { error: profileErr, status: 400 };
const profile = profileFromInput(input); const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_create", {
if (!profile.name) return { error: "Nombre de empresa obligatorio" }; ...input,
tenant_id: tenantId ?? null,
const principal = await principalCompany(database, tenantId); });
if (!principal) return { error: "No hay empresa principal" }; if (!env.ok) return envelopeError(env);
const parentId = input.parent_id ? Number(input.parent_id) : principal.id; return { company: env.data?.company as Company };
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()) };
} }
export async function updateCompany( export async function updateCompany(
@ -260,100 +198,14 @@ export async function updateCompany(
input: CompanyProfileInput, input: CompanyProfileInput,
tenantId?: number | null, tenantId?: number | null,
): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> { ): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> {
const current = await companyById(database, id); const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_update", {
// Con RLS activo, una fila de otro tenant ya no aparece aquí (la conexión id,
// solo ve app.tenant_id); este chequeo explícito es defensa adicional y tenant_id: tenantId ?? null,
// un mensaje de error más claro que un 404 "silencioso". ...input,
if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) { });
return { error: "Empresa no encontrada", status: 404 }; if (!env.ok) {
const st = mapRpcToStatus(String(env.code));
return { error: env.message, status: st === 404 ? 404 : 400 };
} }
return { company: env.data?.company as Company };
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";
} }

265
api/db.ts
View file

@ -1,22 +1,15 @@
import postgres, { createPool, PgDb, withTenant } from "./pg.ts"; import postgres, { createPool, PgDb, withTenant } from "./pg.ts";
import { config } from "./config.ts"; import { config } from "./config.ts";
import { callCoreFn, RpcCallError } from "./rpc.ts";
import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts"; import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
export type Db = PgDb; 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 corePool: postgres.Sql | null = null;
let coreOwnerPool: postgres.Sql | null = null; let coreOwnerPool: postgres.Sql | null = null;
function getCorePool(): postgres.Sql { function getCorePool(): postgres.Sql {
if (!corePool) { if (!corePool) corePool = createPool(config.databaseUrlCore, { max: 20 });
corePool = createPool(config.databaseUrlCore, { max: 20 });
}
return corePool; return corePool;
} }
@ -31,10 +24,6 @@ export async function pingCoreDb(): Promise<void> {
await getCorePool()`SELECT 1`; 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<T>( export async function withCoreTenant<T>(
tenantId: number | null, tenantId: number | null,
fn: (db: Db) => Promise<T>, fn: (db: Db) => Promise<T>,
@ -42,10 +31,6 @@ export async function withCoreTenant<T>(
return await withTenant(getCorePool(), tenantId, fn); 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<Db> { export async function getCoreDb(): Promise<Db> {
return new PgDb(getCoreOwnerPool()); return new PgDb(getCoreOwnerPool());
} }
@ -76,17 +61,15 @@ export function isProjectStatus(value: string): value is ProjectStatus {
} }
export async function nextProjectCode(database: Db): Promise<string> { export async function nextProjectCode(database: Db): Promise<string> {
const row = await database.prepare( const env = await callCoreFn<{ code: string }>(database, "core.fn_next_project_code", {});
`SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n if (!env.ok) throw new RpcCallError(env);
FROM projects WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}'`, return String(env.data?.code ?? "PRY-0001");
).get() as { n: number };
return `PRY-${String(row.n).padStart(4, "0")}`;
} }
export async function projectById(database: Db, id: number) { export async function projectById(database: Db, id: number) {
return await database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as const env = await callCoreFn<{ project: Record<string, unknown> }>(database, "core.fn_project_get", { id });
| { id: number; code: string; name: string; status: string } if (!env.ok) return undefined;
| undefined; return env.data?.project as { id: number; code: string; name: string; status: string } | undefined;
} }
export function projectMustBe( export function projectMustBe(
@ -102,104 +85,87 @@ export function projectMustBe(
} }
export async function workerImssContext(database: Db, workerId: number): Promise<WorkerImssContext> { export async function workerImssContext(database: Db, workerId: number): Promise<WorkerImssContext> {
const w = await database.prepare( const env = await callCoreFn<{ imss_context: WorkerImssContext }>(
"SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?", database, "core.fn_worker_checklist_raw", { worker_id: workerId },
).get(workerId) as { );
imss_status: ImssStatus; if (env.ok && env.data?.imss_context) return env.data.imss_context;
imss_alta_at: string | null; return { imss_status: "sin_alta" as ImssStatus, last_rehire_at: null, current_alta_at: 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,
};
} }
/** 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) { export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) {
const tz = await tenantTimezone(database, tenantId); const tz = await tenantTimezone(database, tenantId);
const today = todayInTimezone(tz); const today = todayInTimezone(tz);
const types = await database.prepare( const env = await callCoreFn<{
`SELECT code, label, required, validity_mode, freshness_days, items?: Array<Record<string, unknown>>;
requires_issued_at, requires_expires_at, category ctx?: WorkerImssContext;
FROM document_types`, imss_context?: WorkerImssContext;
).all() as { freshness_required?: boolean;
code: string; document_types?: Array<Record<string, unknown>>;
label: string; current_documents?: Array<Record<string, unknown>>;
required: boolean; }>(database, "core.fn_worker_checklist_raw", { worker_id: workerId, tenant_id: tenantId });
validity_mode: string; if (env.ok && env.data?.items) {
freshness_days: number | null; return {
requires_issued_at: boolean; ctx: (env.data.ctx ?? env.data.imss_context) as WorkerImssContext,
requires_expires_at: boolean; freshness_required: Boolean(env.data.freshness_required),
category: string; items: env.data.items,
}[]; };
const currentDocs = await database.prepare( }
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at const types = env.data?.document_types ?? [];
FROM documents WHERE worker_id = ? AND is_current = true`, const currentDocs = env.data?.current_documents ?? [];
).all(workerId) as { const ctx = (env.data?.imss_context ?? await workerImssContext(database, workerId)) as WorkerImssContext;
type_code: string; const byType = new Map(currentDocs.map((d) => [String(d.type_code), d]));
issued_at: string | null; const items = types.map((t) => {
expires_at: string | null; const policy = {
uploaded_at: string; code: String(t.code),
imss_alta_at: string | null; label: String(t.label),
}[]; required: !!t.required,
const byType = new Map(currentDocs.map((d) => [d.type_code, d])); validity_mode: (String(t.validity_mode || "none")) as "none" | "freshness" | "expiry",
const ctx = await workerImssContext(database, workerId); freshness_days: t.freshness_days as number | null,
requires_issued_at: !!t.requires_issued_at,
return { requires_expires_at: !!t.requires_expires_at,
ctx, };
freshness_required: freshnessRequired(ctx), const evaled = evaluateDocumentValidity(policy, byType.get(policy.code) as never, ctx, today);
items: types.map((t) => { return {
const policy = { code: policy.code,
code: t.code, label: policy.label,
label: t.label, required: policy.required,
required: !!t.required, validity_mode: policy.validity_mode,
validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry", freshness_days: t.freshness_days,
freshness_days: t.freshness_days, requires_issued_at: policy.requires_issued_at,
requires_issued_at: !!t.requires_issued_at, requires_expires_at: policy.requires_expires_at,
requires_expires_at: !!t.requires_expires_at, category: String(t.category || "otro"),
}; present: evaled.present,
const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today); valid: evaled.valid,
return { validity_status: evaled.validity_status,
code: t.code, issued_at: evaled.issued_at,
label: t.label, expires_at: evaled.expires_at,
required: !!t.required, };
validity_mode: policy.validity_mode, });
freshness_days: t.freshness_days, return { ctx, freshness_required: freshnessRequired(ctx), items };
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,
};
}),
};
} }
export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) { export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) {
const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId); 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 requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
const inProject = !!await database.prepare( const env = await callCoreFn<{ workers: Array<Record<string, unknown>> }>(
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1", database, "core.fn_worker_list", { tenant_id: tenantId },
).get(workerId); );
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"; const hasImss = ctx.imss_status === "alta";
return { return {
imss_status: ctx.imss_status, imss_status: ctx.imss_status,
freshness_required: needFresh, freshness_required: needFresh,
imss_ready: requiredOk && !hasImss, imss_ready: requiredOk && !hasImss,
in_project_without_imss: inProject && !hasImss, in_project_without_imss: false,
expediente_ok: requiredOk, 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<void> { export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise<void> {
const w = await database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as const env = await callCoreFn(database, "core.fn_worker_refresh_pipeline", {
| { status: string } worker_id: workerId, tenant_id: tenantId,
| undefined; });
if (!w) return; if (!env.ok) throw new RpcCallError(env);
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);
} }
export async function projectChecklistFor(database: Db, projectId: number) { export async function projectChecklistFor(database: Db, projectId: number) {
const types = await database.prepare( const env = await callCoreFn<{ checklist: unknown[] }>(
"SELECT code, label, required, category FROM project_document_types", database, "core.fn_project_checklist", { project_id: projectId },
).all() as { code: string; label: string; required: boolean; category: string }[]; );
const current = await database.prepare( if (!env.ok) throw new RpcCallError(env);
`SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = true`, return env.data?.checklist ?? [];
).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),
}));
} }
export async function companyChecklistFor(database: Db, companyId: number) { export async function companyChecklistFor(database: Db, companyId: number) {
const types = await database.prepare( const env = await callCoreFn<{ checklist: unknown[] }>(
"SELECT code, label, required, category FROM company_document_types", database, "core.fn_company_checklist", { company_id: companyId },
).all() as { code: string; label: string; required: boolean; category: string }[]; );
const current = await database.prepare( if (!env.ok) throw new RpcCallError(env);
`SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = true`, return env.data?.checklist ?? [];
).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),
}));
} }
/** 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<string> { export async function tenantTimezone(database: Db, tenantId: number | null): Promise<string> {
if (tenantId == null) return "America/Mexico_City"; if (tenantId == null) return "America/Mexico_City";
const row = await database.prepare( const env = await callCoreFn<{ timezone: string }>(
"SELECT timezone FROM tenant_settings WHERE tenant_id = ?", database, "core.fn_tenant_timezone_get", { tenant_id: tenantId },
).get(tenantId) as { timezone: string } | undefined; );
return row?.timezone || "America/Mexico_City"; 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 { export function todayInTimezone(timezone: string, now = new Date()): string {
return now.toLocaleDateString("en-CA", { timeZone: timezone }); return now.toLocaleDateString("en-CA", { timeZone: timezone });
} }

View file

@ -3,9 +3,10 @@ import type { Db } from "./db.ts";
import { encryptBytes } from "./docs_crypto.ts"; import { encryptBytes } from "./docs_crypto.ts";
import { sha256Hex } from "./crypto.ts"; import { sha256Hex } from "./crypto.ts";
import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts";
import { refreshPipeline, lastInsertId } from "./db.ts"; import { refreshPipeline } from "./db.ts";
import { resolveCompany } from "./companies.ts"; import { resolveCompany } from "./companies.ts";
import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts"; import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts";
import { callCoreFn, RpcCallError } from "./rpc.ts";
export const IMPORT_COLUMNS = [ export const IMPORT_COLUMNS = [
"NOMBRE", "NOMBRE",
@ -190,12 +191,12 @@ export function buildImportTemplate(companies: { code: string; name: string }[]
} }
async function findExisting(db: Db, curp: string, rfc: string, nss: string) { async function findExisting(db: Db, curp: string, rfc: string, nss: string) {
return await db.prepare( const env = await callCoreFn<{
`SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null;
WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`, found?: boolean;
).get(curp, rfc, nss) as }>(db, "core.fn_worker_find_existing", { curp, rfc, nss });
| { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } if (!env.ok || !env.data?.found) return undefined;
| undefined; return env.data.worker;
} }
export async function storeDocument( export async function storeDocument(
@ -214,82 +215,34 @@ export async function storeDocument(
imss_baja_at?: string | null; imss_baja_at?: string | null;
} = {}, } = {},
) { ) {
const allowed = await db.prepare("SELECT code FROM document_types WHERE code = ?").get(type);
if (!allowed) throw new Error("Tipo de documento no válido");
const { iv, cipher } = await encryptBytes(bytes); const { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes); const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`; const storage = `${crypto.randomUUID()}.enc`;
await putObject(workerDocKey(workerId, storage), cipher); await putObject(workerDocKey(workerId, storage), cipher);
await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run(
workerId,
type,
);
const issuedAt = meta.issued_at || null; const issuedAt = meta.issued_at || null;
const expiresAt = meta.expires_at || null; const expiresAt = meta.expires_at || null;
const imssCompanyId = meta.imss_company_id || null; const imssCompanyId = meta.imss_company_id || null;
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null); const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null);
const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null);
const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt;
await db.prepare( const env = await callCoreFn(db, "core.fn_worker_document_store", {
`INSERT INTO documents worker_id: workerId,
(worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, type_code: type,
parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by_id, uploaded_by_name) original_name: filename,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`,
).run(
workerId,
type,
filename,
mime, mime,
bytes.byteLength, size_bytes: bytes.byteLength,
hash, sha256: hash,
iv, iv,
storage, storage_name: storage,
issuedAt || movementDate, uploaded_by_id: userId,
expiresAt, issued_at: issuedAt,
imssCompanyId, expires_at: expiresAt,
movementDate, imss_company_id: imssCompanyId,
userId, imss_alta_at: imssAltaAt,
); imss_baja_at: imssBajaAt,
});
if (type === "alta_imss") { if (!env.ok) throw new RpcCallError(env);
const companyId = imssCompanyId ||
(await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined)
?.company_id ||
null;
const company = companyId
? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as
| { id: number; code: string }
| undefined
: undefined;
if (!company) throw new Error("Empresa patrón no válida");
await db.prepare(
`UPDATE workers SET
imss_status = 'alta',
imss_company_id = ?,
imss_alta_at = ?,
imss_baja_at = NULL,
company_id = ?,
hire_type = ?,
updated_at = now()
WHERE id = ?`,
).run(company.id, imssAltaAt, company.id, company.code, workerId);
}
if (type === "baja_imss") {
await db.prepare(
`UPDATE workers SET
imss_status = 'baja_imss',
imss_baja_at = ?,
imss_company_id = NULL,
company_id = NULL,
hire_type = '',
updated_at = now()
WHERE id = ?`,
).run(imssBajaAt, workerId);
}
await refreshPipeline(db, workerId); await refreshPipeline(db, workerId);
} }
@ -303,21 +256,23 @@ export async function storeProjectDocument(
bytes: Uint8Array, bytes: Uint8Array,
userId: number | null, 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 { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes); const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`; const storage = `${crypto.randomUUID()}.enc`;
await putObject(projectDocKey(projectId, storage), cipher); await putObject(projectDocKey(projectId, storage), cipher);
await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run( const env = await callCoreFn(db, "core.fn_project_document_store", {
projectId, project_id: projectId,
type, type_code: type,
); original_name: filename,
await db.prepare( mime,
`INSERT INTO project_documents size_bytes: bytes.byteLength,
(project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) sha256: hash,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, iv,
).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); storage_name: storage,
uploaded_by_id: userId,
uploaded_by_name: "",
});
if (!env.ok) throw new RpcCallError(env);
} }
export async function storeCompanyDocument( export async function storeCompanyDocument(
@ -329,21 +284,23 @@ export async function storeCompanyDocument(
bytes: Uint8Array, bytes: Uint8Array,
userId: number | null, 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 { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes); const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`; const storage = `${crypto.randomUUID()}.enc`;
await putObject(companyDocKey(companyId, storage), cipher); await putObject(companyDocKey(companyId, storage), cipher);
await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run( const env = await callCoreFn(db, "core.fn_company_document_store", {
companyId, company_id: companyId,
type, type_code: type,
); original_name: filename,
await db.prepare( mime,
`INSERT INTO company_documents size_bytes: bytes.byteLength,
(company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) sha256: hash,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, iv,
).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); storage_name: storage,
uploaded_by_id: userId,
uploaded_by_name: "",
});
if (!env.ok) throw new RpcCallError(env);
} }
async function fetchPhoto(url: string): Promise<Uint8Array | null> { async function fetchPhoto(url: string): Promise<Uint8Array | null> {
@ -357,92 +314,6 @@ async function fetchPhoto(url: string): Promise<Uint8Array | null> {
} }
} }
async function upsertWorker(
db: Db,
input: ReturnType<typeof normalizeWorker> & { 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 = { export type ImportReport = {
inserted: number; inserted: number;
existed: number; existed: number;
@ -452,6 +323,79 @@ export type ImportReport = {
error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[]; error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[];
}; };
type ImportBatchResult = {
inserted: number;
existed: number;
errors: number;
existed_rows: ImportReport["existed_rows"];
error_rows: ImportReport["error_rows"];
};
function workerToImportRow(
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
sheet: string,
row: number,
projectId: number | null,
) {
return {
sheet,
row,
first_name: input.first_name,
middle_name: input.middle_name,
last_name_p: input.last_name_p,
last_name_m: input.last_name_m,
curp: input.curp,
rfc: input.rfc,
nss: input.nss,
phone: input.phone,
email: input.email,
address: input.address,
blood_type: input.blood_type,
hire_type: input.hire_type,
company_id: input.company_id,
position: input.position,
risk_code: input.risk_code,
work_type: input.work_type,
daily_wage: input.daily_wage,
needs_badge: input.needs_badge,
status: input.status,
project_id: input.status === "activo" ? projectId : null,
tenant_id: input.tenant_id ?? 1,
};
}
async function upsertWorker(
db: Db,
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
projectId: number | null,
) {
const env = await callCoreFn<ImportBatchResult>(db, "core.fn_workers_import_batch", {
tenant_id: input.tenant_id ?? 1,
project_id: projectId,
rows: [workerToImportRow(input, "IMPORT", 1, projectId)],
});
if (!env.ok) throw new RpcCallError(env);
const data = env.data!;
const errorRow = data.error_rows?.[0];
if (errorRow?.messages?.length) throw new Error(errorRow.messages[0]);
const existing = await findExisting(db, input.curp, input.rfc, input.nss);
if (!existing) 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( export async function importExcel(
db: Db, db: Db,
bytes: Uint8Array, bytes: Uint8Array,
@ -459,9 +403,9 @@ export async function importExcel(
userId: number | null, userId: number | null,
): Promise<ImportReport> { ): Promise<ImportReport> {
const wb = XLSX.read(bytes, { type: "array" }); const wb = XLSX.read(bytes, { type: "array" });
const risks = new Set( const catEnv = await callCoreFn<{ risks?: { code: string }[] }>(db, "core.fn_catalogs", {});
(await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code), if (!catEnv.ok) throw new RpcCallError(catEnv);
); const risks = new Set((catEnv.data?.risks ?? []).map((r) => r.code));
const report: ImportReport = { const report: ImportReport = {
inserted: 0, inserted: 0,
existed: 0, existed: 0,
@ -496,6 +440,9 @@ export async function importExcel(
return report; return report;
} }
const batchRows: ReturnType<typeof workerToImportRow>[] = [];
const photoJobs: { curp: string; rfc: string; nss: string; url: string; tenant_id: number }[] = [];
for (const job of jobs) { for (const job of jobs) {
for (const { row, data } of job.rows) { for (const { row, data } of job.rows) {
if (isEmptyRow(data)) continue; if (isEmptyRow(data)) continue;
@ -539,28 +486,41 @@ export async function importExcel(
company_id: company!.id, company_id: company!.id,
tenant_id: company!.tenant_id ?? 1, tenant_id: company!.tenant_id ?? 1,
}; };
const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null); batchRows.push(workerToImportRow(n, job.sheet, row, n.status === "activo" ? projectId : null));
if (res.action === "inserted") report.inserted++;
else {
report.existed++;
report.existed_rows.push({
sheet: job.sheet,
row,
nombre,
curp: n.curp,
matched: res.matched || "CURP",
});
}
const url = data["URL FOTO"]; const url = data["URL FOTO"];
if (url) { if (url) {
const photo = await fetchPhoto(url); photoJobs.push({ curp: n.curp, rfc: n.rfc, nss: n.nss, url, tenant_id: n.tenant_id });
if (photo) {
await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId);
report.photos++;
}
} }
} }
} }
if (batchRows.length > 0) {
const env = await callCoreFn<ImportBatchResult>(db, "core.fn_workers_import_batch", {
project_id: projectId,
rows: batchRows,
});
if (!env.ok) throw new RpcCallError(env);
const data = env.data!;
report.inserted = data.inserted ?? 0;
report.existed = data.existed ?? 0;
report.errors += data.errors ?? 0;
report.existed_rows.push(...(data.existed_rows ?? []));
report.error_rows.push(...(data.error_rows ?? []));
for (const row of batchRows) {
const worker = await findExisting(db, row.curp, row.rfc, row.nss);
if (worker) await refreshPipeline(db, worker.id, row.tenant_id ?? null);
}
}
for (const photo of photoJobs) {
const worker = await findExisting(db, photo.curp, photo.rfc, photo.nss);
if (!worker) continue;
const photoBytes = await fetchPhoto(photo.url);
if (photoBytes) {
await storeDocument(db, worker.id, "foto", "foto-import.jpg", "image/jpeg", photoBytes, userId);
report.photos++;
}
}
return report; return report;
} }

130
api/http_errors.ts Normal file
View file

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

15
api/http_errors_test.ts Normal file
View file

@ -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);
});

File diff suppressed because it is too large Load diff

View file

@ -117,921 +117,124 @@ export function loanConditionLabel(
return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`; 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<PayrollSettings> { export async function getSettings(db: Db, tenantId: number): Promise<PayrollSettings> {
await seedDefaults(db, tenantId); const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_get", { tenant_id: tenantId });
return await db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings; if (!env.ok) throwFromRpc(env);
return env.data!.settings;
} }
export async function saveSettings( export async function saveSettings(db: Db, tenantId: number, patch: Partial<PayrollSettings>): Promise<PayrollSettings> {
db: Db, const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_save", { tenant_id: tenantId, ...patch });
tenantId: number, if (!env.ok) throwFromRpc(env);
patch: Partial<PayrollSettings>, return env.data!.settings;
): Promise<PayrollSettings> {
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 seedDefaults(db: Db, tenantId: number): Promise<void> { export async function seedDefaults(db: Db, tenantId: number): Promise<void> { await getSettings(db, tenantId); }
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 listUnits(db: Db, tenantId: number) { export async function listUnits(db: Db, tenantId: number) {
await seedDefaults(db, tenantId); const env = await callCoreFn<{ units: unknown[] }>(db, "core.fn_payroll_unit_list", { tenant_id: tenantId });
return await db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId); if (!env.ok) throwFromRpc(env);
return env.data?.units ?? [];
} }
export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise<number> { export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise<number> {
const c = code.trim().toLowerCase().replace(/\s+/g, ""); const env = await callCoreFn<{ id: number }>(db, "core.fn_payroll_unit_add", { tenant_id: tenantId, code, label });
const l = label.trim() || c; if (!env.ok) throwFromRpc(env);
if (!c) throw new Error("Unidad requerida"); return Number(env.data?.id);
await db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l);
return await db.lastInsertId();
} }
export function firstDueForLoan(opts: { export async function createLoan(db: Db, tenantId: number, body: Record<string, unknown>): Promise<{ id: number }> {
delivered: number; const env = await callCoreFn<{ id: number }>(db, "core.fn_loan_create", { tenant_id: tenantId, ...body });
smallMax: number; if (!env.ok) throwFromRpc(env);
plan: "single" | "installments"; return { id: Number(env.data?.id) };
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( export async function setAttendance(db: Db, tenantId: number, body: { project_id: number; worker_id: number; work_date: string; present: boolean }) {
db: Db, const env = await callCoreFn(db, "core.fn_attendance_set", { tenant_id: tenantId, ...body });
tenantId: number, if (!env.ok) return { error: env.message, status: mapRpcToStatus(String(env.code)), other: (env.data as { other?: unknown })?.other };
body: { return { ok: true as const };
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 ensureWeek(db: Db, tenantId: number, weekStart: string): Promise<number> { export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise<number> {
await seedDefaults(db, tenantId); const env = await callCoreFn<{ week_id: number }>(db, "core.fn_payroll_week_ensure", { tenant_id: tenantId, week_start: weekStart });
const { weekEnd } = weekContaining(weekStart); if (!env.ok) throwFromRpc(env);
const start = weekContaining(weekStart).weekStart; return Number(env.data?.week_id);
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<number> {
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<LoanRow[]> {
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<number> {
const row = await db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number };
return Number(row.n);
} }
export async function recalcWeek(db: Db, weekId: number): Promise<void> { export async function recalcWeek(db: Db, weekId: number): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { const env = await callCoreFn(db, "core.fn_payroll_week_recalc", { week_id: weekId });
id: number; if (!env.ok) throwFromRpc(env);
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<number>();
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<number, typeof allLines>();
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<void> {
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);
}
} }
export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise<void> { export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { const env = await callCoreFn(db, "core.fn_payroll_week_assemble", { week_id: weekId, today: nowIso });
id: number; if (!env.ok) throwFromRpc(env);
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);
} }
export async function payWeek(db: Db, weekId: number, nowIso: string): Promise<void> { export async function payWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { const env = await callCoreFn(db, "core.fn_payroll_week_pay", { week_id: weekId, today: nowIso });
id: number; if (!env.ok) throwFromRpc(env);
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);
} }
export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise<void> { export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; const env = await callCoreFn(db, "core.fn_payroll_jornal_worker_add", { week_id: weekId, project_id: projectId, worker_id: workerId });
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); if (!env.ok) throwFromRpc(env);
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);
} }
export async function addAdminLine( export async function addAdminLine(db: Db, weekId: number, body: { worker_id: number; amount: number; project_id?: number | null }): Promise<void> {
db: Db, const env = await callCoreFn(db, "core.fn_payroll_admin_line_add", { week_id: weekId, ...body });
weekId: number, if (!env.ok) throwFromRpc(env);
body: { worker_id: number; amount: number; project_id?: number | null },
): Promise<void> {
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 updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise<void> { export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise<void> {
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; const env = await callCoreFn(db, "core.fn_payroll_admin_line_update", { week_id: weekId, line_id: lineId, amount });
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); if (!env.ok) throwFromRpc(env);
await db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId);
await recalcWeek(db, weekId);
} }
export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise<void> { export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise<void> {
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined; const env = await callCoreFn(db, "core.fn_payroll_admin_line_remove", { week_id: weekId, line_id: lineId });
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 }); if (!env.ok) throwFromRpc(env);
await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId);
await recalcWeek(db, weekId);
} }
export async function jobRemainder(db: Db, jobId: number): Promise<number> { export async function createDestajoJob(db: Db, tenantId: number, weekId: number, body: Record<string, unknown>): Promise<{ id: number }> {
const job = await db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as const env = await callCoreFn<{ id: number }>(db, "core.fn_destajo_job_create", { tenant_id: tenantId, week_id: weekId, ...body });
| { qty_total_estimated: number } if (!env.ok) throwFromRpc(env);
| undefined; return { id: Number(env.data?.id) };
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( export async function patchDestajoCut(db: Db, weekId: number, cutId: number, patch: Record<string, unknown>): Promise<void> {
db: Db, const env = await callCoreFn(db, "core.fn_destajo_cut_patch", { week_id: weekId, cut_id: cutId, ...patch });
tenantId: number, if (!env.ok) throwFromRpc(env);
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: { qty_planned?: number; qty_actual?: number; qty_extra?: number },
): Promise<void> {
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<void> {
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) { export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) {
const weekId = await ensureWeek(db, tenantId, weekStart); await ensureWeek(db, tenantId, weekStart);
await ensureCutForOpenJobs(db, weekId); const env = await callCoreFn<Record<string, unknown>>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart });
await recalcWeek(db, weekId); if (!env.ok) throwFromRpc(env);
const week = await db.prepare( const data = env.data ?? {};
`SELECT * FROM payroll_weeks WHERE id=?`,
).get(weekId) as Record<string, unknown>;
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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>[];
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));
const today = await resolveToday(db, tenantId); const today = await resolveToday(db, tenantId);
return { const start = String((data.week as Record<string, unknown>)?.week_start ?? weekStart);
week, return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) };
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),
};
} }
export async function weekCsv(db: Db, weekId: number): Promise<string> { export async function weekCsv(db: Db, weekId: number): Promise<string> {
const lines = await db.prepare( const env = await callCoreFn<{ csv: string }>(db, "core.fn_payroll_week_csv", { week_id: weekId });
`SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name, if (!env.ok) throwFromRpc(env);
l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code, return String(env.data?.csv ?? "");
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<string, unknown>[];
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");
} }
export async function listOpenWeeks(db: Db, tenantId: number) { export async function listOpenWeeks(db: Db, tenantId: number) {
return await db.prepare( const env = await callCoreFn<{ weeks: unknown[] }>(db, "core.fn_payroll_week_list_open", { tenant_id: tenantId });
`SELECT w.*, if (!env.ok) throwFromRpc(env);
(SELECT COALESCE(SUM(l.loan_discount), 0) return env.data?.weeks ?? [];
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);
} }

View file

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

125
api/rpc.ts Normal file
View file

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

View file

@ -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 `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 lógica de seed en el arranque de la API ni con datos hardcodeados en
Liquibase. 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).

View file

@ -12,5 +12,15 @@
<include file="changesets/003-seed-catalogs.sql" relativeToChangelogFile="true"/> <include file="changesets/003-seed-catalogs.sql" relativeToChangelogFile="true"/>
<include file="changesets/004-seed-dev-data.sql" relativeToChangelogFile="true"/> <include file="changesets/004-seed-dev-data.sql" relativeToChangelogFile="true"/>
<include file="changesets/005-rls.sql" relativeToChangelogFile="true"/> <include file="changesets/005-rls.sql" relativeToChangelogFile="true"/>
<include file="changesets/006-rpc-infra.sql" relativeToChangelogFile="true"/>
<include file="changesets/007-rpc-catalogs.sql" relativeToChangelogFile="true"/>
<include file="changesets/008-rpc-companies.sql" relativeToChangelogFile="true"/>
<include file="changesets/009-rpc-projects.sql" relativeToChangelogFile="true"/>
<include file="changesets/010-rpc-workers.sql" relativeToChangelogFile="true"/>
<include file="changesets/011-rpc-budget.sql" relativeToChangelogFile="true"/>
<include file="changesets/012-rpc-badges.sql" relativeToChangelogFile="true"/>
<include file="changesets/013-rpc-payroll.sql" relativeToChangelogFile="true"/>
<include file="changesets/014-rpc-import.sql" relativeToChangelogFile="true"/>
<include file="changesets/015-rpc-documents.sql" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

View file

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

View file

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

View file

@ -0,0 +1,639 @@
--liquibase formatted sql
-- PANELS · core · RPC empresas (CRUD)
--changeset panel:core-008a-company-helpers endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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;

View file

@ -0,0 +1,604 @@
--liquibase formatted sql
-- PANELS · core · RPC proyectos (obras)
--changeset panel:core-009a-fn-next-project-code endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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;

File diff suppressed because it is too large Load diff

View file

@ -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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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;

View file

@ -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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,566 @@
--liquibase formatted sql
-- PANELS · core · RPC importación de personal y metadatos documentales
--changeset panel:core-014a-import-helpers endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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;

View file

@ -0,0 +1,164 @@
--liquibase formatted sql
-- PANELS · core · RPC documentos (metadata post-S3)
--changeset panel:core-015a-fn-project-document-store endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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 endDelimiter:; splitStatements:true
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;

View file

@ -0,0 +1,59 @@
export type FrontRpcCode = "VALIDATION" | "NETWORK" | "INTERNAL";
export type FrontResponse<T = unknown> = {
ok: boolean;
code: FrontRpcCode | string;
layer: "front";
message: string;
context?: Record<string, unknown>;
data?: T | null;
errors?: Record<string, string> | string[] | null;
};
export function buildFrontError(
message: string,
code: FrontRpcCode = "VALIDATION",
context?: Record<string, unknown>,
errors?: Record<string, string> | 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;
}

View file

@ -0,0 +1,58 @@
export type FrontRpcCode = "VALIDATION" | "NETWORK" | "INTERNAL";
export type FrontResponse<T = unknown> = {
ok: boolean;
code: FrontRpcCode | string;
layer: "front";
message: string;
context?: Record<string, unknown>;
data?: T | null;
errors?: Record<string, string> | string[] | null;
};
export function buildFrontError(
message: string,
code: FrontRpcCode = "VALIDATION",
context?: Record<string, unknown>,
errors?: Record<string, string> | 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;
}