refactor(core): finish RPC migration for budget, excel import, and docs

- Replace remaining budget.ts prepare() with fn_budget_replace and fn_project_update
- Migrate excel.ts findExisting, document store, assign, and catalogs to RPC
- Add 015-rpc-documents.sql for project/company/worker document metadata
- Document RPC envelope conventions in db/README.md
- Add http_errors_test and front api-response composables

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-04 00:01:57 +00:00
parent 059ecf69f8
commit b58791aea4
No known key found for this signature in database
11 changed files with 643 additions and 1315 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
// solo ve app.tenant_id); este chequeo explícito es defensa adicional y
// un mensaje de error más claro que un 404 "silencioso".
if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) {
return { error: "Empresa no encontrada", status: 404 };
}
const 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, id,
); tenant_id: tenantId ?? null,
} catch (e) { ...input,
if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 }; });
throw e; if (!env.ok) {
const st = mapRpcToStatus(String(env.code));
return { error: env.message, status: st === 404 ? 404 : 400 };
} }
if (code !== current.code) { return { company: env.data?.company as Company };
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";
} }

231
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;
requires_issued_at: boolean;
requires_expires_at: boolean;
category: string;
}[];
const currentDocs = await database.prepare(
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
FROM documents WHERE worker_id = ? AND is_current = true`,
).all(workerId) as {
type_code: string;
issued_at: string | null;
expires_at: string | null;
uploaded_at: string;
imss_alta_at: string | null;
}[];
const byType = new Map(currentDocs.map((d) => [d.type_code, d]));
const ctx = await workerImssContext(database, workerId);
return { return {
ctx, ctx: (env.data.ctx ?? env.data.imss_context) as WorkerImssContext,
freshness_required: freshnessRequired(ctx), freshness_required: Boolean(env.data.freshness_required),
items: types.map((t) => { items: env.data.items,
};
}
const types = env.data?.document_types ?? [];
const currentDocs = env.data?.current_documents ?? [];
const ctx = (env.data?.imss_context ?? await workerImssContext(database, workerId)) as WorkerImssContext;
const byType = new Map(currentDocs.map((d) => [String(d.type_code), d]));
const items = types.map((t) => {
const policy = { const policy = {
code: t.code, code: String(t.code),
label: t.label, label: String(t.label),
required: !!t.required, required: !!t.required,
validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry", validity_mode: (String(t.validity_mode || "none")) as "none" | "freshness" | "expiry",
freshness_days: t.freshness_days, freshness_days: t.freshness_days as number | null,
requires_issued_at: !!t.requires_issued_at, requires_issued_at: !!t.requires_issued_at,
requires_expires_at: !!t.requires_expires_at, requires_expires_at: !!t.requires_expires_at,
}; };
const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today); const evaled = evaluateDocumentValidity(policy, byType.get(policy.code) as never, ctx, today);
return { return {
code: t.code, code: policy.code,
label: t.label, label: policy.label,
required: !!t.required, required: policy.required,
validity_mode: policy.validity_mode, validity_mode: policy.validity_mode,
freshness_days: t.freshness_days, freshness_days: t.freshness_days,
requires_issued_at: policy.requires_issued_at, requires_issued_at: policy.requires_issued_at,
requires_expires_at: policy.requires_expires_at, requires_expires_at: policy.requires_expires_at,
category: t.category || "otro", category: String(t.category || "otro"),
present: evaled.present, present: evaled.present,
valid: evaled.valid, valid: evaled.valid,
validity_status: evaled.validity_status, validity_status: evaled.validity_status,
issued_at: evaled.issued_at, issued_at: evaled.issued_at,
expires_at: evaled.expires_at, expires_at: evaled.expires_at,
}; };
}), });
}; return { ctx, freshness_required: freshnessRequired(ctx), items };
} }
export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) { 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

@ -191,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(
@ -256,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(
@ -282,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> {
@ -385,14 +389,11 @@ async function upsertWorker(
} }
async function assign(db: Db, workerId: number, projectId: number) { async function assign(db: Db, workerId: number, projectId: number) {
await db.prepare( const env = await callCoreFn(db, "core.fn_worker_assign", {
`INSERT INTO assignments (worker_id, project_id, active, start_date) worker_id: workerId,
VALUES (?, ?, true, current_date) project_id: projectId,
ON CONFLICT(worker_id, project_id) DO UPDATE SET });
active=true, if (!env.ok) throw new RpcCallError(env);
start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END,
end_date=NULL`,
).run(workerId, projectId);
} }
export async function importExcel( export async function importExcel(
@ -402,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,

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

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

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