mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 22:23:18 +00:00
Pre-computed merge for change #2
This commit is contained in:
commit
bf28e36f81
24 changed files with 8266 additions and 2025 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import * as XLSX from "xlsx";
|
||||
import type { Db } from "./db.ts";
|
||||
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||
|
||||
export const BUDGET_IVA = 0.16;
|
||||
|
||||
|
|
@ -642,17 +643,14 @@ function compareWbs(a: string, b: string) {
|
|||
}
|
||||
|
||||
export async function listBudget(database: Db, projectId: number) {
|
||||
const chapters = await database.prepare(
|
||||
`SELECT c.id, c.project_id, c.parent_id, c.code, c.name, COALESCE(c.wbs, '') AS wbs, c.sort_order
|
||||
FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`,
|
||||
).all(projectId) as ChapterRow[];
|
||||
const rawItems = await database.prepare(
|
||||
`SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code
|
||||
FROM budget_items i
|
||||
LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id
|
||||
WHERE i.project_id = ?
|
||||
ORDER BY COALESCE(ch.sort_order, 9999), i.sort_order, i.id`,
|
||||
).all(projectId) as (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
|
||||
const env = await callCoreFn<{
|
||||
chapters: ChapterRow[];
|
||||
items: (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
|
||||
totals: { subtotal: number; iva: number; total: number; item_count: number };
|
||||
}>(database, "core.fn_budget_list", { project_id: projectId });
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
const chapters = env.data?.chapters ?? [];
|
||||
const rawItems = env.data?.items ?? [];
|
||||
|
||||
const byParent = new Map<number | null, 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 iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
||||
const iva = env.data?.totals?.iva ?? Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
||||
const chapterView = chapters.map((chapter) => {
|
||||
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
|
||||
const chain = ancestorsOf(chapter.id);
|
||||
|
|
@ -744,44 +742,34 @@ export async function listBudget(database: Db, projectId: number) {
|
|||
};
|
||||
}
|
||||
|
||||
async function insertChapter(
|
||||
database: Db,
|
||||
projectId: number,
|
||||
parentId: number | null,
|
||||
code: string,
|
||||
name: string,
|
||||
wbs: string,
|
||||
sort: number,
|
||||
): Promise<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> {
|
||||
await database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId);
|
||||
await database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId);
|
||||
const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs));
|
||||
const ids = new Map<string, number>();
|
||||
let sort = 1;
|
||||
for (const group of groups) {
|
||||
const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null;
|
||||
const id = await insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort);
|
||||
ids.set(group.wbs, id);
|
||||
sort++;
|
||||
}
|
||||
const chapters = groups.map((group) => ({
|
||||
wbs: group.wbs,
|
||||
parent_wbs: group.parentWbs || null,
|
||||
code: group.code,
|
||||
name: group.name,
|
||||
sort_order: sort++,
|
||||
}));
|
||||
let itemSort = 1;
|
||||
for (const item of parsed.items) {
|
||||
const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null;
|
||||
const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100;
|
||||
await database.prepare(
|
||||
`INSERT INTO budget_items
|
||||
(project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs);
|
||||
itemSort++;
|
||||
}
|
||||
const items = parsed.items.map((item) => ({
|
||||
chapter_wbs: item.parentWbs || null,
|
||||
code: item.code,
|
||||
description: item.description,
|
||||
unit: item.unit,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
amount: Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100,
|
||||
wbs: item.wbs,
|
||||
sort_order: itemSort++,
|
||||
}));
|
||||
const env = await callCoreFn(database, "core.fn_budget_replace", {
|
||||
project_id: projectId,
|
||||
chapters,
|
||||
items,
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
}
|
||||
|
||||
export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
|
||||
|
|
@ -927,7 +915,11 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes:
|
|||
item_count: parsed.items.length,
|
||||
};
|
||||
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado.
|
||||
await database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId);
|
||||
const projectEnv = await callCoreFn(database, "core.fn_project_update", {
|
||||
id: projectId,
|
||||
contract_amount: subtotal,
|
||||
});
|
||||
if (!projectEnv.ok) throw new RpcCallError(projectEnv);
|
||||
return {
|
||||
replaced: true,
|
||||
chapters: parsed.groups.length,
|
||||
|
|
|
|||
234
api/companies.ts
234
api/companies.ts
|
|
@ -1,4 +1,7 @@
|
|||
import type { Db } from "./db.ts";
|
||||
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||
import type { RpcEnvelope } from "./http_errors.ts";
|
||||
import { mapRpcToStatus } from "./http_errors.ts";
|
||||
|
||||
export type Company = {
|
||||
id: number;
|
||||
|
|
@ -60,43 +63,35 @@ function normRfc(value: unknown): string {
|
|||
return trimText(value).toUpperCase().replace(/\s+/g, "");
|
||||
}
|
||||
|
||||
function envelopeError<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[]> {
|
||||
// El filtro tenant_id aquí es defensa adicional/legibilidad -- el
|
||||
// aislamiento real ya lo garantiza Row Level Security sobre la conexión
|
||||
// acotada por withCoreScope (ver db/core/changesets/005-rls.sql).
|
||||
const where = tenantId != null ? "WHERE c.tenant_id = ?" : "";
|
||||
const params = tenantId != null ? [tenantId] : [];
|
||||
return await database.prepare(
|
||||
`SELECT c.*, p.code AS parent_code, p.name AS parent_name,
|
||||
(SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count
|
||||
FROM companies c
|
||||
LEFT JOIN companies p ON p.id = c.parent_id
|
||||
${where}
|
||||
ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, LOWER(c.name)`,
|
||||
).all(...params) as Company[];
|
||||
const env = await callCoreFn<{ companies: Company[] }>(database, "core.fn_company_list", {
|
||||
tenant_id: tenantId ?? null,
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
return (env.data?.companies ?? []) as Company[];
|
||||
}
|
||||
|
||||
export async function companyById(database: Db, id: number): Promise<Company | undefined> {
|
||||
return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as
|
||||
| Company
|
||||
| undefined;
|
||||
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get", { id });
|
||||
if (!env.ok) return undefined;
|
||||
return env.data?.company as Company | undefined;
|
||||
}
|
||||
|
||||
export async function companyByCode(database: Db, code: string): Promise<Company | undefined> {
|
||||
return await database.prepare("SELECT * FROM companies WHERE code = ?").get(
|
||||
normCompanyCode(code),
|
||||
) as Company | undefined;
|
||||
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get_by_code", {
|
||||
code: normCompanyCode(code),
|
||||
});
|
||||
if (!env.ok) return undefined;
|
||||
return env.data?.company as Company | undefined;
|
||||
}
|
||||
|
||||
export async function principalCompany(database: Db, tenantId?: number | null): Promise<Company | undefined> {
|
||||
if (tenantId != null) {
|
||||
return await database.prepare(
|
||||
"SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1",
|
||||
).get(tenantId) as Company | undefined;
|
||||
}
|
||||
return await database.prepare(
|
||||
"SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1",
|
||||
).get() as Company | undefined;
|
||||
const companies = await listCompanies(database, tenantId);
|
||||
return companies.find((c) => c.kind === "principal");
|
||||
}
|
||||
|
||||
export function normCompanyCode(value: string | null | undefined): string {
|
||||
|
|
@ -114,13 +109,9 @@ export function companyCodeFromName(name: string): string {
|
|||
}
|
||||
|
||||
export async function nextCompanyCode(database: Db, name: string): Promise<string> {
|
||||
const base = companyCodeFromName(name);
|
||||
if (!await companyByCode(database, base)) return base;
|
||||
const row = await database.prepare(
|
||||
`SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n
|
||||
FROM companies WHERE code ~ '^EMP-[0-9]{4}'`,
|
||||
).get() as { n: number };
|
||||
return `EMP-${String(row.n).padStart(4, "0")}`;
|
||||
const env = await callCoreFn<{ code: string }>(database, "core.fn_next_company_code", { name });
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
return String(env.data?.code ?? companyCodeFromName(name));
|
||||
}
|
||||
|
||||
export async function resolveCompany(
|
||||
|
|
@ -186,72 +177,19 @@ export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName
|
|||
};
|
||||
}
|
||||
|
||||
function validateProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null {
|
||||
return validateCompanyProfile(input, opts);
|
||||
}
|
||||
|
||||
function profileFromInput(input: CompanyProfileInput, fallbackName = "") {
|
||||
return normalizeCompanyProfile(input, fallbackName);
|
||||
}
|
||||
|
||||
export async function createSubcompany(
|
||||
database: Db,
|
||||
input: CompanyProfileInput,
|
||||
tenantId?: number | null,
|
||||
): Promise<{ company?: Company; error?: string }> {
|
||||
const profileErr = validateProfile(input, { requireLegal: true });
|
||||
if (profileErr) return { error: profileErr };
|
||||
const profile = profileFromInput(input);
|
||||
if (!profile.name) return { error: "Nombre de empresa obligatorio" };
|
||||
|
||||
const principal = await principalCompany(database, tenantId);
|
||||
if (!principal) return { error: "No hay empresa principal" };
|
||||
const parentId = input.parent_id ? Number(input.parent_id) : principal.id;
|
||||
const parent = await companyById(database, parentId);
|
||||
if (!parent) return { error: "Empresa padre no encontrada" };
|
||||
if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) {
|
||||
return { error: "Empresa padre de otro tenant" };
|
||||
}
|
||||
|
||||
let code = normCompanyCode(input.code);
|
||||
if (!code) code = await nextCompanyCode(database, profile.name);
|
||||
const codeErr = validateCompanyCode(code);
|
||||
if (codeErr) return { error: codeErr };
|
||||
if (await companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" };
|
||||
|
||||
const tid = tenantId ?? principal.tenant_id ?? null;
|
||||
try {
|
||||
await database.prepare(
|
||||
`INSERT INTO companies (
|
||||
code, name, parent_id, kind, status, tenant_id,
|
||||
registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
|
||||
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
|
||||
) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
code,
|
||||
profile.name,
|
||||
parent.id,
|
||||
tid,
|
||||
profile.registro_patronal,
|
||||
profile.razon_social,
|
||||
profile.nombre_comercial,
|
||||
profile.rfc,
|
||||
profile.regimen_fiscal,
|
||||
profile.clase_riesgo,
|
||||
profile.domicilio_fiscal,
|
||||
profile.codigo_postal,
|
||||
profile.ciudad,
|
||||
profile.estado,
|
||||
profile.telefono,
|
||||
profile.email,
|
||||
profile.representante_legal,
|
||||
profile.giro,
|
||||
);
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código" };
|
||||
throw e;
|
||||
}
|
||||
return { company: await companyById(database, await database.lastInsertId()) };
|
||||
): Promise<{ company?: Company; error?: string; status?: number }> {
|
||||
const profileErr = validateCompanyProfile(input, { requireLegal: true });
|
||||
if (profileErr) return { error: profileErr, status: 400 };
|
||||
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_create", {
|
||||
...input,
|
||||
tenant_id: tenantId ?? null,
|
||||
});
|
||||
if (!env.ok) return envelopeError(env);
|
||||
return { company: env.data?.company as Company };
|
||||
}
|
||||
|
||||
export async function updateCompany(
|
||||
|
|
@ -260,100 +198,14 @@ export async function updateCompany(
|
|||
input: CompanyProfileInput,
|
||||
tenantId?: number | null,
|
||||
): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> {
|
||||
const current = await companyById(database, id);
|
||||
// Con RLS activo, una fila de otro tenant ya no aparece aquí (la conexión
|
||||
// solo ve app.tenant_id); este chequeo explícito es defensa adicional y
|
||||
// un mensaje de error más claro que un 404 "silencioso".
|
||||
if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) {
|
||||
return { error: "Empresa no encontrada", status: 404 };
|
||||
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_update", {
|
||||
id,
|
||||
tenant_id: tenantId ?? null,
|
||||
...input,
|
||||
});
|
||||
if (!env.ok) {
|
||||
const st = mapRpcToStatus(String(env.code));
|
||||
return { error: env.message, status: st === 404 ? 404 : 400 };
|
||||
}
|
||||
|
||||
const profileErr = validateProfile(input);
|
||||
if (profileErr) return { error: profileErr, status: 400 };
|
||||
|
||||
const merged: CompanyProfileInput = {
|
||||
name: input.name !== undefined ? input.name : current.name,
|
||||
code: input.code !== undefined ? input.code : current.code,
|
||||
status: input.status !== undefined ? input.status : current.status,
|
||||
registro_patronal: input.registro_patronal !== undefined ? input.registro_patronal : current.registro_patronal,
|
||||
razon_social: input.razon_social !== undefined ? input.razon_social : current.razon_social,
|
||||
nombre_comercial: input.nombre_comercial !== undefined ? input.nombre_comercial : current.nombre_comercial,
|
||||
rfc: input.rfc !== undefined ? input.rfc : current.rfc,
|
||||
regimen_fiscal: input.regimen_fiscal !== undefined ? input.regimen_fiscal : current.regimen_fiscal,
|
||||
clase_riesgo: input.clase_riesgo !== undefined ? input.clase_riesgo : current.clase_riesgo,
|
||||
domicilio_fiscal: input.domicilio_fiscal !== undefined ? input.domicilio_fiscal : current.domicilio_fiscal,
|
||||
codigo_postal: input.codigo_postal !== undefined ? input.codigo_postal : current.codigo_postal,
|
||||
ciudad: input.ciudad !== undefined ? input.ciudad : current.ciudad,
|
||||
estado: input.estado !== undefined ? input.estado : current.estado,
|
||||
telefono: input.telefono !== undefined ? input.telefono : current.telefono,
|
||||
email: input.email !== undefined ? input.email : current.email,
|
||||
representante_legal: input.representante_legal !== undefined
|
||||
? input.representante_legal
|
||||
: current.representante_legal,
|
||||
giro: input.giro !== undefined ? input.giro : current.giro,
|
||||
};
|
||||
const profile = profileFromInput(merged, current.name);
|
||||
if (!profile.name) return { error: "Nombre de empresa obligatorio", status: 400 };
|
||||
|
||||
let code = current.code;
|
||||
if (input.code !== undefined) {
|
||||
code = normCompanyCode(input.code);
|
||||
const codeErr = validateCompanyCode(code);
|
||||
if (codeErr) return { error: codeErr, status: 400 };
|
||||
const clash = await companyByCode(database, code);
|
||||
if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 };
|
||||
}
|
||||
|
||||
let status = current.status;
|
||||
if (input.status !== undefined) {
|
||||
if (!["activo", "inactivo"].includes(input.status)) {
|
||||
return { error: "Estatus debe ser activo o inactivo", status: 400 };
|
||||
}
|
||||
if (current.kind === "principal" && input.status === "inactivo") {
|
||||
return { error: "La empresa principal no se puede inactivar", status: 400 };
|
||||
}
|
||||
status = input.status as Company["status"];
|
||||
}
|
||||
|
||||
try {
|
||||
await database.prepare(
|
||||
`UPDATE companies SET
|
||||
name = ?, code = ?, status = ?,
|
||||
registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
|
||||
domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?,
|
||||
representante_legal = ?, giro = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
profile.name,
|
||||
code,
|
||||
status,
|
||||
profile.registro_patronal,
|
||||
profile.razon_social,
|
||||
profile.nombre_comercial,
|
||||
profile.rfc,
|
||||
profile.regimen_fiscal,
|
||||
profile.clase_riesgo,
|
||||
profile.domicilio_fiscal,
|
||||
profile.codigo_postal,
|
||||
profile.ciudad,
|
||||
profile.estado,
|
||||
profile.telefono,
|
||||
profile.email,
|
||||
profile.representante_legal,
|
||||
profile.giro,
|
||||
id,
|
||||
);
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 };
|
||||
throw e;
|
||||
}
|
||||
if (code !== current.code) {
|
||||
await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id);
|
||||
}
|
||||
return { company: await companyById(database, id) };
|
||||
}
|
||||
|
||||
/** Postgres error code 23505 = unique_violation. */
|
||||
function isUniqueViolation(e: unknown): boolean {
|
||||
return !!e && typeof e === "object" && (e as { code?: string }).code === "23505";
|
||||
return { company: env.data?.company as Company };
|
||||
}
|
||||
|
|
|
|||
265
api/db.ts
265
api/db.ts
|
|
@ -1,22 +1,15 @@
|
|||
import postgres, { createPool, PgDb, withTenant } from "./pg.ts";
|
||||
import { config } from "./config.ts";
|
||||
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||
import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
|
||||
|
||||
export type Db = PgDb;
|
||||
|
||||
/**
|
||||
* panels_product, esquema core: negocio (empresas, personal, obras,
|
||||
* expedientes, presupuesto, nómina, gafetes). Aislado de `iam` a propósito
|
||||
* -- este módulo no debe importar iam_db.ts.
|
||||
*/
|
||||
|
||||
let corePool: postgres.Sql | null = null;
|
||||
let coreOwnerPool: postgres.Sql | null = null;
|
||||
|
||||
function getCorePool(): postgres.Sql {
|
||||
if (!corePool) {
|
||||
corePool = createPool(config.databaseUrlCore, { max: 20 });
|
||||
}
|
||||
if (!corePool) corePool = createPool(config.databaseUrlCore, { max: 20 });
|
||||
return corePool;
|
||||
}
|
||||
|
||||
|
|
@ -31,10 +24,6 @@ export async function pingCoreDb(): Promise<void> {
|
|||
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>(
|
||||
tenantId: number | null,
|
||||
fn: (db: Db) => Promise<T>,
|
||||
|
|
@ -42,10 +31,6 @@ export async function withCoreTenant<T>(
|
|||
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> {
|
||||
return new PgDb(getCoreOwnerPool());
|
||||
}
|
||||
|
|
@ -76,17 +61,15 @@ export function isProjectStatus(value: string): value is ProjectStatus {
|
|||
}
|
||||
|
||||
export async function nextProjectCode(database: Db): Promise<string> {
|
||||
const row = await database.prepare(
|
||||
`SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n
|
||||
FROM projects WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}'`,
|
||||
).get() as { n: number };
|
||||
return `PRY-${String(row.n).padStart(4, "0")}`;
|
||||
const env = await callCoreFn<{ code: string }>(database, "core.fn_next_project_code", {});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
return String(env.data?.code ?? "PRY-0001");
|
||||
}
|
||||
|
||||
export async function projectById(database: Db, id: number) {
|
||||
return await database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as
|
||||
| { id: number; code: string; name: string; status: string }
|
||||
| undefined;
|
||||
const env = await callCoreFn<{ project: Record<string, unknown> }>(database, "core.fn_project_get", { id });
|
||||
if (!env.ok) return undefined;
|
||||
return env.data?.project as { id: number; code: string; name: string; status: string } | undefined;
|
||||
}
|
||||
|
||||
export function projectMustBe(
|
||||
|
|
@ -102,104 +85,87 @@ export function projectMustBe(
|
|||
}
|
||||
|
||||
export async function workerImssContext(database: Db, workerId: number): Promise<WorkerImssContext> {
|
||||
const w = await database.prepare(
|
||||
"SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?",
|
||||
).get(workerId) as {
|
||||
imss_status: ImssStatus;
|
||||
imss_alta_at: string | null;
|
||||
last_rehire_at: string | null;
|
||||
} | undefined;
|
||||
const altaDoc = await database.prepare(
|
||||
`SELECT imss_alta_at, uploaded_at FROM documents
|
||||
WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = true
|
||||
LIMIT 1`,
|
||||
).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined;
|
||||
return {
|
||||
imss_status: (w?.imss_status as ImssStatus) || "sin_alta",
|
||||
last_rehire_at: w?.last_rehire_at ?? null,
|
||||
current_alta_at: altaDoc?.imss_alta_at || w?.imss_alta_at || altaDoc?.uploaded_at || null,
|
||||
};
|
||||
const env = await callCoreFn<{ imss_context: WorkerImssContext }>(
|
||||
database, "core.fn_worker_checklist_raw", { worker_id: workerId },
|
||||
);
|
||||
if (env.ok && env.data?.imss_context) return env.data.imss_context;
|
||||
return { imss_status: "sin_alta" as ImssStatus, last_rehire_at: null, current_alta_at: null };
|
||||
}
|
||||
|
||||
/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor.
|
||||
* tenantId (Fase 4d) resuelve la zona horaria del tenant para calcular
|
||||
* "hoy" de forma consistente con nómina -- antes esta función usaba
|
||||
* `new Date()` crudo (hora del servidor/UTC) sin relación con PAYROLL_TZ. */
|
||||
export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) {
|
||||
const tz = await tenantTimezone(database, tenantId);
|
||||
const today = todayInTimezone(tz);
|
||||
const types = await database.prepare(
|
||||
`SELECT code, label, required, validity_mode, freshness_days,
|
||||
requires_issued_at, requires_expires_at, category
|
||||
FROM document_types`,
|
||||
).all() as {
|
||||
code: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
validity_mode: string;
|
||||
freshness_days: number | null;
|
||||
requires_issued_at: boolean;
|
||||
requires_expires_at: boolean;
|
||||
category: string;
|
||||
}[];
|
||||
const currentDocs = await database.prepare(
|
||||
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
|
||||
FROM documents WHERE worker_id = ? AND is_current = true`,
|
||||
).all(workerId) as {
|
||||
type_code: string;
|
||||
issued_at: string | null;
|
||||
expires_at: string | null;
|
||||
uploaded_at: string;
|
||||
imss_alta_at: string | null;
|
||||
}[];
|
||||
const byType = new Map(currentDocs.map((d) => [d.type_code, d]));
|
||||
const ctx = await workerImssContext(database, workerId);
|
||||
|
||||
return {
|
||||
ctx,
|
||||
freshness_required: freshnessRequired(ctx),
|
||||
items: types.map((t) => {
|
||||
const policy = {
|
||||
code: t.code,
|
||||
label: t.label,
|
||||
required: !!t.required,
|
||||
validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry",
|
||||
freshness_days: t.freshness_days,
|
||||
requires_issued_at: !!t.requires_issued_at,
|
||||
requires_expires_at: !!t.requires_expires_at,
|
||||
};
|
||||
const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today);
|
||||
return {
|
||||
code: t.code,
|
||||
label: t.label,
|
||||
required: !!t.required,
|
||||
validity_mode: policy.validity_mode,
|
||||
freshness_days: t.freshness_days,
|
||||
requires_issued_at: policy.requires_issued_at,
|
||||
requires_expires_at: policy.requires_expires_at,
|
||||
category: t.category || "otro",
|
||||
present: evaled.present,
|
||||
valid: evaled.valid,
|
||||
validity_status: evaled.validity_status,
|
||||
issued_at: evaled.issued_at,
|
||||
expires_at: evaled.expires_at,
|
||||
};
|
||||
}),
|
||||
};
|
||||
const env = await callCoreFn<{
|
||||
items?: Array<Record<string, unknown>>;
|
||||
ctx?: WorkerImssContext;
|
||||
imss_context?: WorkerImssContext;
|
||||
freshness_required?: boolean;
|
||||
document_types?: Array<Record<string, unknown>>;
|
||||
current_documents?: Array<Record<string, unknown>>;
|
||||
}>(database, "core.fn_worker_checklist_raw", { worker_id: workerId, tenant_id: tenantId });
|
||||
if (env.ok && env.data?.items) {
|
||||
return {
|
||||
ctx: (env.data.ctx ?? env.data.imss_context) as WorkerImssContext,
|
||||
freshness_required: Boolean(env.data.freshness_required),
|
||||
items: env.data.items,
|
||||
};
|
||||
}
|
||||
const types = env.data?.document_types ?? [];
|
||||
const currentDocs = env.data?.current_documents ?? [];
|
||||
const ctx = (env.data?.imss_context ?? await workerImssContext(database, workerId)) as WorkerImssContext;
|
||||
const byType = new Map(currentDocs.map((d) => [String(d.type_code), d]));
|
||||
const items = types.map((t) => {
|
||||
const policy = {
|
||||
code: String(t.code),
|
||||
label: String(t.label),
|
||||
required: !!t.required,
|
||||
validity_mode: (String(t.validity_mode || "none")) as "none" | "freshness" | "expiry",
|
||||
freshness_days: t.freshness_days as number | null,
|
||||
requires_issued_at: !!t.requires_issued_at,
|
||||
requires_expires_at: !!t.requires_expires_at,
|
||||
};
|
||||
const evaled = evaluateDocumentValidity(policy, byType.get(policy.code) as never, ctx, today);
|
||||
return {
|
||||
code: policy.code,
|
||||
label: policy.label,
|
||||
required: policy.required,
|
||||
validity_mode: policy.validity_mode,
|
||||
freshness_days: t.freshness_days,
|
||||
requires_issued_at: policy.requires_issued_at,
|
||||
requires_expires_at: policy.requires_expires_at,
|
||||
category: String(t.category || "otro"),
|
||||
present: evaled.present,
|
||||
valid: evaled.valid,
|
||||
validity_status: evaled.validity_status,
|
||||
issued_at: evaled.issued_at,
|
||||
expires_at: evaled.expires_at,
|
||||
};
|
||||
});
|
||||
return { ctx, freshness_required: freshnessRequired(ctx), items };
|
||||
}
|
||||
|
||||
export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) {
|
||||
const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId);
|
||||
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
|
||||
const inProject = !!await database.prepare(
|
||||
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
|
||||
).get(workerId);
|
||||
const env = await callCoreFn<{ workers: Array<Record<string, unknown>> }>(
|
||||
database, "core.fn_worker_list", { tenant_id: tenantId },
|
||||
);
|
||||
const row = env.ok ? (env.data?.workers ?? []).find((w) => Number(w.id) === workerId) : undefined;
|
||||
if (row) {
|
||||
return {
|
||||
imss_status: String(row.imss_status ?? ctx.imss_status),
|
||||
freshness_required: Boolean(row.freshness_required ?? needFresh),
|
||||
imss_ready: Boolean(row.imss_ready),
|
||||
in_project_without_imss: Boolean(row.in_project_without_imss),
|
||||
expediente_ok: Boolean(row.expediente_ok),
|
||||
};
|
||||
}
|
||||
const hasImss = ctx.imss_status === "alta";
|
||||
return {
|
||||
imss_status: ctx.imss_status,
|
||||
freshness_required: needFresh,
|
||||
imss_ready: requiredOk && !hasImss,
|
||||
in_project_without_imss: inProject && !hasImss,
|
||||
in_project_without_imss: false,
|
||||
expediente_ok: requiredOk,
|
||||
};
|
||||
}
|
||||
|
|
@ -209,82 +175,37 @@ export async function checklistFor(database: Db, workerId: number, tenantId: num
|
|||
}
|
||||
|
||||
export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise<void> {
|
||||
const w = await database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as
|
||||
| { status: string }
|
||||
| undefined;
|
||||
if (!w) return;
|
||||
if (w.status === "baja") {
|
||||
await database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId);
|
||||
return;
|
||||
}
|
||||
const items = await checklistFor(database, workerId, tenantId);
|
||||
const photo = items.find((i) => i.code === "foto");
|
||||
const photoOk = photo?.present && photo?.valid;
|
||||
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
|
||||
const printed = await database.prepare(
|
||||
`SELECT 1 FROM badge_job_people p
|
||||
JOIN badge_jobs j ON j.id = p.job_id
|
||||
WHERE p.worker_id = ? LIMIT 1`,
|
||||
).get(workerId);
|
||||
let pipeline = "incompleto";
|
||||
if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete";
|
||||
const assigned = await database.prepare(
|
||||
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
|
||||
).get(workerId);
|
||||
if (pipeline !== "incompleto" && assigned) {
|
||||
pipeline = printed ? "activo" : "listo_gafete";
|
||||
}
|
||||
await database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId);
|
||||
const env = await callCoreFn(database, "core.fn_worker_refresh_pipeline", {
|
||||
worker_id: workerId, tenant_id: tenantId,
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
}
|
||||
|
||||
export async function projectChecklistFor(database: Db, projectId: number) {
|
||||
const types = await database.prepare(
|
||||
"SELECT code, label, required, category FROM project_document_types",
|
||||
).all() as { code: string; label: string; required: boolean; category: string }[];
|
||||
const current = await database.prepare(
|
||||
`SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = true`,
|
||||
).all(projectId) as { type_code: string }[];
|
||||
const have = new Set(current.map((c) => c.type_code));
|
||||
return types.map((t) => ({
|
||||
code: t.code,
|
||||
label: t.label,
|
||||
required: !!t.required,
|
||||
category: t.category || "otro",
|
||||
present: have.has(t.code),
|
||||
}));
|
||||
const env = await callCoreFn<{ checklist: unknown[] }>(
|
||||
database, "core.fn_project_checklist", { project_id: projectId },
|
||||
);
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
return env.data?.checklist ?? [];
|
||||
}
|
||||
|
||||
export async function companyChecklistFor(database: Db, companyId: number) {
|
||||
const types = await database.prepare(
|
||||
"SELECT code, label, required, category FROM company_document_types",
|
||||
).all() as { code: string; label: string; required: boolean; category: string }[];
|
||||
const current = await database.prepare(
|
||||
`SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = true`,
|
||||
).all(companyId) as { type_code: string }[];
|
||||
const have = new Set(current.map((c) => c.type_code));
|
||||
return types.map((t) => ({
|
||||
code: t.code,
|
||||
label: t.label,
|
||||
required: !!t.required,
|
||||
category: t.category || "otro",
|
||||
present: have.has(t.code),
|
||||
}));
|
||||
const env = await callCoreFn<{ checklist: unknown[] }>(
|
||||
database, "core.fn_company_checklist", { company_id: companyId },
|
||||
);
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
return env.data?.checklist ?? [];
|
||||
}
|
||||
|
||||
/** Zona horaria del tenant (Fase 4d) -- reemplaza el PAYROLL_TZ
|
||||
* hardcodeado. Con default sensato si el tenant no la configuró. */
|
||||
export async function tenantTimezone(database: Db, tenantId: number | null): Promise<string> {
|
||||
if (tenantId == null) return "America/Mexico_City";
|
||||
const row = await database.prepare(
|
||||
"SELECT timezone FROM tenant_settings WHERE tenant_id = ?",
|
||||
).get(tenantId) as { timezone: string } | undefined;
|
||||
return row?.timezone || "America/Mexico_City";
|
||||
const env = await callCoreFn<{ timezone: string }>(
|
||||
database, "core.fn_tenant_timezone_get", { tenant_id: tenantId },
|
||||
);
|
||||
if (!env.ok) return "America/Mexico_City";
|
||||
return String(env.data?.timezone ?? "America/Mexico_City");
|
||||
}
|
||||
|
||||
/** "Hoy" en la zona horaria del tenant, formato ISO (YYYY-MM-DD). Usado por
|
||||
* nómina y por validación de vigencia de documentos -- antes eran dos
|
||||
* nociones de "hoy" distintas (PAYROLL_TZ vs. new Date() crudo); ahora es
|
||||
* una sola función parametrizada por tenant. */
|
||||
export function todayInTimezone(timezone: string, now = new Date()): string {
|
||||
return now.toLocaleDateString("en-CA", { timeZone: timezone });
|
||||
}
|
||||
|
|
|
|||
356
api/excel.ts
356
api/excel.ts
|
|
@ -3,9 +3,10 @@ import type { Db } from "./db.ts";
|
|||
import { encryptBytes } from "./docs_crypto.ts";
|
||||
import { sha256Hex } from "./crypto.ts";
|
||||
import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts";
|
||||
import { refreshPipeline, lastInsertId } from "./db.ts";
|
||||
import { refreshPipeline } from "./db.ts";
|
||||
import { resolveCompany } from "./companies.ts";
|
||||
import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts";
|
||||
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||
|
||||
export const IMPORT_COLUMNS = [
|
||||
"NOMBRE",
|
||||
|
|
@ -190,12 +191,12 @@ export function buildImportTemplate(companies: { code: string; name: string }[]
|
|||
}
|
||||
|
||||
async function findExisting(db: Db, curp: string, rfc: string, nss: string) {
|
||||
return await db.prepare(
|
||||
`SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers
|
||||
WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`,
|
||||
).get(curp, rfc, nss) as
|
||||
| { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string }
|
||||
| undefined;
|
||||
const env = await callCoreFn<{
|
||||
worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null;
|
||||
found?: boolean;
|
||||
}>(db, "core.fn_worker_find_existing", { curp, rfc, nss });
|
||||
if (!env.ok || !env.data?.found) return undefined;
|
||||
return env.data.worker;
|
||||
}
|
||||
|
||||
export async function storeDocument(
|
||||
|
|
@ -214,82 +215,34 @@ export async function storeDocument(
|
|||
imss_baja_at?: string | null;
|
||||
} = {},
|
||||
) {
|
||||
const allowed = await db.prepare("SELECT code FROM document_types WHERE code = ?").get(type);
|
||||
if (!allowed) throw new Error("Tipo de documento no válido");
|
||||
|
||||
const { iv, cipher } = await encryptBytes(bytes);
|
||||
const hash = await sha256Hex(bytes);
|
||||
const storage = `${crypto.randomUUID()}.enc`;
|
||||
await putObject(workerDocKey(workerId, storage), cipher);
|
||||
await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run(
|
||||
workerId,
|
||||
type,
|
||||
);
|
||||
const issuedAt = meta.issued_at || null;
|
||||
const expiresAt = meta.expires_at || null;
|
||||
const imssCompanyId = meta.imss_company_id || null;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null);
|
||||
const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null);
|
||||
const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt;
|
||||
|
||||
await db.prepare(
|
||||
`INSERT INTO documents
|
||||
(worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current,
|
||||
parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by_id, uploaded_by_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`,
|
||||
).run(
|
||||
workerId,
|
||||
type,
|
||||
filename,
|
||||
const env = await callCoreFn(db, "core.fn_worker_document_store", {
|
||||
worker_id: workerId,
|
||||
type_code: type,
|
||||
original_name: filename,
|
||||
mime,
|
||||
bytes.byteLength,
|
||||
hash,
|
||||
size_bytes: bytes.byteLength,
|
||||
sha256: hash,
|
||||
iv,
|
||||
storage,
|
||||
issuedAt || movementDate,
|
||||
expiresAt,
|
||||
imssCompanyId,
|
||||
movementDate,
|
||||
userId,
|
||||
);
|
||||
|
||||
if (type === "alta_imss") {
|
||||
const companyId = imssCompanyId ||
|
||||
(await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined)
|
||||
?.company_id ||
|
||||
null;
|
||||
const company = companyId
|
||||
? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as
|
||||
| { id: number; code: string }
|
||||
| undefined
|
||||
: undefined;
|
||||
if (!company) throw new Error("Empresa patrón no válida");
|
||||
await db.prepare(
|
||||
`UPDATE workers SET
|
||||
imss_status = 'alta',
|
||||
imss_company_id = ?,
|
||||
imss_alta_at = ?,
|
||||
imss_baja_at = NULL,
|
||||
company_id = ?,
|
||||
hire_type = ?,
|
||||
updated_at = now()
|
||||
WHERE id = ?`,
|
||||
).run(company.id, imssAltaAt, company.id, company.code, workerId);
|
||||
}
|
||||
|
||||
if (type === "baja_imss") {
|
||||
await db.prepare(
|
||||
`UPDATE workers SET
|
||||
imss_status = 'baja_imss',
|
||||
imss_baja_at = ?,
|
||||
imss_company_id = NULL,
|
||||
company_id = NULL,
|
||||
hire_type = '',
|
||||
updated_at = now()
|
||||
WHERE id = ?`,
|
||||
).run(imssBajaAt, workerId);
|
||||
}
|
||||
storage_name: storage,
|
||||
uploaded_by_id: userId,
|
||||
issued_at: issuedAt,
|
||||
expires_at: expiresAt,
|
||||
imss_company_id: imssCompanyId,
|
||||
imss_alta_at: imssAltaAt,
|
||||
imss_baja_at: imssBajaAt,
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
|
||||
await refreshPipeline(db, workerId);
|
||||
}
|
||||
|
|
@ -303,21 +256,23 @@ export async function storeProjectDocument(
|
|||
bytes: Uint8Array,
|
||||
userId: number | null,
|
||||
) {
|
||||
const allowed = await db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type);
|
||||
if (!allowed) throw new Error("Tipo de documento no válido");
|
||||
const { iv, cipher } = await encryptBytes(bytes);
|
||||
const hash = await sha256Hex(bytes);
|
||||
const storage = `${crypto.randomUUID()}.enc`;
|
||||
await putObject(projectDocKey(projectId, storage), cipher);
|
||||
await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run(
|
||||
projectId,
|
||||
type,
|
||||
);
|
||||
await db.prepare(
|
||||
`INSERT INTO project_documents
|
||||
(project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
|
||||
).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
|
||||
const env = await callCoreFn(db, "core.fn_project_document_store", {
|
||||
project_id: projectId,
|
||||
type_code: type,
|
||||
original_name: filename,
|
||||
mime,
|
||||
size_bytes: bytes.byteLength,
|
||||
sha256: hash,
|
||||
iv,
|
||||
storage_name: storage,
|
||||
uploaded_by_id: userId,
|
||||
uploaded_by_name: "",
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
}
|
||||
|
||||
export async function storeCompanyDocument(
|
||||
|
|
@ -329,21 +284,23 @@ export async function storeCompanyDocument(
|
|||
bytes: Uint8Array,
|
||||
userId: number | null,
|
||||
) {
|
||||
const allowed = await db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type);
|
||||
if (!allowed) throw new Error("Tipo de documento no válido");
|
||||
const { iv, cipher } = await encryptBytes(bytes);
|
||||
const hash = await sha256Hex(bytes);
|
||||
const storage = `${crypto.randomUUID()}.enc`;
|
||||
await putObject(companyDocKey(companyId, storage), cipher);
|
||||
await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
await db.prepare(
|
||||
`INSERT INTO company_documents
|
||||
(company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
|
||||
).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
|
||||
const env = await callCoreFn(db, "core.fn_company_document_store", {
|
||||
company_id: companyId,
|
||||
type_code: type,
|
||||
original_name: filename,
|
||||
mime,
|
||||
size_bytes: bytes.byteLength,
|
||||
sha256: hash,
|
||||
iv,
|
||||
storage_name: storage,
|
||||
uploaded_by_id: userId,
|
||||
uploaded_by_name: "",
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
}
|
||||
|
||||
async function fetchPhoto(url: string): Promise<Uint8Array | null> {
|
||||
|
|
@ -357,92 +314,6 @@ async function fetchPhoto(url: string): Promise<Uint8Array | null> {
|
|||
}
|
||||
}
|
||||
|
||||
async function upsertWorker(
|
||||
db: Db,
|
||||
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
|
||||
projectId: number | null,
|
||||
) {
|
||||
const existing = await findExisting(db, input.curp, input.rfc, input.nss);
|
||||
if (existing) {
|
||||
await db.prepare(
|
||||
`UPDATE workers SET
|
||||
first_name=?, middle_name=?, last_name_p=?, last_name_m=?,
|
||||
curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?,
|
||||
hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?,
|
||||
needs_badge=?, status=?, updated_at=now()
|
||||
WHERE id=?`,
|
||||
).run(
|
||||
input.first_name,
|
||||
input.middle_name,
|
||||
input.last_name_p,
|
||||
input.last_name_m,
|
||||
input.curp,
|
||||
input.rfc,
|
||||
input.nss,
|
||||
input.phone,
|
||||
input.email,
|
||||
input.address,
|
||||
input.blood_type,
|
||||
input.hire_type,
|
||||
input.company_id,
|
||||
input.tenant_id ?? null,
|
||||
input.position,
|
||||
input.risk_code,
|
||||
input.work_type,
|
||||
input.daily_wage,
|
||||
input.needs_badge,
|
||||
input.status,
|
||||
existing.id,
|
||||
);
|
||||
if (projectId) await assign(db, existing.id, projectId);
|
||||
await refreshPipeline(db, existing.id, input.tenant_id ?? null);
|
||||
const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS";
|
||||
return { id: existing.id, action: "existed" as const, matched };
|
||||
}
|
||||
await db.prepare(
|
||||
`INSERT INTO workers
|
||||
(first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
|
||||
blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
).run(
|
||||
input.first_name,
|
||||
input.middle_name,
|
||||
input.last_name_p,
|
||||
input.last_name_m,
|
||||
input.curp,
|
||||
input.rfc,
|
||||
input.nss,
|
||||
input.phone,
|
||||
input.email,
|
||||
input.address,
|
||||
input.blood_type,
|
||||
input.hire_type,
|
||||
input.company_id,
|
||||
input.position,
|
||||
input.risk_code,
|
||||
input.work_type,
|
||||
input.daily_wage,
|
||||
input.needs_badge,
|
||||
input.status,
|
||||
input.tenant_id ?? null,
|
||||
);
|
||||
const id = await lastInsertId(db);
|
||||
if (projectId) await assign(db, id, projectId);
|
||||
await refreshPipeline(db, id, input.tenant_id ?? null);
|
||||
return { id, action: "inserted" as const, matched: null as string | null };
|
||||
}
|
||||
|
||||
async function assign(db: Db, workerId: number, projectId: number) {
|
||||
await db.prepare(
|
||||
`INSERT INTO assignments (worker_id, project_id, active, start_date)
|
||||
VALUES (?, ?, true, current_date)
|
||||
ON CONFLICT(worker_id, project_id) DO UPDATE SET
|
||||
active=true,
|
||||
start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END,
|
||||
end_date=NULL`,
|
||||
).run(workerId, projectId);
|
||||
}
|
||||
|
||||
export type ImportReport = {
|
||||
inserted: number;
|
||||
existed: number;
|
||||
|
|
@ -452,6 +323,79 @@ export type ImportReport = {
|
|||
error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[];
|
||||
};
|
||||
|
||||
type ImportBatchResult = {
|
||||
inserted: number;
|
||||
existed: number;
|
||||
errors: number;
|
||||
existed_rows: ImportReport["existed_rows"];
|
||||
error_rows: ImportReport["error_rows"];
|
||||
};
|
||||
|
||||
function workerToImportRow(
|
||||
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
|
||||
sheet: string,
|
||||
row: number,
|
||||
projectId: number | null,
|
||||
) {
|
||||
return {
|
||||
sheet,
|
||||
row,
|
||||
first_name: input.first_name,
|
||||
middle_name: input.middle_name,
|
||||
last_name_p: input.last_name_p,
|
||||
last_name_m: input.last_name_m,
|
||||
curp: input.curp,
|
||||
rfc: input.rfc,
|
||||
nss: input.nss,
|
||||
phone: input.phone,
|
||||
email: input.email,
|
||||
address: input.address,
|
||||
blood_type: input.blood_type,
|
||||
hire_type: input.hire_type,
|
||||
company_id: input.company_id,
|
||||
position: input.position,
|
||||
risk_code: input.risk_code,
|
||||
work_type: input.work_type,
|
||||
daily_wage: input.daily_wage,
|
||||
needs_badge: input.needs_badge,
|
||||
status: input.status,
|
||||
project_id: input.status === "activo" ? projectId : null,
|
||||
tenant_id: input.tenant_id ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertWorker(
|
||||
db: Db,
|
||||
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
|
||||
projectId: number | null,
|
||||
) {
|
||||
const env = await callCoreFn<ImportBatchResult>(db, "core.fn_workers_import_batch", {
|
||||
tenant_id: input.tenant_id ?? 1,
|
||||
project_id: projectId,
|
||||
rows: [workerToImportRow(input, "IMPORT", 1, projectId)],
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
const data = env.data!;
|
||||
const errorRow = data.error_rows?.[0];
|
||||
if (errorRow?.messages?.length) throw new Error(errorRow.messages[0]);
|
||||
const existing = await findExisting(db, input.curp, input.rfc, input.nss);
|
||||
if (!existing) throw new Error("No se pudo guardar el trabajador");
|
||||
await refreshPipeline(db, existing.id, input.tenant_id ?? null);
|
||||
return {
|
||||
id: existing.id,
|
||||
action: (data.inserted ?? 0) > 0 ? "inserted" as const : "existed" as const,
|
||||
matched: data.existed_rows?.[0]?.matched ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function assign(db: Db, workerId: number, projectId: number) {
|
||||
const env = await callCoreFn(db, "core.fn_worker_assign", {
|
||||
worker_id: workerId,
|
||||
project_id: projectId,
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
}
|
||||
|
||||
export async function importExcel(
|
||||
db: Db,
|
||||
bytes: Uint8Array,
|
||||
|
|
@ -459,9 +403,9 @@ export async function importExcel(
|
|||
userId: number | null,
|
||||
): Promise<ImportReport> {
|
||||
const wb = XLSX.read(bytes, { type: "array" });
|
||||
const risks = new Set(
|
||||
(await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code),
|
||||
);
|
||||
const catEnv = await callCoreFn<{ risks?: { code: string }[] }>(db, "core.fn_catalogs", {});
|
||||
if (!catEnv.ok) throw new RpcCallError(catEnv);
|
||||
const risks = new Set((catEnv.data?.risks ?? []).map((r) => r.code));
|
||||
const report: ImportReport = {
|
||||
inserted: 0,
|
||||
existed: 0,
|
||||
|
|
@ -496,6 +440,9 @@ export async function importExcel(
|
|||
return report;
|
||||
}
|
||||
|
||||
const batchRows: ReturnType<typeof workerToImportRow>[] = [];
|
||||
const photoJobs: { curp: string; rfc: string; nss: string; url: string; tenant_id: number }[] = [];
|
||||
|
||||
for (const job of jobs) {
|
||||
for (const { row, data } of job.rows) {
|
||||
if (isEmptyRow(data)) continue;
|
||||
|
|
@ -539,28 +486,41 @@ export async function importExcel(
|
|||
company_id: company!.id,
|
||||
tenant_id: company!.tenant_id ?? 1,
|
||||
};
|
||||
const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null);
|
||||
if (res.action === "inserted") report.inserted++;
|
||||
else {
|
||||
report.existed++;
|
||||
report.existed_rows.push({
|
||||
sheet: job.sheet,
|
||||
row,
|
||||
nombre,
|
||||
curp: n.curp,
|
||||
matched: res.matched || "CURP",
|
||||
});
|
||||
}
|
||||
batchRows.push(workerToImportRow(n, job.sheet, row, n.status === "activo" ? projectId : null));
|
||||
const url = data["URL FOTO"];
|
||||
if (url) {
|
||||
const photo = await fetchPhoto(url);
|
||||
if (photo) {
|
||||
await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId);
|
||||
report.photos++;
|
||||
}
|
||||
photoJobs.push({ curp: n.curp, rfc: n.rfc, nss: n.nss, url, tenant_id: n.tenant_id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (batchRows.length > 0) {
|
||||
const env = await callCoreFn<ImportBatchResult>(db, "core.fn_workers_import_batch", {
|
||||
project_id: projectId,
|
||||
rows: batchRows,
|
||||
});
|
||||
if (!env.ok) throw new RpcCallError(env);
|
||||
const data = env.data!;
|
||||
report.inserted = data.inserted ?? 0;
|
||||
report.existed = data.existed ?? 0;
|
||||
report.errors += data.errors ?? 0;
|
||||
report.existed_rows.push(...(data.existed_rows ?? []));
|
||||
report.error_rows.push(...(data.error_rows ?? []));
|
||||
for (const row of batchRows) {
|
||||
const worker = await findExisting(db, row.curp, row.rfc, row.nss);
|
||||
if (worker) await refreshPipeline(db, worker.id, row.tenant_id ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
for (const photo of photoJobs) {
|
||||
const worker = await findExisting(db, photo.curp, photo.rfc, photo.nss);
|
||||
if (!worker) continue;
|
||||
const photoBytes = await fetchPhoto(photo.url);
|
||||
if (photoBytes) {
|
||||
await storeDocument(db, worker.id, "foto", "foto-import.jpg", "image/jpeg", photoBytes, userId);
|
||||
report.photos++;
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
|
|
|
|||
130
api/http_errors.ts
Normal file
130
api/http_errors.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import type { Context } from "hono";
|
||||
|
||||
export type RpcCode =
|
||||
| "OK"
|
||||
| "CREATED"
|
||||
| "VALIDATION"
|
||||
| "UNAUTHORIZED"
|
||||
| "FORBIDDEN"
|
||||
| "NOT_FOUND"
|
||||
| "CONFLICT"
|
||||
| "INTERNAL"
|
||||
| "NETWORK";
|
||||
|
||||
export type ResponseLayer = "db" | "api" | "front";
|
||||
|
||||
export type RpcEnvelope<T = unknown> = {
|
||||
ok: boolean;
|
||||
code: RpcCode | string;
|
||||
layer: ResponseLayer;
|
||||
message: string;
|
||||
context?: Record<string, unknown> | null;
|
||||
data?: T | null;
|
||||
errors?: Record<string, string> | string[] | null;
|
||||
};
|
||||
|
||||
export type ApiResponse<T = unknown> = RpcEnvelope<T> & { status: number };
|
||||
|
||||
const GENERIC_MESSAGES = new Set([
|
||||
"ok", "error", "operación exitosa", "operacion exitosa", "algo salió mal",
|
||||
"algo salio mal", "error interno", "error interno del servidor",
|
||||
]);
|
||||
|
||||
export function isGenericMessage(message: string): boolean {
|
||||
return GENERIC_MESSAGES.has(message.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function mapRpcToStatus(code: string): number {
|
||||
switch (code) {
|
||||
case "OK":
|
||||
return 200;
|
||||
case "CREATED":
|
||||
return 201;
|
||||
case "VALIDATION":
|
||||
return 400;
|
||||
case "UNAUTHORIZED":
|
||||
return 401;
|
||||
case "FORBIDDEN":
|
||||
return 403;
|
||||
case "NOT_FOUND":
|
||||
return 404;
|
||||
case "CONFLICT":
|
||||
return 409;
|
||||
case "NETWORK":
|
||||
return 503;
|
||||
case "INTERNAL":
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
|
||||
export function respondRpc<T>(c: Context, envelope: RpcEnvelope<T>) {
|
||||
const status = mapRpcToStatus(String(envelope.code));
|
||||
const body: ApiResponse<T> = { ...envelope, status };
|
||||
return c.json(body, status as 200 | 201 | 400 | 401 | 403 | 404 | 409 | 500 | 503);
|
||||
}
|
||||
|
||||
export function respondApiError(
|
||||
c: Context,
|
||||
code: RpcCode,
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
errors?: Record<string, string> | string[] | null,
|
||||
) {
|
||||
if (isGenericMessage(message)) {
|
||||
throw new Error(`API error message is too generic: ${message}`);
|
||||
}
|
||||
const status = mapRpcToStatus(code);
|
||||
const body: ApiResponse = {
|
||||
ok: false,
|
||||
code,
|
||||
status,
|
||||
layer: "api",
|
||||
message,
|
||||
context: context ?? {},
|
||||
data: null,
|
||||
errors: errors ?? null,
|
||||
};
|
||||
return c.json(body, status as 400 | 401 | 403 | 404 | 409 | 500 | 503);
|
||||
}
|
||||
|
||||
export function enrichInfraError(
|
||||
fn: string,
|
||||
route: string,
|
||||
cause: string,
|
||||
detail?: string,
|
||||
): { message: string; context: Record<string, unknown> } {
|
||||
const message = detail
|
||||
? `Falló la llamada a ${fn} en ${route}: ${cause} — ${detail}`
|
||||
: `Falló la llamada a ${fn} en ${route}: ${cause}`;
|
||||
return {
|
||||
message,
|
||||
context: { fn, route, cause },
|
||||
};
|
||||
}
|
||||
|
||||
export function routeLabel(c: Context): string {
|
||||
return `${c.req.method} ${c.req.path}`;
|
||||
}
|
||||
|
||||
export function onAppError(err: unknown, c: Context) {
|
||||
console.error(err);
|
||||
if (err && typeof err === "object" && (err as RpcEnvelope).layer) {
|
||||
const env = err as RpcEnvelope;
|
||||
return respondRpc(c, env);
|
||||
}
|
||||
const message = err instanceof Error
|
||||
? `Error no controlado en ${routeLabel(c)}: ${err.message}`
|
||||
: `Error no controlado en ${routeLabel(c)}`;
|
||||
const status = 500;
|
||||
return c.json({
|
||||
ok: false,
|
||||
code: "INTERNAL",
|
||||
status,
|
||||
layer: "api",
|
||||
message,
|
||||
context: { route: routeLabel(c) },
|
||||
data: null,
|
||||
errors: null,
|
||||
}, status);
|
||||
}
|
||||
15
api/http_errors_test.ts
Normal file
15
api/http_errors_test.ts
Normal 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);
|
||||
});
|
||||
1041
api/main.ts
1041
api/main.ts
File diff suppressed because it is too large
Load diff
935
api/payroll.ts
935
api/payroll.ts
|
|
@ -117,921 +117,124 @@ export function loanConditionLabel(
|
|||
return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`;
|
||||
}
|
||||
|
||||
import { callCoreFn } from "./rpc.ts";
|
||||
import type { RpcEnvelope } from "./http_errors.ts";
|
||||
import { mapRpcToStatus } from "./http_errors.ts";
|
||||
|
||||
function throwFromRpc(env: RpcEnvelope): never {
|
||||
const e = new Error(env.message) as Error & { status?: number };
|
||||
e.status = mapRpcToStatus(String(env.code));
|
||||
throw e;
|
||||
}
|
||||
|
||||
export async function getSettings(db: Db, tenantId: number): Promise<PayrollSettings> {
|
||||
await seedDefaults(db, tenantId);
|
||||
return await db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings;
|
||||
const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_get", { tenant_id: tenantId });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return env.data!.settings;
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
db: Db,
|
||||
tenantId: number,
|
||||
patch: Partial<PayrollSettings>,
|
||||
): 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 saveSettings(db: Db, tenantId: number, patch: Partial<PayrollSettings>): Promise<PayrollSettings> {
|
||||
const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_save", { tenant_id: tenantId, ...patch });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return env.data!.settings;
|
||||
}
|
||||
|
||||
export async function seedDefaults(db: Db, tenantId: number): Promise<void> {
|
||||
await db.prepare(
|
||||
`INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max)
|
||||
VALUES (?, true, 10, 500)
|
||||
ON CONFLICT(tenant_id) DO NOTHING`,
|
||||
).run(tenantId);
|
||||
const n = await db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number };
|
||||
if (Number(n.n) === 0) {
|
||||
const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)");
|
||||
for (const u of DEFAULT_UNITS) await ins.run(tenantId, u.code, u.label);
|
||||
}
|
||||
}
|
||||
export async function seedDefaults(db: Db, tenantId: number): Promise<void> { await getSettings(db, tenantId); }
|
||||
|
||||
export async function listUnits(db: Db, tenantId: number) {
|
||||
await seedDefaults(db, tenantId);
|
||||
return await db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId);
|
||||
const env = await callCoreFn<{ units: unknown[] }>(db, "core.fn_payroll_unit_list", { tenant_id: tenantId });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return env.data?.units ?? [];
|
||||
}
|
||||
|
||||
export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise<number> {
|
||||
const c = code.trim().toLowerCase().replace(/\s+/g, "");
|
||||
const l = label.trim() || c;
|
||||
if (!c) throw new Error("Unidad requerida");
|
||||
await db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l);
|
||||
return await db.lastInsertId();
|
||||
const env = await callCoreFn<{ id: number }>(db, "core.fn_payroll_unit_add", { tenant_id: tenantId, code, label });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return Number(env.data?.id);
|
||||
}
|
||||
|
||||
export function firstDueForLoan(opts: {
|
||||
delivered: number;
|
||||
smallMax: number;
|
||||
plan: "single" | "installments";
|
||||
grantIso: string;
|
||||
weekAssembled: boolean;
|
||||
}): string {
|
||||
const small = opts.delivered <= opts.smallMax && opts.plan === "single";
|
||||
if (small) return nextSaturdayAfterThisWeek(opts.grantIso);
|
||||
if (opts.weekAssembled) return nextSaturdayAfterThisWeek(opts.grantIso);
|
||||
return thisSaturday(opts.grantIso);
|
||||
export async function createLoan(db: Db, tenantId: number, body: Record<string, unknown>): Promise<{ id: number }> {
|
||||
const env = await callCoreFn<{ id: number }>(db, "core.fn_loan_create", { tenant_id: tenantId, ...body });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return { id: Number(env.data?.id) };
|
||||
}
|
||||
|
||||
export async function createLoan(
|
||||
db: Db,
|
||||
tenantId: number,
|
||||
body: {
|
||||
worker_id: number;
|
||||
delivered: number;
|
||||
commission_enabled?: boolean;
|
||||
commission_pct?: number;
|
||||
plan?: "single" | "installments";
|
||||
installments_n?: number;
|
||||
note?: string;
|
||||
grantIso?: string;
|
||||
},
|
||||
): Promise<{ id: number }> {
|
||||
const worker = await db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id);
|
||||
if (!worker) throw Object.assign(new Error("Persona no encontrada"), { status: 404 });
|
||||
const settings = await getSettings(db, tenantId);
|
||||
const delivered = roundMoney(body.delivered);
|
||||
if (delivered <= 0) throw Object.assign(new Error("Monto requerido"), { status: 400 });
|
||||
const enabled = body.commission_enabled ?? Boolean(settings.loan_commission_enabled);
|
||||
const pct = enabled ? Number(body.commission_pct ?? settings.loan_commission_pct) || 0 : 0;
|
||||
const comm = commissionAmount(delivered, pct);
|
||||
const balance = roundMoney(delivered + comm);
|
||||
const small = delivered <= settings.loan_small_max;
|
||||
let plan = body.plan ?? "single";
|
||||
let installments = Math.max(1, Math.floor(body.installments_n ?? 1));
|
||||
if (small) {
|
||||
plan = "single";
|
||||
installments = 1;
|
||||
}
|
||||
if (plan === "installments" && installments < 2) installments = 2;
|
||||
if (plan === "single") installments = 1;
|
||||
const weekly = roundMoney(balance / installments);
|
||||
const grantIso = body.grantIso ?? await resolveToday(db, tenantId);
|
||||
const week = weekContaining(grantIso);
|
||||
const current = await db.prepare(
|
||||
"SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
|
||||
).get(tenantId, week.weekStart) as { id: number; status: string } | undefined;
|
||||
const assembled = current?.status === "assembled" || current?.status === "paid";
|
||||
const firstDue = firstDueForLoan({
|
||||
delivered,
|
||||
smallMax: settings.loan_small_max,
|
||||
plan,
|
||||
grantIso,
|
||||
weekAssembled: assembled && weekdayMon0(grantIso) >= 4,
|
||||
});
|
||||
await db.prepare(
|
||||
`INSERT INTO loans
|
||||
(worker_id, amount, delivered, balance, weekly_payment, note,
|
||||
commission_pct, commission_amount, plan, installments_n, first_due)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
body.worker_id,
|
||||
delivered,
|
||||
delivered,
|
||||
balance,
|
||||
weekly,
|
||||
body.note ?? null,
|
||||
pct,
|
||||
comm,
|
||||
plan,
|
||||
installments,
|
||||
firstDue,
|
||||
);
|
||||
const id = await db.lastInsertId();
|
||||
if (current && current.status !== "paid") await recalcWeek(db, current.id);
|
||||
return { id };
|
||||
}
|
||||
|
||||
export async function presentOnOtherProject(
|
||||
db: Db,
|
||||
workerId: number,
|
||||
workDate: string,
|
||||
exceptProjectId?: number,
|
||||
): Promise<{ project_id: number; project_name: string } | null> {
|
||||
const row = await db.prepare(
|
||||
`SELECT a.project_id, p.name AS project_name
|
||||
FROM attendance a JOIN projects p ON p.id=a.project_id
|
||||
WHERE a.worker_id=? AND a.work_date=? AND a.present=true
|
||||
AND a.project_id != ?`,
|
||||
).get(workerId, workDate, exceptProjectId ?? 0) as
|
||||
| { project_id: number; project_name: string }
|
||||
| undefined;
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
export async function setAttendance(
|
||||
db: Db,
|
||||
tenantId: number,
|
||||
body: { project_id: number; worker_id: number; work_date: string; present: boolean },
|
||||
): Promise<{ ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } }> {
|
||||
if (body.present) {
|
||||
const other = await presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id);
|
||||
if (other) {
|
||||
return {
|
||||
error: `Ese día ya está marcado en ${other.project_name}`,
|
||||
status: 409,
|
||||
other,
|
||||
};
|
||||
}
|
||||
}
|
||||
await db.prepare(
|
||||
`INSERT INTO attendance (worker_id, project_id, work_date, present)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`,
|
||||
).run(body.worker_id, body.project_id, body.work_date, body.present);
|
||||
const { weekStart } = weekContaining(body.work_date);
|
||||
const week = await db.prepare(
|
||||
"SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
|
||||
).get(tenantId, weekStart) as { id: number; status: string } | undefined;
|
||||
if (week && week.status !== "paid") await recalcWeek(db, week.id);
|
||||
return { ok: true };
|
||||
export async function setAttendance(db: Db, tenantId: number, body: { project_id: number; worker_id: number; work_date: string; present: boolean }) {
|
||||
const env = await callCoreFn(db, "core.fn_attendance_set", { tenant_id: tenantId, ...body });
|
||||
if (!env.ok) return { error: env.message, status: mapRpcToStatus(String(env.code)), other: (env.data as { other?: unknown })?.other };
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise<number> {
|
||||
await seedDefaults(db, tenantId);
|
||||
const { weekEnd } = weekContaining(weekStart);
|
||||
const start = weekContaining(weekStart).weekStart;
|
||||
let week = await db.prepare(
|
||||
"SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
|
||||
).get(tenantId, start) as { id: number } | undefined;
|
||||
if (!week) {
|
||||
await db.prepare(
|
||||
`INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
|
||||
VALUES (?, ?, 'draft', ?)`,
|
||||
).run(start, weekEnd, tenantId);
|
||||
week = { id: await db.lastInsertId() };
|
||||
}
|
||||
const weekId = week.id;
|
||||
const destajo = destajoPeriodBounds(start);
|
||||
const period = await db.prepare(
|
||||
"SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?",
|
||||
).get(tenantId, destajo.periodEnd) as { id: number } | undefined;
|
||||
if (!period) {
|
||||
await db.prepare(
|
||||
`INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId);
|
||||
}
|
||||
const projects = await db.prepare(
|
||||
`SELECT id FROM projects
|
||||
WHERE status IN ('activo', 'pausado')
|
||||
AND (? = 0 OR tenant_id = ? OR tenant_id IS NULL)
|
||||
ORDER BY name`,
|
||||
).all(tenantId, tenantId) as { id: number }[];
|
||||
const hasSheet = db.prepare(
|
||||
`SELECT id FROM payroll_sheets
|
||||
WHERE week_id=? AND kind=? AND COALESCE(project_id, 0)=COALESCE(?, 0)`,
|
||||
);
|
||||
const insSheet = db.prepare(
|
||||
`INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`,
|
||||
);
|
||||
const ensureSheet = async (kind: string, projectId: number | null) => {
|
||||
if (await hasSheet.get(weekId, kind, projectId)) return;
|
||||
await insSheet.run(weekId, kind, projectId);
|
||||
};
|
||||
for (const p of projects) {
|
||||
await ensureSheet("obra", p.id);
|
||||
await ensureSheet("destajo", p.id);
|
||||
}
|
||||
await ensureSheet("admin", null);
|
||||
return weekId;
|
||||
}
|
||||
|
||||
async function jornalRoster(db: Db, projectId: number, weekStart: string, weekEnd: string, weekId = 0) {
|
||||
return await db.prepare(
|
||||
`SELECT w.id, w.first_name, w.last_name_p, w.position, w.daily_wage, w.work_type
|
||||
FROM workers w
|
||||
WHERE w.status='activo' AND w.pipeline_status != 'baja'
|
||||
AND (
|
||||
EXISTS (
|
||||
SELECT 1 FROM assignments a
|
||||
WHERE a.worker_id=w.id AND a.project_id=? AND a.active=true AND w.work_type='N'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM attendance att
|
||||
WHERE att.worker_id=w.id AND att.project_id=? AND att.work_date BETWEEN ? AND ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM payroll_week_lines l
|
||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
||||
WHERE l.worker_id=w.id AND s.project_id=? AND s.kind='obra' AND s.week_id=?
|
||||
)
|
||||
)
|
||||
ORDER BY w.last_name_p, w.first_name`,
|
||||
).all(projectId, projectId, weekStart, weekEnd, projectId, weekId) as {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name_p: string;
|
||||
position: string;
|
||||
daily_wage: number;
|
||||
work_type: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function sheetLine(
|
||||
db: Db,
|
||||
sheetId: number,
|
||||
workerId: number,
|
||||
destajoCutLineId: number | null,
|
||||
): Promise<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);
|
||||
const env = await callCoreFn<{ week_id: number }>(db, "core.fn_payroll_week_ensure", { tenant_id: tenantId, week_start: weekStart });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return Number(env.data?.week_id);
|
||||
}
|
||||
|
||||
export async function recalcWeek(db: Db, weekId: number): Promise<void> {
|
||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
id: number;
|
||||
week_start: string;
|
||||
week_end: string;
|
||||
status: string;
|
||||
required_net: number;
|
||||
tenant_id: number;
|
||||
} | undefined;
|
||||
if (!week || week.status === "paid") return;
|
||||
const saturday = week.week_end;
|
||||
const sheets = await db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as {
|
||||
id: number;
|
||||
kind: string;
|
||||
project_id: number | null;
|
||||
}[];
|
||||
|
||||
await db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)")
|
||||
.run(weekId);
|
||||
|
||||
for (const sheet of sheets) {
|
||||
if (sheet.kind === "obra" && sheet.project_id) {
|
||||
const roster = await jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId);
|
||||
for (const w of roster) {
|
||||
const lineId = await sheetLine(db, sheet.id, w.id, null);
|
||||
const att = await db.prepare(
|
||||
`SELECT COUNT(*) AS n FROM attendance
|
||||
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`,
|
||||
).get(w.id, sheet.project_id, week.week_start, week.week_end) as { n: number };
|
||||
const frozen = await db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number };
|
||||
const wage = week.status === "assembled" && frozen.daily_wage > 0 ? frozen.daily_wage : w.daily_wage;
|
||||
const gross = roundMoney(Number(att.n) * wage);
|
||||
await db.prepare(
|
||||
`UPDATE payroll_week_lines
|
||||
SET days=?, daily_wage=?, amount=?, gross=?, payable_net=?
|
||||
WHERE id=?`,
|
||||
).run(Number(att.n), wage, gross, gross, gross, lineId);
|
||||
}
|
||||
}
|
||||
|
||||
if (sheet.kind === "destajo" && sheet.project_id) {
|
||||
const period = await db.prepare(
|
||||
"SELECT id FROM destajo_periods WHERE week_id=?",
|
||||
).get(weekId) as { id: number } | undefined;
|
||||
if (!period) continue;
|
||||
const cuts = await db.prepare(
|
||||
`SELECT c.*, j.worker_id, j.unit_price, j.unit_code, j.concepto
|
||||
FROM destajo_cut_lines c
|
||||
JOIN destajo_jobs j ON j.id=c.job_id
|
||||
WHERE c.period_id=? AND j.project_id=?`,
|
||||
).all(period.id, sheet.project_id) as {
|
||||
id: number;
|
||||
worker_id: number;
|
||||
qty_planned: number;
|
||||
qty_actual: number;
|
||||
qty_extra: number;
|
||||
unit_price: number;
|
||||
unit_code: string;
|
||||
concepto: string;
|
||||
}[];
|
||||
const keep = new Set<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);
|
||||
}
|
||||
const env = await callCoreFn(db, "core.fn_payroll_week_recalc", { week_id: weekId });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
}
|
||||
|
||||
export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
|
||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
id: number;
|
||||
week_start: string;
|
||||
status: string;
|
||||
tenant_id: number;
|
||||
} | undefined;
|
||||
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
|
||||
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
|
||||
if (!canAssembleWeek(week.week_start, nowIso)) {
|
||||
throw Object.assign(
|
||||
new Error("La nómina se arma el jueves o después. Aún no es jueves de esta semana."),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const sheets = await db.prepare(
|
||||
"SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'",
|
||||
).all(weekId) as { id: number; project_id: number | null }[];
|
||||
for (const sheet of sheets) {
|
||||
if (!sheet.project_id) continue;
|
||||
const roster = await jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId);
|
||||
for (const w of roster) {
|
||||
const assigned = await db.prepare(
|
||||
"SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=true",
|
||||
).get(w.id, sheet.project_id) as { ok: number } | undefined;
|
||||
if (!assigned) continue;
|
||||
if (w.work_type !== "N") {
|
||||
const hasLine = await db.prepare(
|
||||
"SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?",
|
||||
).get(sheet.id, w.id);
|
||||
if (!hasLine) continue;
|
||||
}
|
||||
await prefillThuSat(db, sheet.project_id, week.week_start, w.id);
|
||||
}
|
||||
}
|
||||
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
|
||||
if (period) {
|
||||
await db.prepare(
|
||||
`UPDATE destajo_cut_lines SET qty_actual = qty_planned
|
||||
WHERE period_id=? AND qty_actual=0 AND qty_planned>0`,
|
||||
).run(period.id);
|
||||
}
|
||||
await db.prepare(
|
||||
"UPDATE payroll_weeks SET status='assembled', assembled_at=now() WHERE id=?",
|
||||
).run(weekId);
|
||||
await recalcWeek(db, weekId);
|
||||
const payable = await db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number };
|
||||
await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId);
|
||||
await db.prepare(
|
||||
`UPDATE payroll_week_lines SET required_net=payable_net
|
||||
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
|
||||
).run(weekId);
|
||||
const env = await callCoreFn(db, "core.fn_payroll_week_assemble", { week_id: weekId, today: nowIso });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
}
|
||||
|
||||
export async function payWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
|
||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
||||
id: number;
|
||||
week_end: string;
|
||||
status: string;
|
||||
} | undefined;
|
||||
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
|
||||
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
|
||||
if (week.status !== "assembled") {
|
||||
await assembleWeek(db, weekId, nowIso);
|
||||
}
|
||||
await recalcWeek(db, weekId);
|
||||
const lines = await db.prepare(
|
||||
`SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
|
||||
FROM payroll_week_lines l
|
||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
||||
WHERE s.week_id=? AND l.loan_id IS NOT NULL AND l.loan_discount > 0`,
|
||||
).all(weekId) as { loan_id: number; loan_discount: number; loan_label: string; worker_id: number }[];
|
||||
for (const line of lines) {
|
||||
const loan = await db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined;
|
||||
if (!loan || loan.balance <= 0) continue;
|
||||
const take = roundMoney(Math.min(line.loan_discount, loan.balance));
|
||||
if (take <= 0) continue;
|
||||
const n = await paidInstallments(db, loan.id) + 1;
|
||||
await db.prepare(
|
||||
`INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`,
|
||||
).run(loan.id, weekId, take, n, line.loan_label);
|
||||
await db.prepare("UPDATE loans SET balance = ROUND((balance - ?)::numeric, 2) WHERE id=?").run(take, loan.id);
|
||||
}
|
||||
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
|
||||
if (period) {
|
||||
const jobs = await db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as {
|
||||
id: number;
|
||||
qty_total_estimated: number;
|
||||
}[];
|
||||
for (const job of jobs) {
|
||||
const paid = await db.prepare(
|
||||
`SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
|
||||
FROM destajo_cut_lines c
|
||||
JOIN destajo_periods p ON p.id=c.period_id
|
||||
JOIN payroll_weeks w ON w.id=p.week_id
|
||||
WHERE c.job_id=? AND (w.status='paid' OR w.id=?)`,
|
||||
).get(job.id, weekId) as { n: number };
|
||||
if (Number(paid.n) >= job.qty_total_estimated) {
|
||||
await db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
await db.prepare(
|
||||
"UPDATE payroll_weeks SET status='paid', paid_at=now() WHERE id=?",
|
||||
).run(weekId);
|
||||
const env = await callCoreFn(db, "core.fn_payroll_week_pay", { week_id: weekId, today: nowIso });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
}
|
||||
|
||||
export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise<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='obra' AND project_id=?",
|
||||
).get(weekId, projectId) as { id: number } | undefined;
|
||||
if (!sheet) throw Object.assign(new Error("Hoja de obra no encontrada"), { status: 404 });
|
||||
await sheetLine(db, sheet.id, workerId, null);
|
||||
await recalcWeek(db, weekId);
|
||||
const env = await callCoreFn(db, "core.fn_payroll_jornal_worker_add", { week_id: weekId, project_id: projectId, worker_id: workerId });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
}
|
||||
|
||||
export async function addAdminLine(
|
||||
db: Db,
|
||||
weekId: number,
|
||||
body: { worker_id: number; amount: number; project_id?: number | null },
|
||||
): Promise<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 addAdminLine(db: Db, weekId: number, body: { worker_id: number; amount: number; project_id?: number | null }): Promise<void> {
|
||||
const env = await callCoreFn(db, "core.fn_payroll_admin_line_add", { week_id: weekId, ...body });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
}
|
||||
|
||||
export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise<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 });
|
||||
await db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId);
|
||||
await recalcWeek(db, weekId);
|
||||
const env = await callCoreFn(db, "core.fn_payroll_admin_line_update", { week_id: weekId, line_id: lineId, amount });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
}
|
||||
|
||||
export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise<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 });
|
||||
await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId);
|
||||
await recalcWeek(db, weekId);
|
||||
const env = await callCoreFn(db, "core.fn_payroll_admin_line_remove", { week_id: weekId, line_id: lineId });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
}
|
||||
|
||||
export async function jobRemainder(db: Db, jobId: number): Promise<number> {
|
||||
const job = await db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as
|
||||
| { qty_total_estimated: number }
|
||||
| undefined;
|
||||
if (!job) return 0;
|
||||
const paid = await db.prepare(
|
||||
`SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
|
||||
FROM destajo_cut_lines c
|
||||
JOIN destajo_periods p ON p.id=c.period_id
|
||||
JOIN payroll_weeks w ON w.id=p.week_id
|
||||
WHERE c.job_id=? AND w.status='paid'`,
|
||||
).get(jobId) as { n: number };
|
||||
return roundMoney(Math.max(0, job.qty_total_estimated - Number(paid.n)));
|
||||
export async function createDestajoJob(db: Db, tenantId: number, weekId: number, body: Record<string, unknown>): Promise<{ id: number }> {
|
||||
const env = await callCoreFn<{ id: number }>(db, "core.fn_destajo_job_create", { tenant_id: tenantId, week_id: weekId, ...body });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return { id: Number(env.data?.id) };
|
||||
}
|
||||
|
||||
export async function createDestajoJob(
|
||||
db: Db,
|
||||
tenantId: number,
|
||||
weekId: number,
|
||||
body: {
|
||||
project_id: number;
|
||||
worker_id: number;
|
||||
concepto: string;
|
||||
unit_code: string;
|
||||
qty_total_estimated: number;
|
||||
unit_price: number;
|
||||
qty_planned?: number;
|
||||
qty_extra?: number;
|
||||
},
|
||||
): Promise<{ id: number }> {
|
||||
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
|
||||
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
|
||||
const concepto = body.concepto.trim();
|
||||
if (!concepto) throw Object.assign(new Error("Concepto requerido"), { status: 400 });
|
||||
await db.prepare(
|
||||
`INSERT INTO destajo_jobs
|
||||
(project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
body.project_id,
|
||||
body.worker_id,
|
||||
concepto,
|
||||
body.unit_code,
|
||||
body.qty_total_estimated,
|
||||
body.unit_price,
|
||||
tenantId,
|
||||
);
|
||||
const jobId = await db.lastInsertId();
|
||||
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number };
|
||||
await db.prepare(
|
||||
`INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
|
||||
VALUES (?, ?, ?, 0, ?)`,
|
||||
).run(period.id, jobId, body.qty_planned ?? 0, body.qty_extra ?? 0);
|
||||
await recalcWeek(db, weekId);
|
||||
return { id: jobId };
|
||||
export async function patchDestajoCut(db: Db, weekId: number, cutId: number, patch: Record<string, unknown>): Promise<void> {
|
||||
const env = await callCoreFn(db, "core.fn_destajo_cut_patch", { week_id: weekId, cut_id: cutId, ...patch });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
}
|
||||
|
||||
export async function patchDestajoCut(
|
||||
db: Db,
|
||||
weekId: number,
|
||||
cutId: number,
|
||||
patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number },
|
||||
): Promise<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) {
|
||||
const weekId = await ensureWeek(db, tenantId, weekStart);
|
||||
await ensureCutForOpenJobs(db, weekId);
|
||||
await recalcWeek(db, weekId);
|
||||
const week = await db.prepare(
|
||||
`SELECT * FROM payroll_weeks WHERE id=?`,
|
||||
).get(weekId) as Record<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));
|
||||
await ensureWeek(db, tenantId, weekStart);
|
||||
const env = await callCoreFn<Record<string, unknown>>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
const data = env.data ?? {};
|
||||
const today = await resolveToday(db, tenantId);
|
||||
return {
|
||||
week,
|
||||
destajo_period: period,
|
||||
sheets,
|
||||
destajo_jobs: jobsOut,
|
||||
destajo_cuts: cuts,
|
||||
settings,
|
||||
units,
|
||||
days,
|
||||
board: {
|
||||
required_net: required,
|
||||
payable_net: payable,
|
||||
loan_recovery: loanRecovery,
|
||||
faltante,
|
||||
delta: roundMoney(required - payable),
|
||||
},
|
||||
can_assemble: canAssembleWeek(start, today),
|
||||
assemble_from: weekAssembleDate(start),
|
||||
};
|
||||
const start = String((data.week as Record<string, unknown>)?.week_start ?? weekStart);
|
||||
return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) };
|
||||
}
|
||||
|
||||
export async function weekCsv(db: Db, weekId: number): Promise<string> {
|
||||
const lines = await db.prepare(
|
||||
`SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name,
|
||||
l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code,
|
||||
l.gross, l.discounts, l.loan_discount, l.loan_label, l.payable_net, l.required_net
|
||||
FROM payroll_week_lines l
|
||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
||||
JOIN workers w ON w.id=l.worker_id
|
||||
LEFT JOIN projects p ON p.id=s.project_id
|
||||
WHERE s.week_id=?
|
||||
ORDER BY s.kind, p.name, w.last_name_p`,
|
||||
).all(weekId) as Record<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");
|
||||
const env = await callCoreFn<{ csv: string }>(db, "core.fn_payroll_week_csv", { week_id: weekId });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return String(env.data?.csv ?? "");
|
||||
}
|
||||
|
||||
export async function listOpenWeeks(db: Db, tenantId: number) {
|
||||
return await db.prepare(
|
||||
`SELECT w.*,
|
||||
(SELECT COALESCE(SUM(l.loan_discount), 0)
|
||||
FROM payroll_week_lines l
|
||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
||||
WHERE s.week_id=w.id) AS loan_recovery
|
||||
FROM payroll_weeks w
|
||||
WHERE w.tenant_id=? AND w.status != 'paid'
|
||||
ORDER BY w.week_start DESC`,
|
||||
).all(tenantId);
|
||||
const env = await callCoreFn<{ weeks: unknown[] }>(db, "core.fn_payroll_week_list_open", { tenant_id: tenantId });
|
||||
if (!env.ok) throwFromRpc(env);
|
||||
return env.data?.weeks ?? [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import type { Context, Hono } from "hono";
|
|||
import type { AuthUser } from "./auth.ts";
|
||||
import { tenantScope } from "./auth.ts";
|
||||
import { requireCoreAuth } from "./scope.ts";
|
||||
import { lastInsertId, projectById, projectMustBe, type Db } from "./db.ts";
|
||||
import { projectById, projectMustBe, type Db } from "./db.ts";
|
||||
import { generateLoanReceiptPdf } from "./pdf.ts";
|
||||
import { fullName } from "./mx.ts";
|
||||
import { callCoreFn } from "./rpc.ts";
|
||||
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
|
||||
import {
|
||||
addAdminLine,
|
||||
addJornalWorker,
|
||||
|
|
@ -47,13 +49,13 @@ export function registerPayrollRoutes(app: App) {
|
|||
const from = c.req.query("from") ?? "";
|
||||
const to = c.req.query("to") ?? "";
|
||||
const db = c.get("db");
|
||||
const rows = await db.prepare(
|
||||
`SELECT a.*, w.first_name, w.last_name_p FROM attendance a
|
||||
JOIN workers w ON w.id=a.worker_id
|
||||
WHERE a.project_id=? AND a.work_date BETWEEN ? AND ?
|
||||
ORDER BY a.work_date, w.last_name_p`,
|
||||
).all(projectId, from, to);
|
||||
return c.json({ attendance: rows });
|
||||
const env = await callCoreFn<{ attendance: unknown[] }>(
|
||||
db,
|
||||
"core.fn_attendance_list",
|
||||
{ project_id: projectId, from, to },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.put("/v1/attendance", ...requireCoreAuth, async (c) => {
|
||||
|
|
@ -69,7 +71,14 @@ export function registerPayrollRoutes(app: App) {
|
|||
["activo", "pausado"],
|
||||
"No se registra asistencia en un proyecto concluido o cancelado",
|
||||
);
|
||||
if (blocked) return c.json({ error: blocked.error }, blocked.status);
|
||||
if (blocked) {
|
||||
return respondApiError(
|
||||
c,
|
||||
blocked.status === 404 ? "NOT_FOUND" : "VALIDATION",
|
||||
blocked.error,
|
||||
{ route: routeLabel(c), project_id: body.project_id },
|
||||
);
|
||||
}
|
||||
const result = await setAttendance(db, tid(c), body);
|
||||
if ("error" in result) {
|
||||
return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400);
|
||||
|
|
@ -114,11 +123,13 @@ export function registerPayrollRoutes(app: App) {
|
|||
app.get("/v1/loans", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const workerId = c.req.query("worker_id");
|
||||
const sql = workerId
|
||||
? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC"
|
||||
: "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC";
|
||||
const loans = workerId ? await db.prepare(sql).all(Number(workerId)) : await db.prepare(sql).all();
|
||||
return c.json({ loans });
|
||||
const env = await callCoreFn<{ loans: unknown[] }>(
|
||||
db,
|
||||
"core.fn_loan_list",
|
||||
workerId ? { worker_id: Number(workerId) } : {},
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.post("/v1/loans", ...requireCoreAuth, async (c) => {
|
||||
|
|
@ -154,10 +165,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
app.get("/v1/loans/:id/recibo", ...requireCoreAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const loan = await db.prepare(
|
||||
`SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m
|
||||
FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`,
|
||||
).get(id) as {
|
||||
const env = await callCoreFn<{ loan: {
|
||||
delivered: number;
|
||||
commission_pct: number;
|
||||
commission_amount: number;
|
||||
|
|
@ -172,8 +180,14 @@ export function registerPayrollRoutes(app: App) {
|
|||
last_name_p: string;
|
||||
last_name_m: string;
|
||||
note: string | null;
|
||||
} | undefined;
|
||||
if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404);
|
||||
} }>(
|
||||
db,
|
||||
"core.fn_loan_get",
|
||||
{ id },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
if (!env.ok) return respondRpc(c, env);
|
||||
const loan = env.data!.loan;
|
||||
const bytes = await generateLoanReceiptPdf({
|
||||
title: "Recibo de aceptación de préstamo",
|
||||
subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`,
|
||||
|
|
@ -202,14 +216,7 @@ export function registerPayrollRoutes(app: App) {
|
|||
app.get("/v1/loan-payments/:id/recibo", ...requireCoreAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const pay = await db.prepare(
|
||||
`SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, wk.week_end
|
||||
FROM loan_payments p
|
||||
JOIN loans l ON l.id=p.loan_id
|
||||
JOIN workers w ON w.id=l.worker_id
|
||||
JOIN payroll_weeks wk ON wk.id=p.week_id
|
||||
WHERE p.id=?`,
|
||||
).get(id) as {
|
||||
const env = await callCoreFn<{ payment: {
|
||||
amount: number;
|
||||
installment_n: number;
|
||||
label: string;
|
||||
|
|
@ -220,8 +227,14 @@ export function registerPayrollRoutes(app: App) {
|
|||
last_name_p: string;
|
||||
last_name_m: string;
|
||||
week_end: string;
|
||||
} | undefined;
|
||||
if (!pay) return c.json({ error: "Recibo no encontrado" }, 404);
|
||||
} }>(
|
||||
db,
|
||||
"core.fn_loan_payment_get",
|
||||
{ id },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
if (!env.ok) return respondRpc(c, env);
|
||||
const pay = env.data!.payment;
|
||||
const bytes = await generateLoanReceiptPdf({
|
||||
title: "Recibo de pago de préstamo",
|
||||
subtitle: `Descuento del sábado ${pay.week_end}`,
|
||||
|
|
@ -380,65 +393,63 @@ export function registerPayrollRoutes(app: App) {
|
|||
["activo", "pausado"],
|
||||
"No se genera nómina de un proyecto concluido o cancelado",
|
||||
);
|
||||
if (blocked) return c.json({ error: blocked.error }, blocked.status);
|
||||
await db.prepare(
|
||||
"INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')",
|
||||
).run(body.project_id, body.week_start, body.week_end);
|
||||
const periodId = await lastInsertId(db);
|
||||
const workers = await db.prepare(
|
||||
`SELECT w.id, w.daily_wage FROM workers w
|
||||
JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=true
|
||||
WHERE w.status='activo'`,
|
||||
).all(body.project_id) as { id: number; daily_wage: number }[];
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
for (const w of workers) {
|
||||
const att = await db.prepare(
|
||||
`SELECT COUNT(*) AS n FROM attendance
|
||||
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`,
|
||||
).get(w.id, body.project_id, body.week_start, body.week_end) as { n: number };
|
||||
const days = Number(att.n);
|
||||
const gross = days * w.daily_wage;
|
||||
const extra = Number(body.extra_discounts?.[w.id] ?? 0);
|
||||
const net = gross - extra;
|
||||
await ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net);
|
||||
if (blocked) {
|
||||
return respondApiError(
|
||||
c,
|
||||
blocked.status === 404 ? "NOT_FOUND" : "VALIDATION",
|
||||
blocked.error,
|
||||
{ route: routeLabel(c), project_id: body.project_id },
|
||||
);
|
||||
}
|
||||
return c.json({ id: periodId });
|
||||
const env = await callCoreFn<{ id: number }>(
|
||||
db,
|
||||
"core.fn_payroll_period_create",
|
||||
{
|
||||
project_id: body.project_id,
|
||||
week_start: body.week_start,
|
||||
week_end: body.week_end,
|
||||
extra_discounts: body.extra_discounts ?? {},
|
||||
},
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods", ...requireCoreAuth, async (c) => {
|
||||
const db = c.get("db");
|
||||
const projectId = c.req.query("project_id");
|
||||
const sql = projectId
|
||||
? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC`
|
||||
: `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`;
|
||||
const periods = projectId ? await db.prepare(sql).all(Number(projectId)) : await db.prepare(sql).all();
|
||||
return c.json({ periods });
|
||||
const env = await callCoreFn<{ periods: unknown[] }>(
|
||||
db,
|
||||
"core.fn_payroll_period_list",
|
||||
projectId ? { project_id: Number(projectId) } : {},
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods/:id", ...requireCoreAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const period = await db.prepare(
|
||||
`SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`,
|
||||
).get(id);
|
||||
if (!period) return c.json({ error: "Periodo no encontrado" }, 404);
|
||||
const lines = await db.prepare(
|
||||
`SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l
|
||||
JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`,
|
||||
).all(id);
|
||||
return c.json({ period, lines });
|
||||
const env = await callCoreFn<{ period: unknown; lines: unknown[] }>(
|
||||
db,
|
||||
"core.fn_payroll_period_get",
|
||||
{ id },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
|
||||
app.get("/v1/payroll/periods/:id/csv", ...requireCoreAuth, async (c) => {
|
||||
const id = Number(c.req.param("id"));
|
||||
const db = c.get("db");
|
||||
const lines = await db.prepare(
|
||||
`SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net
|
||||
FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`,
|
||||
).all(id) as Record<string, unknown>[];
|
||||
const env = await callCoreFn<{ period: unknown; lines: Array<Record<string, unknown>> }>(
|
||||
db,
|
||||
"core.fn_payroll_period_get",
|
||||
{ id },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
if (!env.ok) return respondRpc(c, env);
|
||||
const lines = env.data?.lines ?? [];
|
||||
const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto";
|
||||
const rows = lines.map((l) =>
|
||||
[l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",")
|
||||
|
|
@ -453,7 +464,12 @@ export function registerPayrollRoutes(app: App) {
|
|||
const id = Number(c.req.param("id"));
|
||||
const { status } = await c.req.json<{ status: string }>();
|
||||
const db = c.get("db");
|
||||
await db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id);
|
||||
return c.json({ ok: true });
|
||||
const env = await callCoreFn(
|
||||
db,
|
||||
"core.fn_payroll_period_patch",
|
||||
{ id, status },
|
||||
{ route: routeLabel(c) },
|
||||
);
|
||||
return respondRpc(c, env);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
125
api/rpc.ts
Normal file
125
api/rpc.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { Db } from "./db.ts";
|
||||
import type { RpcEnvelope } from "./http_errors.ts";
|
||||
import { enrichInfraError } from "./http_errors.ts";
|
||||
|
||||
const DEADLOCK = "40P01";
|
||||
|
||||
export class RpcCallError extends Error {
|
||||
constructor(
|
||||
readonly envelope: RpcEnvelope,
|
||||
) {
|
||||
super(envelope.message);
|
||||
this.name = "RpcCallError";
|
||||
}
|
||||
}
|
||||
|
||||
function isEnvelope(value: unknown): value is RpcEnvelope {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const o = value as Record<string, unknown>;
|
||||
return typeof o.ok === "boolean"
|
||||
&& typeof o.code === "string"
|
||||
&& typeof o.message === "string"
|
||||
&& typeof o.layer === "string";
|
||||
}
|
||||
|
||||
function postgresCode(e: unknown): string | undefined {
|
||||
if (!e || typeof e !== "object") return undefined;
|
||||
return (e as { code?: string }).code;
|
||||
}
|
||||
|
||||
function postgresMessage(e: unknown): string {
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
}
|
||||
|
||||
async function invokeOnce<T>(
|
||||
db: Db,
|
||||
fn: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<RpcEnvelope<T>> {
|
||||
const row = await db.prepare(`SELECT ${fn}($1::jsonb) AS result`).get(
|
||||
JSON.stringify(payload),
|
||||
) as { result: unknown } | undefined;
|
||||
const raw = row?.result;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as RpcEnvelope<T>;
|
||||
if (isEnvelope(parsed)) return parsed;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
if (isEnvelope(raw)) return raw as RpcEnvelope<T>;
|
||||
throw new Error(`La función ${fn} no devolvió un envelope RPC válido`);
|
||||
}
|
||||
|
||||
export async function callCoreFn<T = unknown>(
|
||||
db: Db,
|
||||
fn: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
opts: { route?: string; retries?: number } = {},
|
||||
): Promise<RpcEnvelope<T>> {
|
||||
const route = opts.route ?? fn;
|
||||
const retries = opts.retries ?? 1;
|
||||
let lastErr: unknown;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
const envelope = await invokeOnce<T>(db, fn, payload);
|
||||
if (!envelope.ok) return envelope;
|
||||
return envelope;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
const code = postgresCode(e);
|
||||
if (code === DEADLOCK && attempt < retries) continue;
|
||||
const infra = enrichInfraError(
|
||||
fn,
|
||||
route,
|
||||
code ?? "exception",
|
||||
postgresMessage(e),
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
code: code === DEADLOCK ? "CONFLICT" : "INTERNAL",
|
||||
layer: "api",
|
||||
message: infra.message,
|
||||
context: { ...infra.context, sqlstate: code ?? null },
|
||||
data: null,
|
||||
errors: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
const infra = enrichInfraError(fn, route, "exception", postgresMessage(lastErr));
|
||||
return {
|
||||
ok: false,
|
||||
code: "INTERNAL",
|
||||
layer: "api",
|
||||
message: infra.message,
|
||||
context: infra.context,
|
||||
data: null,
|
||||
errors: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function callCoreFnData<T>(
|
||||
db: Db,
|
||||
fn: string,
|
||||
payload: Record<string, unknown> = {},
|
||||
opts: { route?: string } = {},
|
||||
): Promise<T> {
|
||||
const envelope = await callCoreFn<T>(db, fn, payload, opts);
|
||||
if (!envelope.ok) throw new RpcCallError(envelope);
|
||||
return envelope.data as T;
|
||||
}
|
||||
|
||||
export function unwrapRpc<T>(
|
||||
envelope: RpcEnvelope<T>,
|
||||
): { data?: T; error?: string; code?: string; status?: number } {
|
||||
if (envelope.ok) return { data: envelope.data as T };
|
||||
return {
|
||||
error: envelope.message,
|
||||
code: String(envelope.code),
|
||||
status: envelope.code === "NOT_FOUND" ? 404
|
||||
: envelope.code === "CONFLICT" ? 409
|
||||
: 400,
|
||||
};
|
||||
}
|
||||
53
db/README.md
53
db/README.md
|
|
@ -79,3 +79,56 @@ El `password_hash` (PBKDF2) no lo puede generar un changeset SQL. El primer
|
|||
`scripts/bootstrap-admin.ts` (ver Fase 4 del plan de migración), no con
|
||||
lógica de seed en el arranque de la API ni con datos hardcodeados en
|
||||
Liquibase.
|
||||
|
||||
## Funciones RPC (`core.fn_*`)
|
||||
|
||||
A partir del changeset `006-rpc-infra.sql`, el esquema `core` expone la
|
||||
lógica de negocio como funciones PostgreSQL invocadas desde la API con
|
||||
`SELECT core.fn_nombre($1::jsonb)`. La capa TypeScript **no** debe usar
|
||||
`db.prepare()` contra tablas de `core` en rutas de negocio; solo
|
||||
[`api/rpc.ts`](../api/rpc.ts) ejecuta el `SELECT` del RPC.
|
||||
|
||||
### Envelope de respuesta (BD)
|
||||
|
||||
Toda función devuelve un `jsonb` con esta forma:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"code": "OK",
|
||||
"layer": "db",
|
||||
"message": "Mensaje detallado en español",
|
||||
"context": { "fn": "fn_empresa_get", "id": 5 },
|
||||
"data": { },
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
- `code`: código de negocio (`OK`, `CREATED`, `VALIDATION`, `NOT_FOUND`,
|
||||
`CONFLICT`, `INTERNAL`, …).
|
||||
- `layer`: siempre `"db"` desde Postgres.
|
||||
- `message`: texto legible y específico (nunca genérico).
|
||||
- `context`: metadatos seguros para depuración (nombre de función, ids).
|
||||
- `data`: payload de éxito; `errors`: mapa de campos en validación.
|
||||
|
||||
Helpers en `006-rpc-infra.sql`: `rpc_ok`, `rpc_err`, `rpc_created`,
|
||||
`rpc_from_exception`.
|
||||
|
||||
### Contrato API
|
||||
|
||||
[`api/http_errors.ts`](../api/http_errors.ts) mapea `code` → HTTP y añade
|
||||
`status` al body. `respondRpc()` preserva `layer` y `message` de la BD;
|
||||
`respondApiError()` construye envelopes de capa `"api"`.
|
||||
|
||||
### Convenciones al añadir funciones
|
||||
|
||||
1. Un solo parámetro `payload jsonb`.
|
||||
2. Prefijo `fn_` para funciones que devuelven envelope.
|
||||
3. `SECURITY INVOKER` y `SET search_path = core`.
|
||||
4. Errores de negocio con `RETURN core.rpc_err(...)`; constraints de
|
||||
Postgres capturados en `EXCEPTION` y traducidos a mensajes claros.
|
||||
5. `GRANT EXECUTE ... TO panels_core_app` en el mismo changeset.
|
||||
6. Comentario de ejemplo `SELECT core.fn_*(...)` en el SQL.
|
||||
|
||||
Changesets RPC: `006` (infra) … `015` (documentos). Ver
|
||||
[`changelog-master.xml`](core/changelog-master.xml).
|
||||
|
|
|
|||
|
|
@ -12,5 +12,15 @@
|
|||
<include file="changesets/003-seed-catalogs.sql" relativeToChangelogFile="true"/>
|
||||
<include file="changesets/004-seed-dev-data.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>
|
||||
|
|
|
|||
147
db/core/changesets/006-rpc-infra.sql
Normal file
147
db/core/changesets/006-rpc-infra.sql
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · RPC infrastructure (envelope helpers)
|
||||
|
||||
--changeset panel:core-006a-rpc-generic-messages endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.rpc_generic_messages()
|
||||
RETURNS text[]
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT ARRAY[
|
||||
'OK', 'Error', 'ERROR', 'Operación exitosa', 'Operacion exitosa',
|
||||
'Algo salió mal', 'Algo salio mal', 'Error interno', 'Error interno del servidor'
|
||||
]::text[];
|
||||
$$;
|
||||
|
||||
--changeset panel:core-006b-rpc-assert-message endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.rpc_assert_message(p_message text)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_message IS NULL OR btrim(p_message) = '' THEN
|
||||
RAISE EXCEPTION 'RPC message must not be empty';
|
||||
END IF;
|
||||
IF p_message = ANY (core.rpc_generic_messages()) THEN
|
||||
RAISE EXCEPTION 'RPC message is too generic: %', p_message;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-006c-rpc-ok endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.rpc_ok(
|
||||
p_data jsonb,
|
||||
p_message text,
|
||||
p_ctx jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM core.rpc_assert_message(p_message);
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'code', 'OK',
|
||||
'layer', 'db',
|
||||
'message', p_message,
|
||||
'context', COALESCE(p_ctx, '{}'::jsonb),
|
||||
'data', p_data,
|
||||
'errors', NULL
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-006d-rpc-created endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.rpc_created(
|
||||
p_data jsonb,
|
||||
p_message text,
|
||||
p_ctx jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM core.rpc_assert_message(p_message);
|
||||
RETURN jsonb_build_object(
|
||||
'ok', true,
|
||||
'code', 'CREATED',
|
||||
'layer', 'db',
|
||||
'message', p_message,
|
||||
'context', COALESCE(p_ctx, '{}'::jsonb),
|
||||
'data', p_data,
|
||||
'errors', NULL
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-006e-rpc-err endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.rpc_err(
|
||||
p_code text,
|
||||
p_message text,
|
||||
p_ctx jsonb DEFAULT '{}'::jsonb,
|
||||
p_errors jsonb DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
PERFORM core.rpc_assert_message(p_message);
|
||||
RETURN jsonb_build_object(
|
||||
'ok', false,
|
||||
'code', p_code,
|
||||
'layer', 'db',
|
||||
'message', p_message,
|
||||
'context', COALESCE(p_ctx, '{}'::jsonb),
|
||||
'data', NULL,
|
||||
'errors', p_errors
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-006f-rpc-map-exception endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.rpc_from_exception(
|
||||
p_fn text,
|
||||
p_sqlstate text,
|
||||
p_message text,
|
||||
p_detail text DEFAULT NULL
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_code text := 'INTERNAL';
|
||||
v_msg text;
|
||||
v_ctx jsonb := jsonb_build_object('fn', p_fn, 'sqlstate', p_sqlstate);
|
||||
BEGIN
|
||||
IF p_sqlstate = '23505' THEN
|
||||
v_code := 'CONFLICT';
|
||||
v_msg := format('CONFLICT en %s: %s', p_fn, COALESCE(p_detail, p_message));
|
||||
ELSIF p_sqlstate = '23503' THEN
|
||||
v_code := 'VALIDATION';
|
||||
v_msg := format('Referencia inválida en %s: %s', p_fn, COALESCE(p_detail, p_message));
|
||||
ELSIF p_sqlstate = '23514' THEN
|
||||
v_code := 'VALIDATION';
|
||||
v_msg := format('Restricción de valor en %s: %s', p_fn, COALESCE(p_detail, p_message));
|
||||
ELSIF p_sqlstate = '40P01' THEN
|
||||
v_code := 'CONFLICT';
|
||||
v_msg := format('Deadlock en %s: otra operación modificó los mismos registros, intente de nuevo', p_fn);
|
||||
ELSE
|
||||
v_code := 'INTERNAL';
|
||||
v_msg := format('Error inesperado en %s (sqlstate=%s): %s', p_fn, p_sqlstate, p_message);
|
||||
END IF;
|
||||
RETURN core.rpc_err(v_code, v_msg, v_ctx);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-006g-rpc-grants endDelimiter:; splitStatements:true
|
||||
GRANT EXECUTE ON FUNCTION core.rpc_generic_messages() TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.rpc_assert_message(text) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.rpc_ok(jsonb, text, jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.rpc_created(jsonb, text, jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.rpc_err(text, text, jsonb, jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.rpc_from_exception(text, text, text, text) TO panels_core_app;
|
||||
123
db/core/changesets/007-rpc-catalogs.sql
Normal file
123
db/core/changesets/007-rpc-catalogs.sql
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · RPC catálogos y configuración tenant
|
||||
|
||||
--changeset panel:core-007a-fn-catalogs endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_catalogs(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_risks jsonb;
|
||||
v_themes jsonb;
|
||||
v_doc_types jsonb;
|
||||
v_proj_doc_types jsonb;
|
||||
v_co_doc_types jsonb;
|
||||
v_companies jsonb;
|
||||
BEGIN
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(r) ORDER BY r.code), '[]'::jsonb)
|
||||
INTO v_risks FROM risk_levels r;
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(t) ORDER BY t.id), '[]'::jsonb)
|
||||
INTO v_themes FROM badge_themes t;
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.code), '[]'::jsonb)
|
||||
INTO v_doc_types FROM document_types d;
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.code), '[]'::jsonb)
|
||||
INTO v_proj_doc_types FROM project_document_types p;
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.code), '[]'::jsonb)
|
||||
INTO v_co_doc_types FROM company_document_types c;
|
||||
SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.kind, lower(x.name)), '[]'::jsonb)
|
||||
INTO v_companies
|
||||
FROM (
|
||||
SELECT c.*, p.code AS parent_code, p.name AS parent_name,
|
||||
(SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count
|
||||
FROM companies c
|
||||
LEFT JOIN companies p ON p.id = c.parent_id
|
||||
ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, lower(c.name)
|
||||
) x;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'risks', v_risks,
|
||||
'themes', v_themes,
|
||||
'document_types', v_doc_types,
|
||||
'project_document_types', v_proj_doc_types,
|
||||
'company_document_types', v_co_doc_types,
|
||||
'companies', v_companies
|
||||
),
|
||||
format('Catálogos cargados: %s empresas, %s tipos de documento de personal',
|
||||
jsonb_array_length(v_companies), jsonb_array_length(v_doc_types)),
|
||||
jsonb_build_object('fn', 'fn_catalogs', 'company_count', jsonb_array_length(v_companies))
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_catalogs', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-007b-fn-tenant-timezone-get endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_tenant_timezone_get(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tid integer := (payload->>'tenant_id')::integer;
|
||||
v_tz text;
|
||||
BEGIN
|
||||
IF v_tid IS NULL THEN
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('timezone', 'America/Mexico_City'),
|
||||
'Zona horaria por defecto America/Mexico_City (sin tenant_id en la solicitud)',
|
||||
jsonb_build_object('fn', 'fn_tenant_timezone_get', 'tenant_id', NULL)
|
||||
);
|
||||
END IF;
|
||||
SELECT timezone INTO v_tz FROM tenant_settings WHERE tenant_id = v_tid;
|
||||
v_tz := COALESCE(v_tz, 'America/Mexico_City');
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('timezone', v_tz),
|
||||
format('Zona horaria del tenant %s: %s', v_tid, v_tz),
|
||||
jsonb_build_object('fn', 'fn_tenant_timezone_get', 'tenant_id', v_tid)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_tenant_timezone_get', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-007c-fn-tenant-settings-upsert endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_tenant_settings_upsert(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tid integer := (payload->>'tenant_id')::integer;
|
||||
v_tz text := btrim(payload->>'timezone');
|
||||
BEGIN
|
||||
IF v_tid IS NULL THEN
|
||||
RETURN core.rpc_err('VALIDATION',
|
||||
'fn_tenant_settings_upsert: tenant_id es obligatorio para guardar configuración',
|
||||
jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'field', 'tenant_id'));
|
||||
END IF;
|
||||
IF v_tz IS NULL OR v_tz = '' THEN
|
||||
RETURN core.rpc_err('VALIDATION',
|
||||
format('fn_tenant_settings_upsert: timezone vacío para tenant_id=%s', v_tid),
|
||||
jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'tenant_id', v_tid, 'field', 'timezone'));
|
||||
END IF;
|
||||
INSERT INTO tenant_settings (tenant_id, timezone, updated_at)
|
||||
VALUES (v_tid, v_tz, now())
|
||||
ON CONFLICT (tenant_id) DO UPDATE SET timezone = EXCLUDED.timezone, updated_at = now();
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('timezone', v_tz),
|
||||
format('Zona horaria del tenant %s actualizada a %s', v_tid, v_tz),
|
||||
jsonb_build_object('fn', 'fn_tenant_settings_upsert', 'tenant_id', v_tid)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_tenant_settings_upsert', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-007d-fn-catalogs-grants endDelimiter:; splitStatements:true
|
||||
GRANT EXECUTE ON FUNCTION core.fn_catalogs(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_tenant_timezone_get(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_tenant_settings_upsert(jsonb) TO panels_core_app;
|
||||
639
db/core/changesets/008-rpc-companies.sql
Normal file
639
db/core/changesets/008-rpc-companies.sql
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · RPC empresas (CRUD)
|
||||
|
||||
--changeset panel:core-008a-company-helpers endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core._company_trim_text(p_value text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT btrim(COALESCE(p_value, ''));
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._company_norm_code(p_value text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g'));
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._company_norm_rfc(p_value text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g'));
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._company_code_from_name(p_name text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
NULLIF(
|
||||
left(
|
||||
upper(
|
||||
regexp_replace(
|
||||
translate(
|
||||
COALESCE(p_name, ''),
|
||||
'áàäâãåéèëêíìïîóòöôõúùüûñÁÀÄÂÃÅÉÈËÊÍÌÏÎÓÒÖÔÕÚÙÜÛÑ',
|
||||
'aaaaaaeeeeiiiiooooouuuunAAAAAAEEEEIIIIOOOOOUUUUN'
|
||||
),
|
||||
'[^A-Z0-9]+',
|
||||
'',
|
||||
'g'
|
||||
)
|
||||
),
|
||||
12
|
||||
),
|
||||
''
|
||||
),
|
||||
'EMP'
|
||||
);
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._company_validate_code(p_code text)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_code IS NULL OR p_code = '' THEN
|
||||
RETURN 'Código de 2 a 16 caracteres (letras, números, _ o -)';
|
||||
END IF;
|
||||
IF p_code !~ '^[A-Z0-9][A-Z0-9_-]{1,15}$' THEN
|
||||
RETURN 'Código de 2 a 16 caracteres (letras, números, _ o -)';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._company_validate_profile(
|
||||
p_payload jsonb,
|
||||
p_require_legal boolean DEFAULT false
|
||||
)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_rfc text := core._company_norm_rfc(p_payload->>'rfc');
|
||||
v_clase text := upper(core._company_trim_text(p_payload->>'clase_riesgo'));
|
||||
BEGIN
|
||||
IF v_rfc <> '' AND v_rfc !~ '^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$' THEN
|
||||
RETURN 'RFC inválido (formato mexicano de 12 o 13 caracteres)';
|
||||
END IF;
|
||||
IF p_require_legal THEN
|
||||
IF core._company_trim_text(p_payload->>'razon_social') = ''
|
||||
AND core._company_trim_text(p_payload->>'name') = ''
|
||||
AND core._company_trim_text(p_payload->>'nombre_comercial') = '' THEN
|
||||
RETURN 'Indique razón social o nombre comercial';
|
||||
END IF;
|
||||
END IF;
|
||||
IF v_clase <> '' AND v_clase NOT IN ('I', 'II', 'III', 'IV', 'V') THEN
|
||||
RETURN 'Clase de riesgo IMSS debe ser I, II, III, IV o V';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._company_normalize_profile(
|
||||
p_payload jsonb,
|
||||
p_fallback_name text DEFAULT ''
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_nombre_comercial text;
|
||||
v_razon_social text;
|
||||
v_name text;
|
||||
BEGIN
|
||||
v_nombre_comercial := COALESCE(
|
||||
NULLIF(core._company_trim_text(p_payload->>'nombre_comercial'), ''),
|
||||
NULLIF(core._company_trim_text(p_payload->>'name'), ''),
|
||||
p_fallback_name
|
||||
);
|
||||
v_razon_social := COALESCE(
|
||||
NULLIF(core._company_trim_text(p_payload->>'razon_social'), ''),
|
||||
NULLIF(v_nombre_comercial, ''),
|
||||
p_fallback_name
|
||||
);
|
||||
v_name := COALESCE(
|
||||
NULLIF(core._company_trim_text(p_payload->>'name'), ''),
|
||||
NULLIF(v_nombre_comercial, ''),
|
||||
NULLIF(v_razon_social, ''),
|
||||
p_fallback_name
|
||||
);
|
||||
RETURN jsonb_build_object(
|
||||
'name', v_name,
|
||||
'nombre_comercial', v_nombre_comercial,
|
||||
'razon_social', v_razon_social,
|
||||
'rfc', core._company_norm_rfc(p_payload->>'rfc'),
|
||||
'regimen_fiscal', core._company_trim_text(p_payload->>'regimen_fiscal'),
|
||||
'registro_patronal', upper(core._company_trim_text(p_payload->>'registro_patronal')),
|
||||
'clase_riesgo', upper(core._company_trim_text(p_payload->>'clase_riesgo')),
|
||||
'domicilio_fiscal', core._company_trim_text(p_payload->>'domicilio_fiscal'),
|
||||
'codigo_postal', core._company_trim_text(p_payload->>'codigo_postal'),
|
||||
'ciudad', core._company_trim_text(p_payload->>'ciudad'),
|
||||
'estado', core._company_trim_text(p_payload->>'estado'),
|
||||
'telefono', core._company_trim_text(p_payload->>'telefono'),
|
||||
'email', lower(core._company_trim_text(p_payload->>'email')),
|
||||
'representante_legal', core._company_trim_text(p_payload->>'representante_legal'),
|
||||
'giro', core._company_trim_text(p_payload->>'giro')
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-008b-fn-company-list endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_company_list(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tid integer := NULLIF(payload->>'tenant_id', '')::integer;
|
||||
v_companies jsonb;
|
||||
v_count integer;
|
||||
BEGIN
|
||||
SELECT COALESCE(
|
||||
jsonb_agg(row_to_json(x)::jsonb ORDER BY
|
||||
CASE x.kind WHEN 'principal' THEN 0 ELSE 1 END,
|
||||
lower(x.name)),
|
||||
'[]'::jsonb
|
||||
),
|
||||
COUNT(*)::integer
|
||||
INTO v_companies, v_count
|
||||
FROM (
|
||||
SELECT c.*,
|
||||
p.code AS parent_code,
|
||||
p.name AS parent_name,
|
||||
(SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count
|
||||
FROM companies c
|
||||
LEFT JOIN companies p ON p.id = c.parent_id
|
||||
WHERE v_tid IS NULL OR c.tenant_id = v_tid
|
||||
) x;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('companies', v_companies),
|
||||
format('Listado de empresas: %s registro(s)%s',
|
||||
v_count,
|
||||
CASE WHEN v_tid IS NOT NULL THEN format(' para tenant_id=%s', v_tid) ELSE '' END),
|
||||
jsonb_build_object('fn', 'fn_company_list', 'tenant_id', v_tid, 'count', v_count)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_company_list', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-008c-fn-company-get endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_company_get(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||
v_tid integer := NULLIF(payload->>'tenant_id', '')::integer;
|
||||
v_company jsonb;
|
||||
BEGIN
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_company_get: id es obligatorio para consultar una empresa',
|
||||
jsonb_build_object('fn', 'fn_company_get', 'field', 'id')
|
||||
);
|
||||
END IF;
|
||||
SELECT to_jsonb(c) INTO v_company
|
||||
FROM companies c
|
||||
WHERE c.id = v_id
|
||||
AND (v_tid IS NULL OR c.tenant_id IS NULL OR c.tenant_id = v_tid);
|
||||
IF v_company IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_company_get: empresa id=%s no encontrada%s', v_id,
|
||||
CASE WHEN v_tid IS NOT NULL THEN format(' en tenant_id=%s', v_tid) ELSE '' END),
|
||||
jsonb_build_object('fn', 'fn_company_get', 'id', v_id, 'tenant_id', v_tid)
|
||||
);
|
||||
END IF;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('company', v_company),
|
||||
format('Empresa id=%s cargada (%s)', v_id, v_company->>'code'),
|
||||
jsonb_build_object('fn', 'fn_company_get', 'id', v_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_company_get', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-008d-fn-company-get-by-code endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_company_get_by_code(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_code text := core._company_norm_code(payload->>'code');
|
||||
v_company jsonb;
|
||||
BEGIN
|
||||
IF v_code = '' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_company_get_by_code: code es obligatorio para consultar una empresa',
|
||||
jsonb_build_object('fn', 'fn_company_get_by_code', 'field', 'code')
|
||||
);
|
||||
END IF;
|
||||
SELECT to_jsonb(c) INTO v_company
|
||||
FROM companies c
|
||||
WHERE c.code = v_code;
|
||||
IF v_company IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_company_get_by_code: empresa con código %s no encontrada', v_code),
|
||||
jsonb_build_object('fn', 'fn_company_get_by_code', 'code', v_code)
|
||||
);
|
||||
END IF;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('company', v_company),
|
||||
format('Empresa con código %s cargada (id=%s)', v_code, v_company->>'id'),
|
||||
jsonb_build_object('fn', 'fn_company_get_by_code', 'code', v_code)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_company_get_by_code', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-008e-fn-next-company-code endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_next_company_code(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_name text := core._company_trim_text(payload->>'name');
|
||||
v_base text;
|
||||
v_n integer;
|
||||
v_code text;
|
||||
BEGIN
|
||||
IF v_name = '' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_next_company_code: name es obligatorio para generar un código de empresa',
|
||||
jsonb_build_object('fn', 'fn_next_company_code', 'field', 'name')
|
||||
);
|
||||
END IF;
|
||||
v_base := core._company_code_from_name(v_name);
|
||||
IF NOT EXISTS (SELECT 1 FROM companies WHERE code = v_base) THEN
|
||||
v_code := v_base;
|
||||
ELSE
|
||||
SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1
|
||||
INTO v_n
|
||||
FROM companies
|
||||
WHERE code ~ '^EMP-[0-9]{4}';
|
||||
v_code := 'EMP-' || lpad(v_n::text, 4, '0');
|
||||
END IF;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('code', v_code),
|
||||
format('Código sugerido para "%s": %s', v_name, v_code),
|
||||
jsonb_build_object('fn', 'fn_next_company_code', 'name', v_name, 'code', v_code)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_next_company_code', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-008f-fn-company-create endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_company_create(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tid integer := NULLIF(payload->>'tenant_id', '')::integer;
|
||||
v_profile_err text;
|
||||
v_profile jsonb;
|
||||
v_principal companies%ROWTYPE;
|
||||
v_parent_id bigint;
|
||||
v_parent companies%ROWTYPE;
|
||||
v_code text;
|
||||
v_code_err text;
|
||||
v_new_id bigint;
|
||||
v_n integer;
|
||||
v_company jsonb;
|
||||
BEGIN
|
||||
v_profile_err := core._company_validate_profile(payload, true);
|
||||
IF v_profile_err IS NOT NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_create: %s', v_profile_err),
|
||||
jsonb_build_object('fn', 'fn_company_create', 'field', 'profile')
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_profile := core._company_normalize_profile(payload, '');
|
||||
IF core._company_trim_text(v_profile->>'name') = '' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_company_create: nombre de empresa obligatorio',
|
||||
jsonb_build_object('fn', 'fn_company_create', 'field', 'name')
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF v_tid IS NOT NULL THEN
|
||||
SELECT * INTO v_principal
|
||||
FROM companies
|
||||
WHERE kind = 'principal' AND tenant_id = v_tid
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
ELSE
|
||||
SELECT * INTO v_principal
|
||||
FROM companies
|
||||
WHERE kind = 'principal'
|
||||
ORDER BY id
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_company_create: no hay empresa principal configurada en el tenant',
|
||||
jsonb_build_object('fn', 'fn_company_create', 'tenant_id', v_tid)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_parent_id := COALESCE(NULLIF(payload->>'parent_id', '')::bigint, v_principal.id);
|
||||
SELECT * INTO v_parent FROM companies WHERE id = v_parent_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_create: empresa padre id=%s no encontrada', v_parent_id),
|
||||
jsonb_build_object('fn', 'fn_company_create', 'parent_id', v_parent_id)
|
||||
);
|
||||
END IF;
|
||||
IF v_tid IS NOT NULL AND v_parent.tenant_id IS NOT NULL AND v_parent.tenant_id <> v_tid THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_create: empresa padre id=%s pertenece a tenant_id=%s, distinto del solicitado (%s)',
|
||||
v_parent_id, v_parent.tenant_id, v_tid),
|
||||
jsonb_build_object('fn', 'fn_company_create', 'parent_id', v_parent_id, 'tenant_id', v_tid)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_code := core._company_norm_code(payload->>'code');
|
||||
IF v_code = '' THEN
|
||||
v_code := core._company_code_from_name(v_profile->>'name');
|
||||
IF EXISTS (SELECT 1 FROM companies WHERE code = v_code) THEN
|
||||
SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1
|
||||
INTO v_n
|
||||
FROM companies
|
||||
WHERE code ~ '^EMP-[0-9]{4}';
|
||||
v_code := 'EMP-' || lpad(v_n::text, 4, '0');
|
||||
END IF;
|
||||
END IF;
|
||||
v_code_err := core._company_validate_code(v_code);
|
||||
IF v_code_err IS NOT NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_create: %s (código=%s)', v_code_err, v_code),
|
||||
jsonb_build_object('fn', 'fn_company_create', 'field', 'code', 'code', v_code)
|
||||
);
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM companies WHERE code = v_code) THEN
|
||||
RETURN core.rpc_err(
|
||||
'CONFLICT',
|
||||
format('fn_company_create: ya existe una empresa con el código %s', v_code),
|
||||
jsonb_build_object('fn', 'fn_company_create', 'code', v_code)
|
||||
);
|
||||
END IF;
|
||||
|
||||
INSERT INTO companies (
|
||||
code, name, parent_id, kind, status, tenant_id,
|
||||
registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
|
||||
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
|
||||
) VALUES (
|
||||
v_code,
|
||||
v_profile->>'name',
|
||||
v_parent.id,
|
||||
'sub',
|
||||
'activo',
|
||||
COALESCE(v_tid, v_principal.tenant_id),
|
||||
v_profile->>'registro_patronal',
|
||||
v_profile->>'razon_social',
|
||||
v_profile->>'nombre_comercial',
|
||||
v_profile->>'rfc',
|
||||
v_profile->>'regimen_fiscal',
|
||||
v_profile->>'clase_riesgo',
|
||||
v_profile->>'domicilio_fiscal',
|
||||
v_profile->>'codigo_postal',
|
||||
v_profile->>'ciudad',
|
||||
v_profile->>'estado',
|
||||
v_profile->>'telefono',
|
||||
v_profile->>'email',
|
||||
v_profile->>'representante_legal',
|
||||
v_profile->>'giro'
|
||||
)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
SELECT to_jsonb(c) INTO v_company FROM companies c WHERE c.id = v_new_id;
|
||||
RETURN core.rpc_created(
|
||||
jsonb_build_object('company', v_company),
|
||||
format('Subempresa creada: %s (%s), id=%s', v_company->>'name', v_code, v_new_id),
|
||||
jsonb_build_object('fn', 'fn_company_create', 'id', v_new_id, 'code', v_code, 'parent_id', v_parent.id)
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN unique_violation THEN
|
||||
RETURN core.rpc_err(
|
||||
'CONFLICT',
|
||||
format('fn_company_create: ya existe una empresa con el código %s', v_code),
|
||||
jsonb_build_object('fn', 'fn_company_create', 'code', v_code)
|
||||
);
|
||||
WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_company_create', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-008g-fn-company-update endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_company_update(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||
v_tid integer := NULLIF(payload->>'tenant_id', '')::integer;
|
||||
v_current companies%ROWTYPE;
|
||||
v_merged jsonb;
|
||||
v_profile_err text;
|
||||
v_profile jsonb;
|
||||
v_code text;
|
||||
v_code_err text;
|
||||
v_status text;
|
||||
v_clash_id bigint;
|
||||
v_company jsonb;
|
||||
BEGIN
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_company_update: id es obligatorio para actualizar una empresa',
|
||||
jsonb_build_object('fn', 'fn_company_update', 'field', 'id')
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_current FROM companies WHERE id = v_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_company_update: empresa id=%s no encontrada', v_id),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'tenant_id', v_tid)
|
||||
);
|
||||
END IF;
|
||||
IF v_tid IS NOT NULL AND v_current.tenant_id IS NOT NULL AND v_current.tenant_id <> v_tid THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_company_update: empresa id=%s no encontrada en tenant_id=%s', v_id, v_tid),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'tenant_id', v_tid)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_merged := jsonb_build_object(
|
||||
'name', CASE WHEN payload ? 'name' THEN payload->>'name' ELSE v_current.name END,
|
||||
'code', CASE WHEN payload ? 'code' THEN payload->>'code' ELSE v_current.code END,
|
||||
'status', CASE WHEN payload ? 'status' THEN payload->>'status' ELSE v_current.status END,
|
||||
'registro_patronal', CASE WHEN payload ? 'registro_patronal' THEN payload->>'registro_patronal' ELSE v_current.registro_patronal END,
|
||||
'razon_social', CASE WHEN payload ? 'razon_social' THEN payload->>'razon_social' ELSE v_current.razon_social END,
|
||||
'nombre_comercial', CASE WHEN payload ? 'nombre_comercial' THEN payload->>'nombre_comercial' ELSE v_current.nombre_comercial END,
|
||||
'rfc', CASE WHEN payload ? 'rfc' THEN payload->>'rfc' ELSE v_current.rfc END,
|
||||
'regimen_fiscal', CASE WHEN payload ? 'regimen_fiscal' THEN payload->>'regimen_fiscal' ELSE v_current.regimen_fiscal END,
|
||||
'clase_riesgo', CASE WHEN payload ? 'clase_riesgo' THEN payload->>'clase_riesgo' ELSE v_current.clase_riesgo END,
|
||||
'domicilio_fiscal', CASE WHEN payload ? 'domicilio_fiscal' THEN payload->>'domicilio_fiscal' ELSE v_current.domicilio_fiscal END,
|
||||
'codigo_postal', CASE WHEN payload ? 'codigo_postal' THEN payload->>'codigo_postal' ELSE v_current.codigo_postal END,
|
||||
'ciudad', CASE WHEN payload ? 'ciudad' THEN payload->>'ciudad' ELSE v_current.ciudad END,
|
||||
'estado', CASE WHEN payload ? 'estado' THEN payload->>'estado' ELSE v_current.estado END,
|
||||
'telefono', CASE WHEN payload ? 'telefono' THEN payload->>'telefono' ELSE v_current.telefono END,
|
||||
'email', CASE WHEN payload ? 'email' THEN payload->>'email' ELSE v_current.email END,
|
||||
'representante_legal', CASE WHEN payload ? 'representante_legal' THEN payload->>'representante_legal' ELSE v_current.representante_legal END,
|
||||
'giro', CASE WHEN payload ? 'giro' THEN payload->>'giro' ELSE v_current.giro END
|
||||
);
|
||||
|
||||
v_profile_err := core._company_validate_profile(v_merged, false);
|
||||
IF v_profile_err IS NOT NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_update: %s (id=%s)', v_profile_err, v_id),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'profile')
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_profile := core._company_normalize_profile(v_merged, v_current.name);
|
||||
IF core._company_trim_text(v_profile->>'name') = '' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_update: nombre de empresa obligatorio (id=%s)', v_id),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'name')
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_code := v_current.code;
|
||||
IF payload ? 'code' THEN
|
||||
v_code := core._company_norm_code(payload->>'code');
|
||||
v_code_err := core._company_validate_code(v_code);
|
||||
IF v_code_err IS NOT NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_update: %s (id=%s, código=%s)', v_code_err, v_id, v_code),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'code', 'code', v_code)
|
||||
);
|
||||
END IF;
|
||||
SELECT id INTO v_clash_id FROM companies WHERE code = v_code AND id <> v_id;
|
||||
IF FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'CONFLICT',
|
||||
format('fn_company_update: ya existe otra empresa con el código %s (id=%s)', v_code, v_clash_id),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code, 'conflict_id', v_clash_id)
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_status := v_current.status;
|
||||
IF payload ? 'status' THEN
|
||||
IF (payload->>'status') NOT IN ('activo', 'inactivo') THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_update: estatus debe ser activo o inactivo (recibido=%s, id=%s)',
|
||||
payload->>'status', v_id),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'field', 'status', 'status', payload->>'status')
|
||||
);
|
||||
END IF;
|
||||
IF v_current.kind = 'principal' AND payload->>'status' = 'inactivo' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_company_update: la empresa principal id=%s no se puede inactivar', v_id),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'kind', v_current.kind)
|
||||
);
|
||||
END IF;
|
||||
v_status := payload->>'status';
|
||||
END IF;
|
||||
|
||||
UPDATE companies SET
|
||||
name = v_profile->>'name',
|
||||
code = v_code,
|
||||
status = v_status,
|
||||
registro_patronal = v_profile->>'registro_patronal',
|
||||
razon_social = v_profile->>'razon_social',
|
||||
nombre_comercial = v_profile->>'nombre_comercial',
|
||||
rfc = v_profile->>'rfc',
|
||||
regimen_fiscal = v_profile->>'regimen_fiscal',
|
||||
clase_riesgo = v_profile->>'clase_riesgo',
|
||||
domicilio_fiscal = v_profile->>'domicilio_fiscal',
|
||||
codigo_postal = v_profile->>'codigo_postal',
|
||||
ciudad = v_profile->>'ciudad',
|
||||
estado = v_profile->>'estado',
|
||||
telefono = v_profile->>'telefono',
|
||||
email = v_profile->>'email',
|
||||
representante_legal = v_profile->>'representante_legal',
|
||||
giro = v_profile->>'giro'
|
||||
WHERE id = v_id;
|
||||
|
||||
IF v_code <> v_current.code THEN
|
||||
UPDATE workers SET hire_type = v_code WHERE company_id = v_id;
|
||||
END IF;
|
||||
|
||||
SELECT to_jsonb(c) INTO v_company FROM companies c WHERE c.id = v_id;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('company', v_company),
|
||||
format('Empresa id=%s actualizada (%s)', v_id, v_code),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code)
|
||||
);
|
||||
EXCEPTION
|
||||
WHEN unique_violation THEN
|
||||
RETURN core.rpc_err(
|
||||
'CONFLICT',
|
||||
format('fn_company_update: ya existe una empresa con el código %s (id=%s)', v_code, v_id),
|
||||
jsonb_build_object('fn', 'fn_company_update', 'id', v_id, 'code', v_code)
|
||||
);
|
||||
WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_company_update', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-008h-fn-companies-grants endDelimiter:; splitStatements:true
|
||||
GRANT EXECUTE ON FUNCTION core._company_trim_text(text) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core._company_norm_code(text) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core._company_norm_rfc(text) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core._company_code_from_name(text) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core._company_validate_code(text) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core._company_validate_profile(jsonb, boolean) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core._company_normalize_profile(jsonb, text) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_company_list(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_company_get(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_company_get_by_code(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_next_company_code(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_company_create(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_company_update(jsonb) TO panels_core_app;
|
||||
604
db/core/changesets/009-rpc-projects.sql
Normal file
604
db/core/changesets/009-rpc-projects.sql
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · RPC proyectos (obras)
|
||||
|
||||
--changeset panel:core-009a-fn-next-project-code endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_next_project_code(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_n integer;
|
||||
v_code text;
|
||||
BEGIN
|
||||
SELECT COALESCE(MAX(substring(code FROM 5)::integer), 0) + 1
|
||||
INTO v_n
|
||||
FROM projects
|
||||
WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}';
|
||||
v_code := 'PRY-' || lpad(v_n::text, 4, '0');
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('code', v_code),
|
||||
format('Siguiente código de proyecto asignado: %s', v_code),
|
||||
jsonb_build_object('fn', 'fn_next_project_code', 'sequence', v_n)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_next_project_code', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009b-fn-project-assert-status endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_project_assert_status(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id bigint := (payload->>'project_id')::bigint;
|
||||
v_allowed text[];
|
||||
v_proj record;
|
||||
BEGIN
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_assert_status: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF payload->'allowed_statuses' IS NULL OR jsonb_typeof(payload->'allowed_statuses') <> 'array' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_assert_status: allowed_statuses debe ser un arreglo JSON',
|
||||
jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'allowed_statuses')
|
||||
);
|
||||
END IF;
|
||||
SELECT array_agg(btrim(elem))
|
||||
INTO v_allowed
|
||||
FROM jsonb_array_elements_text(payload->'allowed_statuses') AS elem
|
||||
WHERE btrim(elem) IN ('activo', 'pausado', 'concluido', 'cancelado');
|
||||
IF v_allowed IS NULL OR cardinality(v_allowed) = 0 THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_assert_status: allowed_statuses no contiene estados válidos',
|
||||
jsonb_build_object('fn', 'fn_project_assert_status', 'field', 'allowed_statuses')
|
||||
);
|
||||
END IF;
|
||||
SELECT p.id, p.code, p.name, p.status
|
||||
INTO v_proj
|
||||
FROM projects p
|
||||
WHERE p.id = v_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_project_assert_status: proyecto id=%s no encontrado', v_id),
|
||||
jsonb_build_object('fn', 'fn_project_assert_status', 'project_id', v_id)
|
||||
);
|
||||
END IF;
|
||||
IF NOT (v_proj.status = ANY (v_allowed)) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_project_assert_status: proyecto %s (%s) está en estado %s, no permitido para esta operación',
|
||||
v_proj.code, v_id, v_proj.status),
|
||||
jsonb_build_object(
|
||||
'fn', 'fn_project_assert_status',
|
||||
'project_id', v_id,
|
||||
'status', v_proj.status,
|
||||
'allowed_statuses', to_jsonb(v_allowed)
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'project_id', v_proj.id,
|
||||
'code', v_proj.code,
|
||||
'name', v_proj.name,
|
||||
'status', v_proj.status
|
||||
),
|
||||
format('Proyecto %s en estado %s válido para la operación', v_proj.code, v_proj.status),
|
||||
jsonb_build_object('fn', 'fn_project_assert_status', 'project_id', v_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_project_assert_status', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009c-fn-project-list endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_project_list(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_status_raw text := nullif(btrim(payload->>'status'), '');
|
||||
v_statuses text[];
|
||||
v_projects jsonb;
|
||||
BEGIN
|
||||
IF v_status_raw IS NOT NULL THEN
|
||||
SELECT array_agg(DISTINCT btrim(s))
|
||||
INTO v_statuses
|
||||
FROM unnest(string_to_array(v_status_raw, ',')) AS s
|
||||
WHERE btrim(s) IN ('activo', 'pausado', 'concluido', 'cancelado');
|
||||
IF v_statuses IS NULL OR cardinality(v_statuses) = 0 THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_project_list: ningún estado válido en status=%s', v_status_raw),
|
||||
jsonb_build_object('fn', 'fn_project_list', 'field', 'status', 'value', v_status_raw)
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
SELECT COALESCE(jsonb_agg(row_to_json(x)::jsonb ORDER BY x.sort_key, x.id), '[]'::jsonb)
|
||||
INTO v_projects
|
||||
FROM (
|
||||
SELECT
|
||||
p.*,
|
||||
c.name AS company_name,
|
||||
c.code AS company_code,
|
||||
(SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count,
|
||||
(SELECT COUNT(*) FROM project_document_types t
|
||||
WHERE t.required = true AND NOT EXISTS (
|
||||
SELECT 1 FROM project_documents d
|
||||
WHERE d.project_id = p.id AND d.type_code = t.code AND d.is_current = true
|
||||
)) AS missing_docs,
|
||||
(SELECT COUNT(*) FROM budget_items b WHERE b.project_id = p.id) AS budget_count,
|
||||
CASE p.status
|
||||
WHEN 'activo' THEN 0
|
||||
WHEN 'pausado' THEN 1
|
||||
WHEN 'concluido' THEN 2
|
||||
ELSE 3
|
||||
END AS sort_key
|
||||
FROM projects p
|
||||
LEFT JOIN companies c ON c.id = p.company_id
|
||||
WHERE v_statuses IS NULL OR p.status = ANY (v_statuses)
|
||||
) x;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('projects', v_projects),
|
||||
format('Listado de proyectos: %s registro(s)', jsonb_array_length(v_projects)),
|
||||
jsonb_build_object('fn', 'fn_project_list', 'count', jsonb_array_length(v_projects))
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_project_list', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009d-fn-project-get endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_project_get(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id bigint := (payload->>'id')::bigint;
|
||||
v_project jsonb;
|
||||
BEGIN
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_get: id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_project_get', 'field', 'id')
|
||||
);
|
||||
END IF;
|
||||
SELECT row_to_json(x)::jsonb
|
||||
INTO v_project
|
||||
FROM (
|
||||
SELECT
|
||||
p.*,
|
||||
c.name AS company_name,
|
||||
c.code AS company_code,
|
||||
(SELECT COUNT(*) FROM assignments a WHERE a.project_id = p.id AND a.active = true) AS active_count
|
||||
FROM projects p
|
||||
LEFT JOIN companies c ON c.id = p.company_id
|
||||
WHERE p.id = v_id
|
||||
) x;
|
||||
IF v_project IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_project_get: proyecto id=%s no encontrado', v_id),
|
||||
jsonb_build_object('fn', 'fn_project_get', 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('project', v_project),
|
||||
format('Proyecto %s cargado', v_project->>'code'),
|
||||
jsonb_build_object('fn', 'fn_project_get', 'id', v_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_project_get', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009e-fn-project-create endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_project_create(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tid integer := COALESCE(
|
||||
(payload->>'tenant_id')::integer,
|
||||
NULLIF(current_setting('app.tenant_id', true), '')::integer
|
||||
);
|
||||
v_name text := nullif(btrim(payload->>'name'), '');
|
||||
v_address text := coalesce(nullif(btrim(payload->>'address'), ''), '');
|
||||
v_theme_id text := coalesce(nullif(btrim(payload->>'theme_id'), ''), 'arctec-dos-logos-fold');
|
||||
v_status text := coalesce(nullif(btrim(payload->>'status'), ''), 'activo');
|
||||
v_company_id integer := (payload->>'company_id')::integer;
|
||||
v_contract_amount numeric(14,2) := (payload->>'contract_amount')::numeric(14,2);
|
||||
v_start_date date := nullif(btrim(payload->>'start_date'), '')::date;
|
||||
v_end_date date := nullif(btrim(payload->>'end_date'), '')::date;
|
||||
v_resident_name text := coalesce(nullif(btrim(payload->>'resident_name'), ''), '');
|
||||
v_siroc text := upper(coalesce(nullif(btrim(payload->>'siroc'), ''), ''));
|
||||
v_payroll_tax_pct numeric(5,2) := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), 4);
|
||||
v_code_env jsonb;
|
||||
v_code text;
|
||||
v_row projects%ROWTYPE;
|
||||
BEGIN
|
||||
IF v_name IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_create: nombre de proyecto obligatorio',
|
||||
jsonb_build_object('fn', 'fn_project_create', 'field', 'name')
|
||||
);
|
||||
END IF;
|
||||
IF v_company_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_create: company_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_project_create', 'field', 'company_id')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_project_create: empresa id=%s no encontrada', v_company_id),
|
||||
jsonb_build_object('fn', 'fn_project_create', 'company_id', v_company_id)
|
||||
);
|
||||
END IF;
|
||||
IF v_status NOT IN ('activo', 'pausado', 'concluido', 'cancelado') THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_project_create: estado inválido %s', v_status),
|
||||
jsonb_build_object('fn', 'fn_project_create', 'field', 'status', 'value', v_status)
|
||||
);
|
||||
END IF;
|
||||
IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_create: la fecha de término no puede ser anterior al inicio',
|
||||
jsonb_build_object('fn', 'fn_project_create', 'field', 'end_date')
|
||||
);
|
||||
END IF;
|
||||
v_code_env := core.fn_next_project_code('{}'::jsonb);
|
||||
IF NOT COALESCE((v_code_env->>'ok')::boolean, false) THEN
|
||||
RETURN v_code_env;
|
||||
END IF;
|
||||
v_code := v_code_env->'data'->>'code';
|
||||
INSERT INTO projects (
|
||||
code, name, address, theme_id, status, company_id, contract_amount,
|
||||
start_date, end_date, resident_name, siroc, payroll_tax_pct, tenant_id
|
||||
) VALUES (
|
||||
v_code, v_name, v_address, v_theme_id, v_status, v_company_id, v_contract_amount,
|
||||
v_start_date, v_end_date, v_resident_name, v_siroc, v_payroll_tax_pct, v_tid
|
||||
)
|
||||
RETURNING * INTO v_row;
|
||||
RETURN core.rpc_created(
|
||||
jsonb_build_object('id', v_row.id, 'code', v_row.code, 'status', v_row.status),
|
||||
format('Proyecto %s (%s) creado', v_row.code, v_row.name),
|
||||
jsonb_build_object('fn', 'fn_project_create', 'id', v_row.id, 'code', v_row.code)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_project_create', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009f-fn-project-update endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_project_update(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_id bigint := (payload->>'id')::bigint;
|
||||
v_cur projects%ROWTYPE;
|
||||
v_name text;
|
||||
v_address text;
|
||||
v_theme_id text;
|
||||
v_status text;
|
||||
v_company_id integer;
|
||||
v_contract_amount numeric(14,2);
|
||||
v_start_date date;
|
||||
v_end_date date;
|
||||
v_resident_name text;
|
||||
v_siroc text;
|
||||
v_payroll_tax_pct numeric(5,2);
|
||||
BEGIN
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_update: id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_project_update', 'field', 'id')
|
||||
);
|
||||
END IF;
|
||||
SELECT * INTO v_cur FROM projects WHERE id = v_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_project_update: proyecto id=%s no encontrado', v_id),
|
||||
jsonb_build_object('fn', 'fn_project_update', 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name);
|
||||
v_address := CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE v_cur.address END;
|
||||
v_theme_id := coalesce(nullif(btrim(payload->>'theme_id'), ''), v_cur.theme_id);
|
||||
v_status := CASE
|
||||
WHEN payload ? 'status' AND btrim(payload->>'status') IN ('activo', 'pausado', 'concluido', 'cancelado')
|
||||
THEN btrim(payload->>'status')
|
||||
ELSE v_cur.status
|
||||
END;
|
||||
v_company_id := coalesce((payload->>'company_id')::integer, v_cur.company_id);
|
||||
v_contract_amount := CASE
|
||||
WHEN payload ? 'contract_amount' THEN (payload->>'contract_amount')::numeric(14,2)
|
||||
ELSE v_cur.contract_amount
|
||||
END;
|
||||
v_start_date := CASE
|
||||
WHEN payload ? 'start_date' THEN nullif(btrim(payload->>'start_date'), '')::date
|
||||
ELSE v_cur.start_date
|
||||
END;
|
||||
v_end_date := CASE
|
||||
WHEN payload ? 'end_date' THEN nullif(btrim(payload->>'end_date'), '')::date
|
||||
ELSE v_cur.end_date
|
||||
END;
|
||||
v_resident_name := CASE
|
||||
WHEN payload ? 'resident_name' THEN coalesce(nullif(btrim(payload->>'resident_name'), ''), '')
|
||||
ELSE v_cur.resident_name
|
||||
END;
|
||||
v_siroc := CASE
|
||||
WHEN payload ? 'siroc' THEN upper(coalesce(nullif(btrim(payload->>'siroc'), ''), ''))
|
||||
ELSE v_cur.siroc
|
||||
END;
|
||||
v_payroll_tax_pct := coalesce((payload->>'payroll_tax_pct')::numeric(5,2), v_cur.payroll_tax_pct);
|
||||
IF v_company_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_update: company_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_project_update', 'field', 'company_id', 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_project_update: empresa id=%s no encontrada', v_company_id),
|
||||
jsonb_build_object('fn', 'fn_project_update', 'company_id', v_company_id, 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
IF v_start_date IS NOT NULL AND v_end_date IS NOT NULL AND v_end_date < v_start_date THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_update: la fecha de término no puede ser anterior al inicio',
|
||||
jsonb_build_object('fn', 'fn_project_update', 'field', 'end_date', 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
UPDATE projects SET
|
||||
name = v_name,
|
||||
address = v_address,
|
||||
theme_id = v_theme_id,
|
||||
status = v_status,
|
||||
company_id = v_company_id,
|
||||
contract_amount = v_contract_amount,
|
||||
start_date = v_start_date,
|
||||
end_date = v_end_date,
|
||||
resident_name = v_resident_name,
|
||||
siroc = v_siroc,
|
||||
payroll_tax_pct = v_payroll_tax_pct
|
||||
WHERE id = v_id;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('id', v_id, 'code', v_cur.code, 'status', v_status),
|
||||
format('Proyecto %s actualizado', v_cur.code),
|
||||
jsonb_build_object('fn', 'fn_project_update', 'id', v_id, 'code', v_cur.code)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_project_update', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009g-fn-project-document-list endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_project_document_list(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := (payload->>'project_id')::bigint;
|
||||
v_documents jsonb;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_document_list: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_project_document_list', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_project_document_list: proyecto id=%s no encontrado', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_project_document_list', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.uploaded_at DESC), '[]'::jsonb)
|
||||
INTO v_documents
|
||||
FROM (
|
||||
SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at
|
||||
FROM project_documents
|
||||
WHERE project_id = v_project_id
|
||||
) d;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('documents', v_documents),
|
||||
format('Documentos del proyecto %s: %s archivo(s)', v_project_id, jsonb_array_length(v_documents)),
|
||||
jsonb_build_object('fn', 'fn_project_document_list', 'project_id', v_project_id, 'count', jsonb_array_length(v_documents))
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_project_document_list', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009h-fn-company-document-list endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_company_document_list(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_company_id integer := (payload->>'company_id')::integer;
|
||||
v_documents jsonb;
|
||||
BEGIN
|
||||
IF v_company_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_company_document_list: company_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_company_document_list', 'field', 'company_id')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_company_document_list: empresa id=%s no encontrada', v_company_id),
|
||||
jsonb_build_object('fn', 'fn_company_document_list', 'company_id', v_company_id)
|
||||
);
|
||||
END IF;
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(d) ORDER BY d.uploaded_at DESC), '[]'::jsonb)
|
||||
INTO v_documents
|
||||
FROM (
|
||||
SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at
|
||||
FROM company_documents
|
||||
WHERE company_id = v_company_id
|
||||
) d;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('documents', v_documents),
|
||||
format('Documentos de la empresa %s: %s archivo(s)', v_company_id, jsonb_array_length(v_documents)),
|
||||
jsonb_build_object('fn', 'fn_company_document_list', 'company_id', v_company_id, 'count', jsonb_array_length(v_documents))
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_company_document_list', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009i-fn-project-checklist endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_project_checklist(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := (payload->>'project_id')::bigint;
|
||||
v_checklist jsonb;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_project_checklist: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_project_checklist', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_project_checklist: proyecto id=%s no encontrado', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_project_checklist', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'code', t.code,
|
||||
'label', t.label,
|
||||
'required', t.required,
|
||||
'category', coalesce(t.category, 'otro'),
|
||||
'present', EXISTS (
|
||||
SELECT 1 FROM project_documents d
|
||||
WHERE d.project_id = v_project_id
|
||||
AND d.type_code = t.code
|
||||
AND d.is_current = true
|
||||
)
|
||||
) ORDER BY t.code
|
||||
), '[]'::jsonb)
|
||||
INTO v_checklist
|
||||
FROM project_document_types t;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('checklist', v_checklist),
|
||||
format('Checklist documental del proyecto %s: %s tipo(s)', v_project_id, jsonb_array_length(v_checklist)),
|
||||
jsonb_build_object('fn', 'fn_project_checklist', 'project_id', v_project_id, 'count', jsonb_array_length(v_checklist))
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_project_checklist', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009j-fn-company-checklist endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_company_checklist(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_company_id integer := (payload->>'company_id')::integer;
|
||||
v_checklist jsonb;
|
||||
BEGIN
|
||||
IF v_company_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_company_checklist: company_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_company_checklist', 'field', 'company_id')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_company_checklist: empresa id=%s no encontrada', v_company_id),
|
||||
jsonb_build_object('fn', 'fn_company_checklist', 'company_id', v_company_id)
|
||||
);
|
||||
END IF;
|
||||
SELECT COALESCE(jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'code', t.code,
|
||||
'label', t.label,
|
||||
'required', t.required,
|
||||
'category', coalesce(t.category, 'otro'),
|
||||
'present', EXISTS (
|
||||
SELECT 1 FROM company_documents d
|
||||
WHERE d.company_id = v_company_id
|
||||
AND d.type_code = t.code
|
||||
AND d.is_current = true
|
||||
)
|
||||
) ORDER BY t.code
|
||||
), '[]'::jsonb)
|
||||
INTO v_checklist
|
||||
FROM company_document_types t;
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('checklist', v_checklist),
|
||||
format('Checklist documental de la empresa %s: %s tipo(s)', v_company_id, jsonb_array_length(v_checklist)),
|
||||
jsonb_build_object('fn', 'fn_company_checklist', 'company_id', v_company_id, 'count', jsonb_array_length(v_checklist))
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_company_checklist', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-009k-fn-projects-grants endDelimiter:; splitStatements:true
|
||||
GRANT EXECUTE ON FUNCTION core.fn_next_project_code(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_project_assert_status(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_project_list(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_project_get(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_project_create(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_project_update(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_project_document_list(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_company_document_list(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_project_checklist(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_company_checklist(jsonb) TO panels_core_app;
|
||||
1453
db/core/changesets/010-rpc-workers.sql
Normal file
1453
db/core/changesets/010-rpc-workers.sql
Normal file
File diff suppressed because it is too large
Load diff
697
db/core/changesets/011-rpc-budget.sql
Normal file
697
db/core/changesets/011-rpc-budget.sql
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · RPC presupuesto (capítulos, partidas, importación atómica)
|
||||
|
||||
--changeset panel:core-011a-fn-budget-list endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_budget_list(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_chapters jsonb;
|
||||
v_items jsonb;
|
||||
v_subtotal numeric(14,2);
|
||||
v_iva numeric(14,2);
|
||||
v_item_count integer;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_list: project_id es obligatorio para consultar el presupuesto',
|
||||
jsonb_build_object('fn', 'fn_budget_list', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_budget_list: proyecto id=%s no encontrado', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_list', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.sort_order, c.id), '[]'::jsonb)
|
||||
INTO v_chapters
|
||||
FROM (
|
||||
SELECT
|
||||
bc.id,
|
||||
bc.project_id,
|
||||
bc.parent_id,
|
||||
bc.code,
|
||||
bc.name,
|
||||
COALESCE(bc.wbs, '') AS wbs,
|
||||
bc.sort_order
|
||||
FROM budget_chapters bc
|
||||
WHERE bc.project_id = v_project_id
|
||||
) c;
|
||||
|
||||
SELECT
|
||||
COALESCE(jsonb_agg(to_jsonb(i) ORDER BY COALESCE(i.ch_sort, 9999), i.sort_order, i.id), '[]'::jsonb),
|
||||
COALESCE(SUM(i.amount), 0),
|
||||
COUNT(*)::integer
|
||||
INTO v_items, v_subtotal, v_item_count
|
||||
FROM (
|
||||
SELECT
|
||||
bi.*,
|
||||
ch.name AS chapter_name,
|
||||
ch.code AS chapter_code,
|
||||
ch.sort_order AS ch_sort
|
||||
FROM budget_items bi
|
||||
LEFT JOIN budget_chapters ch ON ch.id = bi.chapter_id
|
||||
WHERE bi.project_id = v_project_id
|
||||
) i;
|
||||
|
||||
v_subtotal := round(COALESCE(v_subtotal, 0), 2);
|
||||
v_iva := round(v_subtotal * 0.16, 2);
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'chapters', v_chapters,
|
||||
'items', v_items,
|
||||
'totals', jsonb_build_object(
|
||||
'subtotal', v_subtotal,
|
||||
'iva', v_iva,
|
||||
'total', round(v_subtotal + v_iva, 2),
|
||||
'item_count', v_item_count
|
||||
)
|
||||
),
|
||||
format('Presupuesto del proyecto %s: %s capítulo(s), %s partida(s), subtotal %s',
|
||||
v_project_id, jsonb_array_length(v_chapters), v_item_count, v_subtotal),
|
||||
jsonb_build_object(
|
||||
'fn', 'fn_budget_list',
|
||||
'project_id', v_project_id,
|
||||
'chapter_count', jsonb_array_length(v_chapters),
|
||||
'item_count', v_item_count
|
||||
)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_budget_list', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-011b-fn-budget-chapter-create endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_budget_chapter_create(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_parent_id bigint := NULLIF(payload->>'parent_id', '')::bigint;
|
||||
v_name text := nullif(btrim(payload->>'name'), '');
|
||||
v_code text := coalesce(nullif(btrim(payload->>'code'), ''), '');
|
||||
v_wbs text := coalesce(nullif(btrim(payload->>'wbs'), ''), '');
|
||||
v_sort integer;
|
||||
v_row budget_chapters%ROWTYPE;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_chapter_create: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_create', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF v_name IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_chapter_create: nombre de capítulo obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_create', 'field', 'name', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_budget_chapter_create: proyecto id=%s no encontrado', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
IF v_parent_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM budget_chapters WHERE id = v_parent_id AND project_id = v_project_id
|
||||
) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_chapter_create: capítulo padre id=%s no pertenece al proyecto %s', v_parent_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id, 'parent_id', v_parent_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(MAX(sort_order), 0) + 1
|
||||
INTO v_sort
|
||||
FROM budget_chapters
|
||||
WHERE project_id = v_project_id;
|
||||
|
||||
INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order)
|
||||
VALUES (v_project_id, v_parent_id, v_code, v_name, v_wbs, v_sort)
|
||||
RETURNING * INTO v_row;
|
||||
|
||||
RETURN core.rpc_created(
|
||||
jsonb_build_object('chapter', to_jsonb(v_row)),
|
||||
format('Capítulo "%s" creado en proyecto %s (id=%s)', v_row.name, v_project_id, v_row.id),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_create', 'project_id', v_project_id, 'id', v_row.id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_budget_chapter_create', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-011c-fn-budget-chapter-update endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_budget_chapter_update(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||
v_cur budget_chapters%ROWTYPE;
|
||||
v_name text;
|
||||
v_code text;
|
||||
v_wbs text;
|
||||
v_parent_id bigint;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_chapter_update: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_update', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_chapter_update: id del capítulo es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_update', 'field', 'id', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_cur FROM budget_chapters WHERE id = v_id AND project_id = v_project_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_budget_chapter_update: capítulo id=%s no encontrado en proyecto %s', v_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_name := coalesce(nullif(btrim(payload->>'name'), ''), v_cur.name);
|
||||
v_code := CASE WHEN payload ? 'code' THEN coalesce(nullif(btrim(payload->>'code'), ''), '') ELSE v_cur.code END;
|
||||
v_wbs := CASE WHEN payload ? 'wbs' THEN coalesce(nullif(btrim(payload->>'wbs'), ''), '') ELSE v_cur.wbs END;
|
||||
v_parent_id := CASE
|
||||
WHEN payload ? 'parent_id' THEN NULLIF(payload->>'parent_id', '')::bigint
|
||||
ELSE v_cur.parent_id
|
||||
END;
|
||||
|
||||
IF v_parent_id IS NOT NULL AND v_parent_id = v_id THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_chapter_update: un capítulo no puede ser padre de sí mismo (id=%s)', v_id),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
IF v_parent_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM budget_chapters WHERE id = v_parent_id AND project_id = v_project_id
|
||||
) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_chapter_update: capítulo padre id=%s no pertenece al proyecto %s', v_parent_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'parent_id', v_parent_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
UPDATE budget_chapters
|
||||
SET name = v_name,
|
||||
code = v_code,
|
||||
wbs = v_wbs,
|
||||
parent_id = v_parent_id
|
||||
WHERE id = v_id;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('id', v_id, 'name', v_name, 'code', v_code),
|
||||
format('Capítulo id=%s actualizado en proyecto %s', v_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_update', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_budget_chapter_update', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-011d-fn-budget-chapter-delete endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_budget_chapter_delete(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||
v_name text;
|
||||
v_orphans integer;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_chapter_delete: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_delete', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_chapter_delete: id del capítulo es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_delete', 'field', 'id', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT name INTO v_name FROM budget_chapters WHERE id = v_id AND project_id = v_project_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_budget_chapter_delete: capítulo id=%s no encontrado en proyecto %s', v_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_delete', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
UPDATE budget_items
|
||||
SET chapter_id = NULL
|
||||
WHERE project_id = v_project_id
|
||||
AND chapter_id = v_id;
|
||||
GET DIAGNOSTICS v_orphans = ROW_COUNT;
|
||||
|
||||
DELETE FROM budget_chapters WHERE id = v_id AND project_id = v_project_id;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('id', v_id, 'orphaned_items', v_orphans),
|
||||
format('Capítulo "%s" (id=%s) eliminado del proyecto %s; %s partida(s) quedaron sin capítulo',
|
||||
v_name, v_id, v_project_id, v_orphans),
|
||||
jsonb_build_object('fn', 'fn_budget_chapter_delete', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_budget_chapter_delete', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-011e-fn-budget-item-create endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_budget_item_create(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_chapter_id bigint := NULLIF(payload->>'chapter_id', '')::bigint;
|
||||
v_description text := nullif(btrim(payload->>'description'), '');
|
||||
v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), ''));
|
||||
v_unit text := upper(coalesce(nullif(btrim(payload->>'unit'), ''), ''));
|
||||
v_qty numeric(14,4) := coalesce((payload->>'quantity')::numeric(14,4), 0);
|
||||
v_price numeric(14,4) := coalesce((payload->>'unit_price')::numeric(14,4), 0);
|
||||
v_wbs text := coalesce(nullif(btrim(payload->>'wbs'), ''), '');
|
||||
v_amount numeric(14,2);
|
||||
v_sort integer;
|
||||
v_row budget_items%ROWTYPE;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_item_create: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_item_create', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF v_description IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_item_create: descripción de partida obligatoria',
|
||||
jsonb_build_object('fn', 'fn_budget_item_create', 'field', 'description', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_budget_item_create: proyecto id=%s no encontrado', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
IF v_chapter_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id
|
||||
) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_item_create: capítulo id=%s no pertenece al proyecto %s', v_chapter_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id, 'chapter_id', v_chapter_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_amount := round(v_qty * v_price, 2);
|
||||
SELECT COALESCE(MAX(sort_order), 0) + 1
|
||||
INTO v_sort
|
||||
FROM budget_items
|
||||
WHERE project_id = v_project_id;
|
||||
|
||||
INSERT INTO budget_items (
|
||||
project_id, chapter_id, code, description, unit, quantity, unit_price, amount, wbs, sort_order
|
||||
) VALUES (
|
||||
v_project_id, v_chapter_id, v_code, v_description, v_unit, v_qty, v_price, v_amount, v_wbs, v_sort
|
||||
)
|
||||
RETURNING * INTO v_row;
|
||||
|
||||
RETURN core.rpc_created(
|
||||
jsonb_build_object('item', to_jsonb(v_row)),
|
||||
format('Partida "%s" creada en proyecto %s (id=%s, importe=%s)',
|
||||
v_row.description, v_project_id, v_row.id, v_row.amount),
|
||||
jsonb_build_object('fn', 'fn_budget_item_create', 'project_id', v_project_id, 'id', v_row.id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_budget_item_create', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-011f-fn-budget-item-update endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_budget_item_update(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||
v_cur budget_items%ROWTYPE;
|
||||
v_chapter_id bigint;
|
||||
v_description text;
|
||||
v_code text;
|
||||
v_unit text;
|
||||
v_qty numeric(14,4);
|
||||
v_price numeric(14,4);
|
||||
v_wbs text;
|
||||
v_amount numeric(14,2);
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_item_update: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_item_update', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_item_update: id de partida es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_item_update', 'field', 'id', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_cur FROM budget_items WHERE id = v_id AND project_id = v_project_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_budget_item_update: partida id=%s no encontrada en proyecto %s', v_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_chapter_id := CASE
|
||||
WHEN payload ? 'chapter_id' THEN NULLIF(payload->>'chapter_id', '')::bigint
|
||||
ELSE v_cur.chapter_id
|
||||
END;
|
||||
v_description := coalesce(nullif(btrim(payload->>'description'), ''), v_cur.description);
|
||||
v_code := upper(CASE WHEN payload ? 'code' THEN coalesce(nullif(btrim(payload->>'code'), ''), '') ELSE v_cur.code END);
|
||||
v_unit := upper(CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE v_cur.unit END);
|
||||
v_qty := CASE WHEN payload ? 'quantity' THEN coalesce((payload->>'quantity')::numeric(14,4), 0) ELSE v_cur.quantity END;
|
||||
v_price := CASE WHEN payload ? 'unit_price' THEN coalesce((payload->>'unit_price')::numeric(14,4), 0) ELSE v_cur.unit_price END;
|
||||
v_wbs := CASE WHEN payload ? 'wbs' THEN coalesce(nullif(btrim(payload->>'wbs'), ''), '') ELSE v_cur.wbs END;
|
||||
|
||||
IF v_chapter_id IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id
|
||||
) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_item_update: capítulo id=%s no pertenece al proyecto %s', v_chapter_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'chapter_id', v_chapter_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_amount := round(v_qty * v_price, 2);
|
||||
|
||||
UPDATE budget_items
|
||||
SET chapter_id = v_chapter_id,
|
||||
code = v_code,
|
||||
description = v_description,
|
||||
unit = v_unit,
|
||||
quantity = v_qty,
|
||||
unit_price = v_price,
|
||||
amount = v_amount,
|
||||
wbs = v_wbs
|
||||
WHERE id = v_id;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('id', v_id, 'amount', v_amount),
|
||||
format('Partida id=%s actualizada en proyecto %s (importe=%s)', v_id, v_project_id, v_amount),
|
||||
jsonb_build_object('fn', 'fn_budget_item_update', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_budget_item_update', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-011g-fn-budget-item-delete endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_budget_item_delete(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_id bigint := NULLIF(payload->>'id', '')::bigint;
|
||||
v_description text;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_item_delete: project_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_item_delete', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF v_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_item_delete: id de partida es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_budget_item_delete', 'field', 'id', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
DELETE FROM budget_items
|
||||
WHERE id = v_id
|
||||
AND project_id = v_project_id
|
||||
RETURNING description INTO v_description;
|
||||
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_budget_item_delete: partida id=%s no encontrada en proyecto %s', v_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_item_delete', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('id', v_id),
|
||||
format('Partida "%s" (id=%s) eliminada del proyecto %s', v_description, v_id, v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_item_delete', 'project_id', v_project_id, 'id', v_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_budget_item_delete', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-011h-fn-budget-replace endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_budget_replace(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_chapters jsonb := COALESCE(payload->'chapters', '[]'::jsonb);
|
||||
v_items jsonb := COALESCE(payload->'items', '[]'::jsonb);
|
||||
v_ch jsonb;
|
||||
v_it jsonb;
|
||||
v_wbs text;
|
||||
v_parent_wbs text;
|
||||
v_chapter_wbs text;
|
||||
v_parent_id bigint;
|
||||
v_chapter_id bigint;
|
||||
v_new_id bigint;
|
||||
v_sort integer;
|
||||
v_item_sort integer;
|
||||
v_qty numeric(14,4);
|
||||
v_price numeric(14,4);
|
||||
v_amount numeric(14,2);
|
||||
v_chapter_count integer := 0;
|
||||
v_item_count integer := 0;
|
||||
v_wbs_map jsonb := '{}'::jsonb;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_replace: project_id es obligatorio para reemplazar el presupuesto',
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
IF jsonb_typeof(v_chapters) <> 'array' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_replace: chapters debe ser un arreglo JSON',
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'field', 'chapters', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
IF jsonb_typeof(v_items) <> 'array' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_budget_replace: items debe ser un arreglo JSON',
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'field', 'items', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_budget_replace: proyecto id=%s no encontrado', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
DELETE FROM budget_items WHERE project_id = v_project_id;
|
||||
DELETE FROM budget_chapters WHERE project_id = v_project_id;
|
||||
|
||||
v_sort := 0;
|
||||
FOR v_ch IN
|
||||
SELECT elem
|
||||
FROM jsonb_array_elements(v_chapters) AS elem
|
||||
ORDER BY coalesce(nullif(btrim(elem->>'wbs'), ''), elem->>'sort_order', elem->>'name')
|
||||
LOOP
|
||||
v_sort := v_sort + 1;
|
||||
v_wbs := coalesce(nullif(btrim(v_ch->>'wbs'), ''), v_sort::text);
|
||||
v_parent_wbs := nullif(btrim(v_ch->>'parent_wbs'), '');
|
||||
v_parent_id := NULL;
|
||||
IF v_parent_wbs IS NOT NULL THEN
|
||||
v_parent_id := NULLIF(v_wbs_map->>v_parent_wbs, '')::bigint;
|
||||
IF v_parent_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_replace: parent_wbs=%s no encontrado al insertar capítulo wbs=%s', v_parent_wbs, v_wbs),
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'wbs', v_wbs, 'parent_wbs', v_parent_wbs)
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF nullif(btrim(v_ch->>'name'), '') IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_replace: nombre de capítulo obligatorio (wbs=%s)', v_wbs),
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'wbs', v_wbs)
|
||||
);
|
||||
END IF;
|
||||
|
||||
INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order)
|
||||
VALUES (
|
||||
v_project_id,
|
||||
v_parent_id,
|
||||
coalesce(nullif(btrim(v_ch->>'code'), ''), ''),
|
||||
btrim(v_ch->>'name'),
|
||||
v_wbs,
|
||||
coalesce((v_ch->>'sort_order')::integer, v_sort)
|
||||
)
|
||||
RETURNING id INTO v_new_id;
|
||||
|
||||
v_wbs_map := v_wbs_map || jsonb_build_object(v_wbs, v_new_id::text);
|
||||
v_chapter_count := v_chapter_count + 1;
|
||||
END LOOP;
|
||||
|
||||
v_item_sort := 0;
|
||||
FOR v_it IN SELECT elem FROM jsonb_array_elements(v_items) AS elem
|
||||
LOOP
|
||||
v_item_sort := v_item_sort + 1;
|
||||
IF nullif(btrim(v_it->>'description'), '') IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_replace: descripción obligatoria en partida #%s', v_item_sort),
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'item_index', v_item_sort)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_chapter_wbs := nullif(btrim(v_it->>'chapter_wbs'), '');
|
||||
v_chapter_id := NULL;
|
||||
IF v_chapter_wbs IS NOT NULL THEN
|
||||
v_chapter_id := NULLIF(v_wbs_map->>v_chapter_wbs, '')::bigint;
|
||||
IF v_chapter_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_replace: chapter_wbs=%s no encontrado para partida #%s', v_chapter_wbs, v_item_sort),
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_wbs', v_chapter_wbs)
|
||||
);
|
||||
END IF;
|
||||
ELSIF v_it ? 'chapter_id' AND nullif(v_it->>'chapter_id', '') IS NOT NULL THEN
|
||||
v_chapter_id := (v_it->>'chapter_id')::bigint;
|
||||
IF NOT EXISTS (SELECT 1 FROM budget_chapters WHERE id = v_chapter_id AND project_id = v_project_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_budget_replace: chapter_id=%s inválido para partida #%s', v_chapter_id, v_item_sort),
|
||||
jsonb_build_object('fn', 'fn_budget_replace', 'project_id', v_project_id, 'chapter_id', v_chapter_id)
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
v_qty := coalesce((v_it->>'quantity')::numeric(14,4), 0);
|
||||
v_price := coalesce((v_it->>'unit_price')::numeric(14,4), 0);
|
||||
v_amount := round(coalesce((v_it->>'amount')::numeric(14,2), v_qty * v_price), 2);
|
||||
|
||||
INSERT INTO budget_items (
|
||||
project_id, chapter_id, code, description, unit, quantity, unit_price, amount, wbs, sort_order
|
||||
) VALUES (
|
||||
v_project_id,
|
||||
v_chapter_id,
|
||||
upper(coalesce(nullif(btrim(v_it->>'code'), ''), '')),
|
||||
btrim(v_it->>'description'),
|
||||
upper(coalesce(nullif(btrim(v_it->>'unit'), ''), '')),
|
||||
v_qty,
|
||||
v_price,
|
||||
v_amount,
|
||||
coalesce(nullif(btrim(v_it->>'wbs'), ''), ''),
|
||||
coalesce((v_it->>'sort_order')::integer, v_item_sort)
|
||||
);
|
||||
v_item_count := v_item_count + 1;
|
||||
END LOOP;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'project_id', v_project_id,
|
||||
'chapter_count', v_chapter_count,
|
||||
'item_count', v_item_count
|
||||
),
|
||||
format('Presupuesto del proyecto %s reemplazado: %s capítulo(s) y %s partida(s) importados',
|
||||
v_project_id, v_chapter_count, v_item_count),
|
||||
jsonb_build_object(
|
||||
'fn', 'fn_budget_replace',
|
||||
'project_id', v_project_id,
|
||||
'chapter_count', v_chapter_count,
|
||||
'item_count', v_item_count
|
||||
)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_budget_replace', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-011i-fn-budget-grants endDelimiter:; splitStatements:true
|
||||
GRANT EXECUTE ON FUNCTION core.fn_budget_list(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_create(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_update(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_budget_chapter_delete(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_budget_item_create(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_budget_item_update(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_budget_item_delete(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_budget_replace(jsonb) TO panels_core_app;
|
||||
333
db/core/changesets/012-rpc-badges.sql
Normal file
333
db/core/changesets/012-rpc-badges.sql
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · RPC gafetes (jobs de impresión y entrega)
|
||||
|
||||
--changeset panel:core-012a-fn-badge-job-create endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_badge_job_create(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer;
|
||||
v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
|
||||
v_worker_ids integer[];
|
||||
v_wid integer;
|
||||
v_job_id bigint;
|
||||
v_project record;
|
||||
v_missing integer;
|
||||
v_count integer := 0;
|
||||
BEGIN
|
||||
IF v_project_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_badge_job_create: project_id es obligatorio para generar gafetes',
|
||||
jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'project_id')
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT p.id, p.code, p.name, p.status
|
||||
INTO v_project
|
||||
FROM projects p
|
||||
WHERE p.id = v_project_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_badge_job_create: proyecto id=%s no encontrado', v_project_id),
|
||||
jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
IF v_project.status <> 'activo' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_badge_job_create: solo se generan gafetes de proyectos activos (proyecto %s está en %s)',
|
||||
v_project.code, v_project.status),
|
||||
jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id, 'status', v_project.status)
|
||||
);
|
||||
END IF;
|
||||
|
||||
IF payload->'worker_ids' IS NULL OR jsonb_typeof(payload->'worker_ids') <> 'array' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_badge_job_create: worker_ids debe ser un arreglo de enteros',
|
||||
jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'worker_ids', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT array_agg(DISTINCT (elem::text)::integer ORDER BY (elem::text)::integer)
|
||||
INTO v_worker_ids
|
||||
FROM jsonb_array_elements(payload->'worker_ids') AS elem
|
||||
WHERE nullif(btrim(elem::text), '') IS NOT NULL;
|
||||
|
||||
IF v_worker_ids IS NULL OR cardinality(v_worker_ids) = 0 THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_badge_job_create: debe indicar al menos un trabajador en worker_ids',
|
||||
jsonb_build_object('fn', 'fn_badge_job_create', 'field', 'worker_ids', 'project_id', v_project_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT COUNT(*)
|
||||
INTO v_missing
|
||||
FROM unnest(v_worker_ids) AS wid
|
||||
WHERE NOT EXISTS (SELECT 1 FROM workers w WHERE w.id = wid);
|
||||
IF v_missing > 0 THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_badge_job_create: %s trabajador(es) en worker_ids no existen en el catálogo', v_missing),
|
||||
jsonb_build_object('fn', 'fn_badge_job_create', 'project_id', v_project_id, 'missing_count', v_missing)
|
||||
);
|
||||
END IF;
|
||||
|
||||
INSERT INTO badge_jobs (project_id, status, created_by_id, created_by_name)
|
||||
VALUES (v_project_id, 'done', v_created_by_id, v_created_by_name)
|
||||
RETURNING id INTO v_job_id;
|
||||
|
||||
FOREACH v_wid IN ARRAY v_worker_ids
|
||||
LOOP
|
||||
INSERT INTO badge_job_people (job_id, worker_id)
|
||||
VALUES (v_job_id::integer, v_wid)
|
||||
ON CONFLICT (job_id, worker_id) DO NOTHING;
|
||||
v_count := v_count + 1;
|
||||
END LOOP;
|
||||
|
||||
RETURN core.rpc_created(
|
||||
jsonb_build_object(
|
||||
'id', v_job_id,
|
||||
'project_id', v_project_id,
|
||||
'count', v_count,
|
||||
'worker_ids', to_jsonb(v_worker_ids)
|
||||
),
|
||||
format('Job de gafetes #%s creado para proyecto %s (%s): %s persona(s)',
|
||||
v_job_id, v_project.code, v_project.name, v_count),
|
||||
jsonb_build_object(
|
||||
'fn', 'fn_badge_job_create',
|
||||
'job_id', v_job_id,
|
||||
'project_id', v_project_id,
|
||||
'count', v_count,
|
||||
'created_by_id', v_created_by_id
|
||||
)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_badge_job_create', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-012b-fn-badge-job-list endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_badge_job_list(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_jobs jsonb;
|
||||
v_count integer;
|
||||
BEGIN
|
||||
SELECT
|
||||
COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.id DESC), '[]'::jsonb),
|
||||
COUNT(*)::integer
|
||||
INTO v_jobs, v_count
|
||||
FROM (
|
||||
SELECT
|
||||
j.id,
|
||||
j.project_id,
|
||||
j.status,
|
||||
j.pdf_path,
|
||||
j.created_by_id,
|
||||
j.created_by_name,
|
||||
j.created_at,
|
||||
p.code AS project_code,
|
||||
p.name AS project_name,
|
||||
(SELECT COUNT(*) FROM badge_job_people bp WHERE bp.job_id = j.id) AS people,
|
||||
(SELECT COUNT(*) FROM badge_job_people bp WHERE bp.job_id = j.id AND bp.delivered = true) AS delivered
|
||||
FROM badge_jobs j
|
||||
JOIN projects p ON p.id = j.project_id
|
||||
WHERE v_project_id IS NULL OR j.project_id = v_project_id
|
||||
) x;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('jobs', v_jobs),
|
||||
format('Listado de jobs de gafetes: %s registro(s)%s',
|
||||
v_count,
|
||||
CASE WHEN v_project_id IS NOT NULL THEN format(' para proyecto %s', v_project_id) ELSE '' END),
|
||||
jsonb_build_object(
|
||||
'fn', 'fn_badge_job_list',
|
||||
'project_id', v_project_id,
|
||||
'count', v_count
|
||||
)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_badge_job_list', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-012c-fn-badge-job-get endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_badge_job_get(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_job_id bigint := COALESCE(
|
||||
NULLIF(payload->>'job_id', '')::bigint,
|
||||
NULLIF(payload->>'id', '')::bigint
|
||||
);
|
||||
v_job jsonb;
|
||||
v_people jsonb;
|
||||
BEGIN
|
||||
IF v_job_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_badge_job_get: job_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_badge_job_get', 'field', 'job_id')
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT to_jsonb(x)
|
||||
INTO v_job
|
||||
FROM (
|
||||
SELECT
|
||||
j.*,
|
||||
p.code AS project_code,
|
||||
p.name AS project_name
|
||||
FROM badge_jobs j
|
||||
JOIN projects p ON p.id = j.project_id
|
||||
WHERE j.id = v_job_id
|
||||
) x;
|
||||
|
||||
IF v_job IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_badge_job_get: job de gafetes id=%s no encontrado', v_job_id),
|
||||
jsonb_build_object('fn', 'fn_badge_job_get', 'job_id', v_job_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY w.last_name_p, w.first_name), '[]'::jsonb)
|
||||
INTO v_people
|
||||
FROM (
|
||||
SELECT
|
||||
bp.job_id,
|
||||
bp.worker_id,
|
||||
bp.delivered,
|
||||
bp.delivered_at,
|
||||
w.first_name,
|
||||
w.last_name_p,
|
||||
w.last_name_m,
|
||||
w.position
|
||||
FROM badge_job_people bp
|
||||
JOIN workers w ON w.id = bp.worker_id
|
||||
WHERE bp.job_id = v_job_id::integer
|
||||
) p;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object('job', v_job, 'people', v_people),
|
||||
format('Job de gafetes #%s cargado: %s persona(s) en el lote',
|
||||
v_job_id, jsonb_array_length(v_people)),
|
||||
jsonb_build_object(
|
||||
'fn', 'fn_badge_job_get',
|
||||
'job_id', v_job_id,
|
||||
'people_count', jsonb_array_length(v_people)
|
||||
)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_badge_job_get', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-012d-fn-badge-job-set-delivered endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_badge_job_set_delivered(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_job_id bigint := NULLIF(payload->>'job_id', '')::bigint;
|
||||
v_worker_id integer := NULLIF(payload->>'worker_id', '')::integer;
|
||||
v_delivered boolean;
|
||||
v_updated boolean;
|
||||
BEGIN
|
||||
IF v_job_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_badge_job_set_delivered: job_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'job_id')
|
||||
);
|
||||
END IF;
|
||||
IF v_worker_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_badge_job_set_delivered: worker_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'worker_id', 'job_id', v_job_id)
|
||||
);
|
||||
END IF;
|
||||
IF payload->>'delivered' IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_badge_job_set_delivered: delivered (boolean) es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'field', 'delivered', 'job_id', v_job_id, 'worker_id', v_worker_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_delivered := (payload->>'delivered')::boolean;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM badge_jobs WHERE id = v_job_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_badge_job_set_delivered: job de gafetes id=%s no encontrado', v_job_id),
|
||||
jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'job_id', v_job_id)
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_badge_job_set_delivered: trabajador id=%s no encontrado', v_worker_id),
|
||||
jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'worker_id', v_worker_id, 'job_id', v_job_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
UPDATE badge_job_people
|
||||
SET delivered = v_delivered,
|
||||
delivered_at = CASE WHEN v_delivered THEN now() ELSE NULL END
|
||||
WHERE job_id = v_job_id::integer
|
||||
AND worker_id = v_worker_id
|
||||
RETURNING true INTO v_updated;
|
||||
|
||||
IF NOT COALESCE(v_updated, false) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_badge_job_set_delivered: trabajador id=%s no pertenece al job %s', v_worker_id, v_job_id),
|
||||
jsonb_build_object('fn', 'fn_badge_job_set_delivered', 'job_id', v_job_id, 'worker_id', v_worker_id)
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'job_id', v_job_id,
|
||||
'worker_id', v_worker_id,
|
||||
'delivered', v_delivered
|
||||
),
|
||||
format('Entrega de gafete actualizada: trabajador %s en job #%s → %s',
|
||||
v_worker_id, v_job_id, CASE WHEN v_delivered THEN 'entregado' ELSE 'pendiente' END),
|
||||
jsonb_build_object(
|
||||
'fn', 'fn_badge_job_set_delivered',
|
||||
'job_id', v_job_id,
|
||||
'worker_id', v_worker_id,
|
||||
'delivered', v_delivered
|
||||
)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_badge_job_set_delivered', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-012e-fn-badge-grants endDelimiter:; splitStatements:true
|
||||
GRANT EXECUTE ON FUNCTION core.fn_badge_job_create(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_badge_job_list(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_badge_job_get(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_badge_job_set_delivered(jsonb) TO panels_core_app;
|
||||
2032
db/core/changesets/013-rpc-payroll.sql
Normal file
2032
db/core/changesets/013-rpc-payroll.sql
Normal file
File diff suppressed because it is too large
Load diff
566
db/core/changesets/014-rpc-import.sql
Normal file
566
db/core/changesets/014-rpc-import.sql
Normal file
|
|
@ -0,0 +1,566 @@
|
|||
--liquibase formatted sql
|
||||
-- PANELS · core · RPC importación de personal y metadatos documentales
|
||||
|
||||
--changeset panel:core-014a-import-helpers endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core._import_tenant_id(payload jsonb)
|
||||
RETURNS integer
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
SELECT COALESCE(
|
||||
NULLIF(payload->>'tenant_id', '')::integer,
|
||||
NULLIF(current_setting('app.tenant_id', true), '')::integer,
|
||||
0
|
||||
);
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._import_norm_upper(p_value text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT upper(regexp_replace(btrim(COALESCE(p_value, '')), '\s+', '', 'g'));
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._import_norm_name(p_value text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT regexp_replace(btrim(COALESCE(p_value, '')), '\s+', ' ', 'g');
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._import_format_nss(p_value text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT regexp_replace(COALESCE(p_value, ''), '\D', '', 'g');
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._import_canonical_risk(p_value text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
SELECT CASE lower(btrim(COALESCE(p_value, '')))
|
||||
WHEN 'alto' THEN 'rojo'
|
||||
WHEN 'medio' THEN 'amarillo'
|
||||
WHEN 'bajo' THEN 'verde'
|
||||
ELSE lower(btrim(COALESCE(p_value, '')))
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._import_validate_worker_row(p_row jsonb)
|
||||
RETURNS text[]
|
||||
LANGUAGE plpgsql
|
||||
IMMUTABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
v_errors text[] := ARRAY[]::text[];
|
||||
v_email text := lower(btrim(COALESCE(p_row->>'email', '')));
|
||||
v_wt text := upper(btrim(COALESCE(p_row->>'work_type', '')));
|
||||
v_nss text := core._import_format_nss(p_row->>'nss');
|
||||
BEGIN
|
||||
IF core._import_norm_name(p_row->>'first_name') = '' THEN
|
||||
v_errors := array_append(v_errors, 'Nombre obligatorio');
|
||||
END IF;
|
||||
IF core._import_norm_name(p_row->>'last_name_p') = '' THEN
|
||||
v_errors := array_append(v_errors, 'Apellido paterno obligatorio');
|
||||
END IF;
|
||||
IF core._import_norm_name(p_row->>'last_name_m') = '' THEN
|
||||
v_errors := array_append(v_errors, 'Apellido materno obligatorio');
|
||||
END IF;
|
||||
IF length(core._import_norm_upper(p_row->>'curp')) <> 18 THEN
|
||||
v_errors := array_append(v_errors, 'CURP inválida (18 caracteres, formato oficial)');
|
||||
END IF;
|
||||
IF length(core._import_norm_upper(p_row->>'rfc')) NOT IN (12, 13) THEN
|
||||
v_errors := array_append(v_errors, 'RFC inválido (12 o 13 caracteres, formato SAT)');
|
||||
END IF;
|
||||
IF length(v_nss) <> 11 THEN
|
||||
v_errors := array_append(v_errors, 'NSS inválido (11 dígitos)');
|
||||
END IF;
|
||||
IF core._import_norm_name(p_row->>'phone') = '' THEN
|
||||
v_errors := array_append(v_errors, 'Teléfono obligatorio');
|
||||
END IF;
|
||||
IF v_email = '' THEN
|
||||
v_errors := array_append(v_errors, 'Correo obligatorio');
|
||||
ELSIF v_email !~ '^[^\s@]+@[^\s@]+\.[^\s@]+$' THEN
|
||||
v_errors := array_append(v_errors, 'Correo inválido');
|
||||
END IF;
|
||||
IF core._import_norm_name(p_row->>'address') = '' THEN
|
||||
v_errors := array_append(v_errors, 'Dirección obligatoria');
|
||||
END IF;
|
||||
IF core._import_norm_name(p_row->>'position') = '' THEN
|
||||
v_errors := array_append(v_errors, 'Cargo obligatorio');
|
||||
END IF;
|
||||
IF core._import_canonical_risk(p_row->>'risk_code') = '' THEN
|
||||
v_errors := array_append(v_errors, 'Riesgo obligatorio');
|
||||
END IF;
|
||||
IF v_wt NOT IN ('N', 'D') THEN
|
||||
v_errors := array_append(v_errors, 'Tipo de trabajo N o D');
|
||||
END IF;
|
||||
IF p_row->>'daily_wage' IS NULL OR (p_row->>'daily_wage')::numeric IS NULL THEN
|
||||
v_errors := array_append(v_errors, 'Jornal obligatorio');
|
||||
END IF;
|
||||
RETURN v_errors;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._import_find_worker(p_curp text, p_rfc text, p_nss text)
|
||||
RETURNS TABLE(id bigint, curp text, rfc text, nss text)
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
SET search_path = core
|
||||
AS $$
|
||||
SELECT w.id, w.curp::text, w.rfc::text, w.nss
|
||||
FROM workers w
|
||||
WHERE w.curp = p_curp OR w.rfc = p_rfc OR w.nss = p_nss
|
||||
LIMIT 1;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._import_assign_worker(p_worker_id bigint, p_project_id bigint)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = core
|
||||
AS $$
|
||||
BEGIN
|
||||
IF p_project_id IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
INSERT INTO assignments (worker_id, project_id, active, start_date)
|
||||
VALUES (p_worker_id, p_project_id, true, current_date)
|
||||
ON CONFLICT (worker_id, project_id) DO UPDATE SET
|
||||
active = true,
|
||||
start_date = CASE
|
||||
WHEN assignments.active = false THEN excluded.start_date
|
||||
ELSE assignments.start_date
|
||||
END,
|
||||
end_date = NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION core._import_upsert_worker(
|
||||
p_row jsonb,
|
||||
p_company_id bigint,
|
||||
p_tenant_id integer
|
||||
)
|
||||
RETURNS TABLE(worker_id bigint, action text, matched text)
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_curp text := core._import_norm_upper(p_row->>'curp');
|
||||
v_rfc text := core._import_norm_upper(p_row->>'rfc');
|
||||
v_nss text := core._import_format_nss(p_row->>'nss');
|
||||
v_existing record;
|
||||
v_status text := CASE WHEN lower(btrim(COALESCE(p_row->>'status', 'activo'))) = 'baja' THEN 'baja' ELSE 'activo' END;
|
||||
v_needs_badge boolean := CASE
|
||||
WHEN lower(COALESCE(p_row->>'needs_badge', 'true')) IN ('false', '0', 'no') THEN false
|
||||
ELSE true
|
||||
END;
|
||||
v_id bigint;
|
||||
BEGIN
|
||||
SELECT * INTO v_existing FROM core._import_find_worker(v_curp, v_rfc, v_nss);
|
||||
IF FOUND THEN
|
||||
UPDATE workers SET
|
||||
first_name = initcap(lower(core._import_norm_name(p_row->>'first_name'))),
|
||||
middle_name = NULLIF(initcap(lower(core._import_norm_name(p_row->>'middle_name'))), ''),
|
||||
last_name_p = initcap(lower(core._import_norm_name(p_row->>'last_name_p'))),
|
||||
last_name_m = initcap(lower(core._import_norm_name(p_row->>'last_name_m'))),
|
||||
curp = v_curp,
|
||||
rfc = v_rfc,
|
||||
nss = v_nss,
|
||||
phone = core._import_norm_name(p_row->>'phone'),
|
||||
email = lower(btrim(COALESCE(p_row->>'email', ''))),
|
||||
address = core._import_norm_name(p_row->>'address'),
|
||||
blood_type = NULLIF(core._import_norm_upper(p_row->>'blood_type'), ''),
|
||||
hire_type = upper(btrim(COALESCE(p_row->>'hire_type', ''))),
|
||||
company_id = p_company_id,
|
||||
tenant_id = COALESCE(p_tenant_id, tenant_id),
|
||||
position = initcap(lower(core._import_norm_name(p_row->>'position'))),
|
||||
risk_code = core._import_canonical_risk(p_row->>'risk_code'),
|
||||
work_type = upper(btrim(COALESCE(p_row->>'work_type', ''))),
|
||||
daily_wage = COALESCE((p_row->>'daily_wage')::numeric, 0),
|
||||
needs_badge = v_needs_badge,
|
||||
status = v_status,
|
||||
updated_at = now()
|
||||
WHERE id = v_existing.id;
|
||||
worker_id := v_existing.id;
|
||||
action := 'existed';
|
||||
matched := CASE
|
||||
WHEN v_existing.curp = v_curp THEN 'CURP'
|
||||
WHEN v_existing.rfc = v_rfc THEN 'RFC'
|
||||
ELSE 'NSS'
|
||||
END;
|
||||
RETURN NEXT;
|
||||
RETURN;
|
||||
END IF;
|
||||
INSERT INTO workers (
|
||||
first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss,
|
||||
phone, email, address, blood_type, hire_type, company_id, position,
|
||||
risk_code, work_type, daily_wage, needs_badge, status, tenant_id
|
||||
) VALUES (
|
||||
initcap(lower(core._import_norm_name(p_row->>'first_name'))),
|
||||
NULLIF(initcap(lower(core._import_norm_name(p_row->>'middle_name'))), ''),
|
||||
initcap(lower(core._import_norm_name(p_row->>'last_name_p'))),
|
||||
initcap(lower(core._import_norm_name(p_row->>'last_name_m'))),
|
||||
v_curp, v_rfc, v_nss,
|
||||
core._import_norm_name(p_row->>'phone'),
|
||||
lower(btrim(COALESCE(p_row->>'email', ''))),
|
||||
core._import_norm_name(p_row->>'address'),
|
||||
NULLIF(core._import_norm_upper(p_row->>'blood_type'), ''),
|
||||
upper(btrim(COALESCE(p_row->>'hire_type', ''))),
|
||||
p_company_id,
|
||||
initcap(lower(core._import_norm_name(p_row->>'position'))),
|
||||
core._import_canonical_risk(p_row->>'risk_code'),
|
||||
upper(btrim(COALESCE(p_row->>'work_type', ''))),
|
||||
COALESCE((p_row->>'daily_wage')::numeric, 0),
|
||||
v_needs_badge,
|
||||
v_status,
|
||||
p_tenant_id
|
||||
)
|
||||
RETURNING id INTO v_id;
|
||||
worker_id := v_id;
|
||||
action := 'inserted';
|
||||
matched := NULL;
|
||||
RETURN NEXT;
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-014b-fn-workers-import-batch endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_workers_import_batch(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_tid integer := core._import_tenant_id(payload);
|
||||
v_default_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
||||
v_rows jsonb := COALESCE(payload->'rows', '[]'::jsonb);
|
||||
v_row jsonb;
|
||||
v_idx integer := 0;
|
||||
v_sheet text;
|
||||
v_row_no integer;
|
||||
v_nombre text;
|
||||
v_errors text[];
|
||||
v_company record;
|
||||
v_company_code text;
|
||||
v_company_id bigint;
|
||||
v_project_id bigint;
|
||||
v_status text;
|
||||
v_curp text;
|
||||
v_rfc text;
|
||||
v_nss text;
|
||||
v_seen_curp jsonb := '{}'::jsonb;
|
||||
v_seen_rfc jsonb := '{}'::jsonb;
|
||||
v_seen_nss jsonb := '{}'::jsonb;
|
||||
v_dup_row integer;
|
||||
v_upsert record;
|
||||
v_inserted integer := 0;
|
||||
v_existed integer := 0;
|
||||
v_error_count integer := 0;
|
||||
v_error_rows jsonb := '[]'::jsonb;
|
||||
v_existed_rows jsonb := '[]'::jsonb;
|
||||
BEGIN
|
||||
IF jsonb_typeof(v_rows) <> 'array' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_workers_import_batch: rows debe ser un arreglo JSON',
|
||||
jsonb_build_object('fn', 'fn_workers_import_batch', 'field', 'rows')
|
||||
);
|
||||
END IF;
|
||||
IF jsonb_array_length(v_rows) = 0 THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_workers_import_batch: el arreglo rows está vacío',
|
||||
jsonb_build_object('fn', 'fn_workers_import_batch', 'field', 'rows')
|
||||
);
|
||||
END IF;
|
||||
|
||||
FOR v_row IN SELECT value FROM jsonb_array_elements(v_rows) LOOP
|
||||
v_idx := v_idx + 1;
|
||||
v_sheet := COALESCE(NULLIF(btrim(v_row->>'sheet'), ''), 'IMPORT');
|
||||
v_row_no := COALESCE((v_row->>'row')::integer, v_idx);
|
||||
v_nombre := trim(concat_ws(' ', v_row->>'first_name', v_row->>'last_name_p', v_row->>'last_name_m'));
|
||||
v_errors := core._import_validate_worker_row(v_row);
|
||||
|
||||
v_company_code := upper(btrim(COALESCE(v_row->>'hire_type', v_row->>'company_code', '')));
|
||||
v_company_id := NULLIF(v_row->>'company_id', '')::bigint;
|
||||
IF v_company_id IS NOT NULL THEN
|
||||
SELECT id, code, tenant_id INTO v_company FROM companies WHERE id = v_company_id;
|
||||
ELSIF v_company_code <> '' THEN
|
||||
SELECT id, code, tenant_id INTO v_company FROM companies WHERE upper(code) = v_company_code;
|
||||
ELSE
|
||||
v_company := NULL;
|
||||
END IF;
|
||||
IF v_company.id IS NULL THEN
|
||||
v_errors := array_append(
|
||||
v_errors,
|
||||
format('Empresa no encontrada (%s)', COALESCE(NULLIF(v_company_code, ''), '—'))
|
||||
);
|
||||
ELSE
|
||||
v_company_id := v_company.id;
|
||||
IF v_tid = 0 AND v_company.tenant_id IS NOT NULL THEN
|
||||
v_tid := v_company.tenant_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM risk_levels WHERE code = core._import_canonical_risk(v_row->>'risk_code')
|
||||
) THEN
|
||||
v_errors := array_append(
|
||||
v_errors,
|
||||
format('Riesgo inválido (%s). Use alto, medio o bajo', COALESCE(v_row->>'risk_code', '—'))
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_curp := core._import_norm_upper(v_row->>'curp');
|
||||
v_rfc := core._import_norm_upper(v_row->>'rfc');
|
||||
v_nss := core._import_format_nss(v_row->>'nss');
|
||||
|
||||
IF cardinality(v_errors) = 0 THEN
|
||||
IF v_seen_curp ? v_curp THEN
|
||||
v_dup_row := (v_seen_curp->>v_curp)::integer;
|
||||
v_errors := array_append(v_errors, format('CURP duplicada en el lote (fila %s)', v_dup_row));
|
||||
END IF;
|
||||
IF v_seen_rfc ? v_rfc THEN
|
||||
v_dup_row := (v_seen_rfc->>v_rfc)::integer;
|
||||
v_errors := array_append(v_errors, format('RFC duplicado en el lote (fila %s)', v_dup_row));
|
||||
END IF;
|
||||
IF v_seen_nss ? v_nss THEN
|
||||
v_dup_row := (v_seen_nss->>v_nss)::integer;
|
||||
v_errors := array_append(v_errors, format('NSS duplicado en el lote (fila %s)', v_dup_row));
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF cardinality(v_errors) > 0 THEN
|
||||
v_error_count := v_error_count + 1;
|
||||
v_error_rows := v_error_rows || jsonb_build_array(jsonb_build_object(
|
||||
'sheet', v_sheet,
|
||||
'row', v_row_no,
|
||||
'nombre', v_nombre,
|
||||
'curp', COALESCE(v_row->>'curp', ''),
|
||||
'messages', to_jsonb(v_errors)
|
||||
));
|
||||
CONTINUE;
|
||||
END IF;
|
||||
|
||||
v_seen_curp := v_seen_curp || jsonb_build_object(v_curp, v_row_no);
|
||||
v_seen_rfc := v_seen_rfc || jsonb_build_object(v_rfc, v_row_no);
|
||||
v_seen_nss := v_seen_nss || jsonb_build_object(v_nss, v_row_no);
|
||||
|
||||
v_status := CASE WHEN lower(btrim(COALESCE(v_row->>'status', 'activo'))) = 'baja' THEN 'baja' ELSE 'activo' END;
|
||||
v_project_id := COALESCE(NULLIF(v_row->>'project_id', '')::bigint, v_default_project_id);
|
||||
IF v_status <> 'activo' THEN
|
||||
v_project_id := NULL;
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_upsert
|
||||
FROM core._import_upsert_worker(
|
||||
v_row || jsonb_build_object('hire_type', v_company.code),
|
||||
v_company_id,
|
||||
COALESCE(v_tid, v_company.tenant_id, 1)
|
||||
);
|
||||
|
||||
IF v_project_id IS NOT NULL THEN
|
||||
PERFORM core._import_assign_worker(v_upsert.worker_id, v_project_id);
|
||||
END IF;
|
||||
|
||||
IF v_status = 'baja' THEN
|
||||
UPDATE workers SET pipeline_status = 'baja' WHERE id = v_upsert.worker_id;
|
||||
END IF;
|
||||
|
||||
IF v_upsert.action = 'inserted' THEN
|
||||
v_inserted := v_inserted + 1;
|
||||
ELSE
|
||||
v_existed := v_existed + 1;
|
||||
v_existed_rows := v_existed_rows || jsonb_build_array(jsonb_build_object(
|
||||
'sheet', v_sheet,
|
||||
'row', v_row_no,
|
||||
'nombre', v_nombre,
|
||||
'curp', v_curp,
|
||||
'matched', COALESCE(v_upsert.matched, 'CURP')
|
||||
));
|
||||
END IF;
|
||||
END LOOP;
|
||||
|
||||
RETURN core.rpc_ok(
|
||||
jsonb_build_object(
|
||||
'inserted', v_inserted,
|
||||
'existed', v_existed,
|
||||
'errors', v_error_count,
|
||||
'existed_rows', v_existed_rows,
|
||||
'error_rows', v_error_rows
|
||||
),
|
||||
format('Importación por lote: %s nuevo(s), %s existente(s), %s error(es)',
|
||||
v_inserted, v_existed, v_error_count),
|
||||
jsonb_build_object(
|
||||
'fn', 'fn_workers_import_batch',
|
||||
'tenant_id', v_tid,
|
||||
'inserted', v_inserted,
|
||||
'existed', v_existed,
|
||||
'errors', v_error_count
|
||||
)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_workers_import_batch', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-014c-fn-worker-document-store endDelimiter:; splitStatements:true
|
||||
CREATE OR REPLACE FUNCTION core.fn_worker_document_store(payload jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
SET search_path = core
|
||||
AS $$
|
||||
DECLARE
|
||||
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
|
||||
v_type_code text := btrim(COALESCE(payload->>'type_code', payload->>'type', ''));
|
||||
v_original_name text := btrim(COALESCE(payload->>'original_name', payload->>'filename', ''));
|
||||
v_mime text := btrim(COALESCE(payload->>'mime', ''));
|
||||
v_size_bytes bigint := NULLIF(payload->>'size_bytes', '')::bigint;
|
||||
v_sha256 text := btrim(COALESCE(payload->>'sha256', ''));
|
||||
v_iv text := btrim(COALESCE(payload->>'iv', ''));
|
||||
v_storage_name text := btrim(COALESCE(payload->>'storage_name', ''));
|
||||
v_uploaded_by_id integer := NULLIF(payload->>'uploaded_by_id', '')::integer;
|
||||
v_uploaded_by_name text := COALESCE(NULLIF(btrim(payload->>'uploaded_by_name'), ''), '');
|
||||
v_issued_at date := NULLIF(btrim(payload->>'issued_at'), '')::date;
|
||||
v_expires_at date := NULLIF(btrim(payload->>'expires_at'), '')::date;
|
||||
v_imss_company_id integer := NULLIF(payload->>'imss_company_id', '')::integer;
|
||||
v_imss_alta_at date := NULLIF(btrim(payload->>'imss_alta_at'), '')::date;
|
||||
v_imss_baja_at date := NULLIF(btrim(payload->>'imss_baja_at'), '')::date;
|
||||
v_today date := current_date;
|
||||
v_movement_date date;
|
||||
v_doc_id bigint;
|
||||
v_company record;
|
||||
BEGIN
|
||||
IF v_worker_id IS NULL THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_worker_document_store: worker_id es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'worker_id')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM workers WHERE id = v_worker_id) THEN
|
||||
RETURN core.rpc_err(
|
||||
'NOT_FOUND',
|
||||
format('fn_worker_document_store: trabajador id=%s no encontrado', v_worker_id),
|
||||
jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id)
|
||||
);
|
||||
END IF;
|
||||
IF v_type_code = '' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_worker_document_store: type_code es obligatorio',
|
||||
jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'type_code')
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM document_types WHERE code = v_type_code) THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
format('fn_worker_document_store: tipo de documento no válido (%s)', v_type_code),
|
||||
jsonb_build_object('fn', 'fn_worker_document_store', 'type_code', v_type_code)
|
||||
);
|
||||
END IF;
|
||||
IF v_original_name = '' OR v_mime = '' OR v_sha256 = '' OR v_iv = '' OR v_storage_name = '' THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_worker_document_store: original_name, mime, sha256, iv y storage_name son obligatorios tras la carga a almacenamiento',
|
||||
jsonb_build_object('fn', 'fn_worker_document_store')
|
||||
);
|
||||
END IF;
|
||||
IF v_size_bytes IS NULL OR v_size_bytes < 0 THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_worker_document_store: size_bytes debe ser un entero no negativo',
|
||||
jsonb_build_object('fn', 'fn_worker_document_store', 'field', 'size_bytes')
|
||||
);
|
||||
END IF;
|
||||
|
||||
v_imss_alta_at := COALESCE(v_imss_alta_at, CASE WHEN v_type_code = 'alta_imss' THEN v_today END);
|
||||
v_imss_baja_at := COALESCE(v_imss_baja_at, CASE WHEN v_type_code = 'baja_imss' THEN v_today END);
|
||||
v_movement_date := CASE WHEN v_type_code = 'baja_imss' THEN v_imss_baja_at ELSE v_imss_alta_at END;
|
||||
|
||||
UPDATE documents
|
||||
SET is_current = false
|
||||
WHERE worker_id = v_worker_id
|
||||
AND type_code = v_type_code
|
||||
AND is_current = true;
|
||||
|
||||
INSERT INTO documents (
|
||||
worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name,
|
||||
is_current, parse_status, issued_at, expires_at, imss_company_id, imss_alta_at,
|
||||
uploaded_by_id, uploaded_by_name
|
||||
) VALUES (
|
||||
v_worker_id, v_type_code, v_original_name, v_mime, v_size_bytes, v_sha256, v_iv, v_storage_name,
|
||||
true, 'manual',
|
||||
COALESCE(v_issued_at, v_movement_date),
|
||||
v_expires_at,
|
||||
v_imss_company_id,
|
||||
v_movement_date,
|
||||
v_uploaded_by_id,
|
||||
v_uploaded_by_name
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
IF v_type_code = 'alta_imss' THEN
|
||||
v_imss_company_id := COALESCE(
|
||||
v_imss_company_id,
|
||||
(SELECT company_id FROM workers WHERE id = v_worker_id)
|
||||
);
|
||||
SELECT id, code INTO v_company FROM companies WHERE id = v_imss_company_id;
|
||||
IF NOT FOUND THEN
|
||||
RETURN core.rpc_err(
|
||||
'VALIDATION',
|
||||
'fn_worker_document_store: empresa patrón no válida para alta IMSS',
|
||||
jsonb_build_object('fn', 'fn_worker_document_store', 'worker_id', v_worker_id)
|
||||
);
|
||||
END IF;
|
||||
UPDATE workers SET
|
||||
imss_status = 'alta',
|
||||
imss_company_id = v_company.id,
|
||||
imss_alta_at = v_imss_alta_at,
|
||||
imss_baja_at = NULL,
|
||||
company_id = v_company.id,
|
||||
hire_type = v_company.code,
|
||||
updated_at = now()
|
||||
WHERE id = v_worker_id;
|
||||
END IF;
|
||||
|
||||
IF v_type_code = 'baja_imss' THEN
|
||||
UPDATE workers SET
|
||||
imss_status = 'baja_imss',
|
||||
imss_baja_at = v_imss_baja_at,
|
||||
imss_company_id = NULL,
|
||||
company_id = NULL,
|
||||
hire_type = '',
|
||||
updated_at = now()
|
||||
WHERE id = v_worker_id;
|
||||
END IF;
|
||||
|
||||
IF (SELECT status FROM workers WHERE id = v_worker_id) = 'baja' THEN
|
||||
UPDATE workers SET pipeline_status = 'baja' WHERE id = v_worker_id;
|
||||
END IF;
|
||||
|
||||
RETURN core.rpc_created(
|
||||
jsonb_build_object(
|
||||
'id', v_doc_id,
|
||||
'worker_id', v_worker_id,
|
||||
'type_code', v_type_code,
|
||||
'is_current', true,
|
||||
'storage_name', v_storage_name
|
||||
),
|
||||
format('Metadatos del documento %s guardados para trabajador %s (versión actual)', v_type_code, v_worker_id),
|
||||
jsonb_build_object('fn', 'fn_worker_document_store', 'id', v_doc_id, 'worker_id', v_worker_id)
|
||||
);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RETURN core.rpc_from_exception('fn_worker_document_store', SQLSTATE, SQLERRM);
|
||||
END;
|
||||
$$;
|
||||
|
||||
--changeset panel:core-014d-fn-import-grants endDelimiter:; splitStatements:true
|
||||
GRANT EXECUTE ON FUNCTION core.fn_workers_import_batch(jsonb) TO panels_core_app;
|
||||
GRANT EXECUTE ON FUNCTION core.fn_worker_document_store(jsonb) TO panels_core_app;
|
||||
164
db/core/changesets/015-rpc-documents.sql
Normal file
164
db/core/changesets/015-rpc-documents.sql
Normal 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;
|
||||
59
web-panel/composables/api-response.ts
Normal file
59
web-panel/composables/api-response.ts
Normal 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;
|
||||
}
|
||||
58
web-saas/composables/api-response.ts
Normal file
58
web-saas/composables/api-response.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue