mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:43:17 +00:00
refactor(core): finish RPC migration for budget, excel import, and docs
- Replace remaining budget.ts prepare() with fn_budget_replace and fn_project_update - Migrate excel.ts findExisting, document store, assign, and catalogs to RPC - Add 015-rpc-documents.sql for project/company/worker document metadata - Document RPC envelope conventions in db/README.md - Add http_errors_test and front api-response composables Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
parent
059ecf69f8
commit
b58791aea4
11 changed files with 643 additions and 1315 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
import type { Db } from "./db.ts";
|
import type { Db } from "./db.ts";
|
||||||
|
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||||
|
|
||||||
export const BUDGET_IVA = 0.16;
|
export const BUDGET_IVA = 0.16;
|
||||||
|
|
||||||
|
|
@ -642,17 +643,14 @@ function compareWbs(a: string, b: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listBudget(database: Db, projectId: number) {
|
export async function listBudget(database: Db, projectId: number) {
|
||||||
const chapters = await database.prepare(
|
const env = await callCoreFn<{
|
||||||
`SELECT c.id, c.project_id, c.parent_id, c.code, c.name, COALESCE(c.wbs, '') AS wbs, c.sort_order
|
chapters: ChapterRow[];
|
||||||
FROM budget_chapters c WHERE c.project_id = ? ORDER BY c.sort_order, c.id`,
|
items: (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
|
||||||
).all(projectId) as ChapterRow[];
|
totals: { subtotal: number; iva: number; total: number; item_count: number };
|
||||||
const rawItems = await database.prepare(
|
}>(database, "core.fn_budget_list", { project_id: projectId });
|
||||||
`SELECT i.*, ch.name AS chapter_name, ch.code AS chapter_code
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
FROM budget_items i
|
const chapters = env.data?.chapters ?? [];
|
||||||
LEFT JOIN budget_chapters ch ON ch.id = i.chapter_id
|
const rawItems = env.data?.items ?? [];
|
||||||
WHERE i.project_id = ?
|
|
||||||
ORDER BY COALESCE(ch.sort_order, 9999), i.sort_order, i.id`,
|
|
||||||
).all(projectId) as (BudgetItemRow & { chapter_name: string | null; chapter_code: string | null })[];
|
|
||||||
|
|
||||||
const byParent = new Map<number | null, ChapterRow[]>();
|
const byParent = new Map<number | null, ChapterRow[]>();
|
||||||
const byId = new Map<number, ChapterRow>();
|
const byId = new Map<number, ChapterRow>();
|
||||||
|
|
@ -720,7 +718,7 @@ export async function listBudget(database: Db, projectId: number) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const subtotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
const subtotal = items.reduce((sum, item) => sum + Number(item.amount || 0), 0);
|
||||||
const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
const iva = env.data?.totals?.iva ?? Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
||||||
const chapterView = chapters.map((chapter) => {
|
const chapterView = chapters.map((chapter) => {
|
||||||
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
|
const stats = amounts.get(chapter.id) || { amount: 0, item_count: 0 };
|
||||||
const chain = ancestorsOf(chapter.id);
|
const chain = ancestorsOf(chapter.id);
|
||||||
|
|
@ -744,44 +742,34 @@ export async function listBudget(database: Db, projectId: number) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function insertChapter(
|
|
||||||
database: Db,
|
|
||||||
projectId: number,
|
|
||||||
parentId: number | null,
|
|
||||||
code: string,
|
|
||||||
name: string,
|
|
||||||
wbs: string,
|
|
||||||
sort: number,
|
|
||||||
): Promise<number> {
|
|
||||||
await database.prepare(
|
|
||||||
"INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
).run(projectId, parentId, code, name, wbs, sort);
|
|
||||||
return await database.lastInsertId();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise<void> {
|
export async function replaceBudgetFromParsed(database: Db, projectId: number, parsed: ParsedBudget): Promise<void> {
|
||||||
await database.prepare("DELETE FROM budget_items WHERE project_id = ?").run(projectId);
|
|
||||||
await database.prepare("DELETE FROM budget_chapters WHERE project_id = ?").run(projectId);
|
|
||||||
const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs));
|
const groups = [...parsed.groups].sort((a, b) => compareWbs(a.wbs, b.wbs));
|
||||||
const ids = new Map<string, number>();
|
|
||||||
let sort = 1;
|
let sort = 1;
|
||||||
for (const group of groups) {
|
const chapters = groups.map((group) => ({
|
||||||
const parentId = group.parentWbs ? ids.get(group.parentWbs) || null : null;
|
wbs: group.wbs,
|
||||||
const id = await insertChapter(database, projectId, parentId, group.code, group.name, group.wbs, sort);
|
parent_wbs: group.parentWbs || null,
|
||||||
ids.set(group.wbs, id);
|
code: group.code,
|
||||||
sort++;
|
name: group.name,
|
||||||
}
|
sort_order: sort++,
|
||||||
|
}));
|
||||||
let itemSort = 1;
|
let itemSort = 1;
|
||||||
for (const item of parsed.items) {
|
const items = parsed.items.map((item) => ({
|
||||||
const chapterId = item.parentWbs ? ids.get(item.parentWbs) || null : null;
|
chapter_wbs: item.parentWbs || null,
|
||||||
const amount = Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100;
|
code: item.code,
|
||||||
await database.prepare(
|
description: item.description,
|
||||||
`INSERT INTO budget_items
|
unit: item.unit,
|
||||||
(project_id, chapter_id, code, description, unit, quantity, unit_price, amount, sort_order, wbs)
|
quantity: item.quantity,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
unit_price: item.unit_price,
|
||||||
).run(projectId, chapterId, item.code, item.description, item.unit, item.quantity, item.unit_price, amount, itemSort, item.wbs);
|
amount: Math.round((item.quantity * item.unit_price || item.amount) * 100) / 100,
|
||||||
itemSort++;
|
wbs: item.wbs,
|
||||||
}
|
sort_order: itemSort++,
|
||||||
|
}));
|
||||||
|
const env = await callCoreFn(database, "core.fn_budget_replace", {
|
||||||
|
project_id: projectId,
|
||||||
|
chapters,
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
|
export async function replaceBudgetFromItems(database: Db, projectId: number, parsed: ParsedBudgetItem[]): Promise<void> {
|
||||||
|
|
@ -927,7 +915,11 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes:
|
||||||
item_count: parsed.items.length,
|
item_count: parsed.items.length,
|
||||||
};
|
};
|
||||||
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado.
|
// El monto de contrato del proyecto refleja el subtotal del presupuesto importado.
|
||||||
await database.prepare("UPDATE projects SET contract_amount = ? WHERE id = ?").run(subtotal, projectId);
|
const projectEnv = await callCoreFn(database, "core.fn_project_update", {
|
||||||
|
id: projectId,
|
||||||
|
contract_amount: subtotal,
|
||||||
|
});
|
||||||
|
if (!projectEnv.ok) throw new RpcCallError(projectEnv);
|
||||||
return {
|
return {
|
||||||
replaced: true,
|
replaced: true,
|
||||||
chapters: parsed.groups.length,
|
chapters: parsed.groups.length,
|
||||||
|
|
|
||||||
232
api/companies.ts
232
api/companies.ts
|
|
@ -1,4 +1,7 @@
|
||||||
import type { Db } from "./db.ts";
|
import type { Db } from "./db.ts";
|
||||||
|
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||||
|
import type { RpcEnvelope } from "./http_errors.ts";
|
||||||
|
import { mapRpcToStatus } from "./http_errors.ts";
|
||||||
|
|
||||||
export type Company = {
|
export type Company = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -60,43 +63,35 @@ function normRfc(value: unknown): string {
|
||||||
return trimText(value).toUpperCase().replace(/\s+/g, "");
|
return trimText(value).toUpperCase().replace(/\s+/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function envelopeError<T>(env: RpcEnvelope<T>): { error: string; status: number } {
|
||||||
|
return { error: env.message, status: mapRpcToStatus(String(env.code)) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function listCompanies(database: Db, tenantId?: number | null): Promise<Company[]> {
|
export async function listCompanies(database: Db, tenantId?: number | null): Promise<Company[]> {
|
||||||
// El filtro tenant_id aquí es defensa adicional/legibilidad -- el
|
const env = await callCoreFn<{ companies: Company[] }>(database, "core.fn_company_list", {
|
||||||
// aislamiento real ya lo garantiza Row Level Security sobre la conexión
|
tenant_id: tenantId ?? null,
|
||||||
// acotada por withCoreScope (ver db/core/changesets/005-rls.sql).
|
});
|
||||||
const where = tenantId != null ? "WHERE c.tenant_id = ?" : "";
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
const params = tenantId != null ? [tenantId] : [];
|
return (env.data?.companies ?? []) as Company[];
|
||||||
return await database.prepare(
|
|
||||||
`SELECT c.*, p.code AS parent_code, p.name AS parent_name,
|
|
||||||
(SELECT COUNT(*) FROM workers w WHERE w.company_id = c.id) AS worker_count
|
|
||||||
FROM companies c
|
|
||||||
LEFT JOIN companies p ON p.id = c.parent_id
|
|
||||||
${where}
|
|
||||||
ORDER BY CASE c.kind WHEN 'principal' THEN 0 ELSE 1 END, LOWER(c.name)`,
|
|
||||||
).all(...params) as Company[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function companyById(database: Db, id: number): Promise<Company | undefined> {
|
export async function companyById(database: Db, id: number): Promise<Company | undefined> {
|
||||||
return await database.prepare("SELECT * FROM companies WHERE id = ?").get(id) as
|
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get", { id });
|
||||||
| Company
|
if (!env.ok) return undefined;
|
||||||
| undefined;
|
return env.data?.company as Company | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function companyByCode(database: Db, code: string): Promise<Company | undefined> {
|
export async function companyByCode(database: Db, code: string): Promise<Company | undefined> {
|
||||||
return await database.prepare("SELECT * FROM companies WHERE code = ?").get(
|
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get_by_code", {
|
||||||
normCompanyCode(code),
|
code: normCompanyCode(code),
|
||||||
) as Company | undefined;
|
});
|
||||||
|
if (!env.ok) return undefined;
|
||||||
|
return env.data?.company as Company | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function principalCompany(database: Db, tenantId?: number | null): Promise<Company | undefined> {
|
export async function principalCompany(database: Db, tenantId?: number | null): Promise<Company | undefined> {
|
||||||
if (tenantId != null) {
|
const companies = await listCompanies(database, tenantId);
|
||||||
return await database.prepare(
|
return companies.find((c) => c.kind === "principal");
|
||||||
"SELECT * FROM companies WHERE kind = 'principal' AND tenant_id = ? ORDER BY id LIMIT 1",
|
|
||||||
).get(tenantId) as Company | undefined;
|
|
||||||
}
|
|
||||||
return await database.prepare(
|
|
||||||
"SELECT * FROM companies WHERE kind = 'principal' ORDER BY id LIMIT 1",
|
|
||||||
).get() as Company | undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normCompanyCode(value: string | null | undefined): string {
|
export function normCompanyCode(value: string | null | undefined): string {
|
||||||
|
|
@ -114,13 +109,9 @@ export function companyCodeFromName(name: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function nextCompanyCode(database: Db, name: string): Promise<string> {
|
export async function nextCompanyCode(database: Db, name: string): Promise<string> {
|
||||||
const base = companyCodeFromName(name);
|
const env = await callCoreFn<{ code: string }>(database, "core.fn_next_company_code", { name });
|
||||||
if (!await companyByCode(database, base)) return base;
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
const row = await database.prepare(
|
return String(env.data?.code ?? companyCodeFromName(name));
|
||||||
`SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n
|
|
||||||
FROM companies WHERE code ~ '^EMP-[0-9]{4}'`,
|
|
||||||
).get() as { n: number };
|
|
||||||
return `EMP-${String(row.n).padStart(4, "0")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveCompany(
|
export async function resolveCompany(
|
||||||
|
|
@ -186,72 +177,19 @@ export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateProfile(input: CompanyProfileInput, opts: { requireLegal?: boolean } = {}): string | null {
|
|
||||||
return validateCompanyProfile(input, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
function profileFromInput(input: CompanyProfileInput, fallbackName = "") {
|
|
||||||
return normalizeCompanyProfile(input, fallbackName);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSubcompany(
|
export async function createSubcompany(
|
||||||
database: Db,
|
database: Db,
|
||||||
input: CompanyProfileInput,
|
input: CompanyProfileInput,
|
||||||
tenantId?: number | null,
|
tenantId?: number | null,
|
||||||
): Promise<{ company?: Company; error?: string }> {
|
): Promise<{ company?: Company; error?: string; status?: number }> {
|
||||||
const profileErr = validateProfile(input, { requireLegal: true });
|
const profileErr = validateCompanyProfile(input, { requireLegal: true });
|
||||||
if (profileErr) return { error: profileErr };
|
if (profileErr) return { error: profileErr, status: 400 };
|
||||||
const profile = profileFromInput(input);
|
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_create", {
|
||||||
if (!profile.name) return { error: "Nombre de empresa obligatorio" };
|
...input,
|
||||||
|
tenant_id: tenantId ?? null,
|
||||||
const principal = await principalCompany(database, tenantId);
|
});
|
||||||
if (!principal) return { error: "No hay empresa principal" };
|
if (!env.ok) return envelopeError(env);
|
||||||
const parentId = input.parent_id ? Number(input.parent_id) : principal.id;
|
return { company: env.data?.company as Company };
|
||||||
const parent = await companyById(database, parentId);
|
|
||||||
if (!parent) return { error: "Empresa padre no encontrada" };
|
|
||||||
if (tenantId != null && parent.tenant_id != null && parent.tenant_id !== tenantId) {
|
|
||||||
return { error: "Empresa padre de otro tenant" };
|
|
||||||
}
|
|
||||||
|
|
||||||
let code = normCompanyCode(input.code);
|
|
||||||
if (!code) code = await nextCompanyCode(database, profile.name);
|
|
||||||
const codeErr = validateCompanyCode(code);
|
|
||||||
if (codeErr) return { error: codeErr };
|
|
||||||
if (await companyByCode(database, code)) return { error: "Ya existe una empresa con ese código" };
|
|
||||||
|
|
||||||
const tid = tenantId ?? principal.tenant_id ?? null;
|
|
||||||
try {
|
|
||||||
await database.prepare(
|
|
||||||
`INSERT INTO companies (
|
|
||||||
code, name, parent_id, kind, status, tenant_id,
|
|
||||||
registro_patronal, razon_social, nombre_comercial, rfc, regimen_fiscal, clase_riesgo,
|
|
||||||
domicilio_fiscal, codigo_postal, ciudad, estado, telefono, email, representante_legal, giro
|
|
||||||
) VALUES (?, ?, ?, 'sub', 'activo', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
code,
|
|
||||||
profile.name,
|
|
||||||
parent.id,
|
|
||||||
tid,
|
|
||||||
profile.registro_patronal,
|
|
||||||
profile.razon_social,
|
|
||||||
profile.nombre_comercial,
|
|
||||||
profile.rfc,
|
|
||||||
profile.regimen_fiscal,
|
|
||||||
profile.clase_riesgo,
|
|
||||||
profile.domicilio_fiscal,
|
|
||||||
profile.codigo_postal,
|
|
||||||
profile.ciudad,
|
|
||||||
profile.estado,
|
|
||||||
profile.telefono,
|
|
||||||
profile.email,
|
|
||||||
profile.representante_legal,
|
|
||||||
profile.giro,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código" };
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return { company: await companyById(database, await database.lastInsertId()) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCompany(
|
export async function updateCompany(
|
||||||
|
|
@ -260,100 +198,14 @@ export async function updateCompany(
|
||||||
input: CompanyProfileInput,
|
input: CompanyProfileInput,
|
||||||
tenantId?: number | null,
|
tenantId?: number | null,
|
||||||
): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> {
|
): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> {
|
||||||
const current = await companyById(database, id);
|
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_update", {
|
||||||
// Con RLS activo, una fila de otro tenant ya no aparece aquí (la conexión
|
|
||||||
// solo ve app.tenant_id); este chequeo explícito es defensa adicional y
|
|
||||||
// un mensaje de error más claro que un 404 "silencioso".
|
|
||||||
if (!current || (tenantId != null && current.tenant_id != null && current.tenant_id !== tenantId)) {
|
|
||||||
return { error: "Empresa no encontrada", status: 404 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const profileErr = validateProfile(input);
|
|
||||||
if (profileErr) return { error: profileErr, status: 400 };
|
|
||||||
|
|
||||||
const merged: CompanyProfileInput = {
|
|
||||||
name: input.name !== undefined ? input.name : current.name,
|
|
||||||
code: input.code !== undefined ? input.code : current.code,
|
|
||||||
status: input.status !== undefined ? input.status : current.status,
|
|
||||||
registro_patronal: input.registro_patronal !== undefined ? input.registro_patronal : current.registro_patronal,
|
|
||||||
razon_social: input.razon_social !== undefined ? input.razon_social : current.razon_social,
|
|
||||||
nombre_comercial: input.nombre_comercial !== undefined ? input.nombre_comercial : current.nombre_comercial,
|
|
||||||
rfc: input.rfc !== undefined ? input.rfc : current.rfc,
|
|
||||||
regimen_fiscal: input.regimen_fiscal !== undefined ? input.regimen_fiscal : current.regimen_fiscal,
|
|
||||||
clase_riesgo: input.clase_riesgo !== undefined ? input.clase_riesgo : current.clase_riesgo,
|
|
||||||
domicilio_fiscal: input.domicilio_fiscal !== undefined ? input.domicilio_fiscal : current.domicilio_fiscal,
|
|
||||||
codigo_postal: input.codigo_postal !== undefined ? input.codigo_postal : current.codigo_postal,
|
|
||||||
ciudad: input.ciudad !== undefined ? input.ciudad : current.ciudad,
|
|
||||||
estado: input.estado !== undefined ? input.estado : current.estado,
|
|
||||||
telefono: input.telefono !== undefined ? input.telefono : current.telefono,
|
|
||||||
email: input.email !== undefined ? input.email : current.email,
|
|
||||||
representante_legal: input.representante_legal !== undefined
|
|
||||||
? input.representante_legal
|
|
||||||
: current.representante_legal,
|
|
||||||
giro: input.giro !== undefined ? input.giro : current.giro,
|
|
||||||
};
|
|
||||||
const profile = profileFromInput(merged, current.name);
|
|
||||||
if (!profile.name) return { error: "Nombre de empresa obligatorio", status: 400 };
|
|
||||||
|
|
||||||
let code = current.code;
|
|
||||||
if (input.code !== undefined) {
|
|
||||||
code = normCompanyCode(input.code);
|
|
||||||
const codeErr = validateCompanyCode(code);
|
|
||||||
if (codeErr) return { error: codeErr, status: 400 };
|
|
||||||
const clash = await companyByCode(database, code);
|
|
||||||
if (clash && clash.id !== id) return { error: "Ya existe una empresa con ese código", status: 400 };
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = current.status;
|
|
||||||
if (input.status !== undefined) {
|
|
||||||
if (!["activo", "inactivo"].includes(input.status)) {
|
|
||||||
return { error: "Estatus debe ser activo o inactivo", status: 400 };
|
|
||||||
}
|
|
||||||
if (current.kind === "principal" && input.status === "inactivo") {
|
|
||||||
return { error: "La empresa principal no se puede inactivar", status: 400 };
|
|
||||||
}
|
|
||||||
status = input.status as Company["status"];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await database.prepare(
|
|
||||||
`UPDATE companies SET
|
|
||||||
name = ?, code = ?, status = ?,
|
|
||||||
registro_patronal = ?, razon_social = ?, nombre_comercial = ?, rfc = ?, regimen_fiscal = ?, clase_riesgo = ?,
|
|
||||||
domicilio_fiscal = ?, codigo_postal = ?, ciudad = ?, estado = ?, telefono = ?, email = ?,
|
|
||||||
representante_legal = ?, giro = ?
|
|
||||||
WHERE id = ?`,
|
|
||||||
).run(
|
|
||||||
profile.name,
|
|
||||||
code,
|
|
||||||
status,
|
|
||||||
profile.registro_patronal,
|
|
||||||
profile.razon_social,
|
|
||||||
profile.nombre_comercial,
|
|
||||||
profile.rfc,
|
|
||||||
profile.regimen_fiscal,
|
|
||||||
profile.clase_riesgo,
|
|
||||||
profile.domicilio_fiscal,
|
|
||||||
profile.codigo_postal,
|
|
||||||
profile.ciudad,
|
|
||||||
profile.estado,
|
|
||||||
profile.telefono,
|
|
||||||
profile.email,
|
|
||||||
profile.representante_legal,
|
|
||||||
profile.giro,
|
|
||||||
id,
|
id,
|
||||||
);
|
tenant_id: tenantId ?? null,
|
||||||
} catch (e) {
|
...input,
|
||||||
if (isUniqueViolation(e)) return { error: "Ya existe una empresa con ese código", status: 400 };
|
});
|
||||||
throw e;
|
if (!env.ok) {
|
||||||
|
const st = mapRpcToStatus(String(env.code));
|
||||||
|
return { error: env.message, status: st === 404 ? 404 : 400 };
|
||||||
}
|
}
|
||||||
if (code !== current.code) {
|
return { company: env.data?.company as Company };
|
||||||
await database.prepare("UPDATE workers SET hire_type = ? WHERE company_id = ?").run(code, id);
|
|
||||||
}
|
|
||||||
return { company: await companyById(database, id) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Postgres error code 23505 = unique_violation. */
|
|
||||||
function isUniqueViolation(e: unknown): boolean {
|
|
||||||
return !!e && typeof e === "object" && (e as { code?: string }).code === "23505";
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
231
api/db.ts
231
api/db.ts
|
|
@ -1,22 +1,15 @@
|
||||||
import postgres, { createPool, PgDb, withTenant } from "./pg.ts";
|
import postgres, { createPool, PgDb, withTenant } from "./pg.ts";
|
||||||
import { config } from "./config.ts";
|
import { config } from "./config.ts";
|
||||||
|
import { callCoreFn, RpcCallError } from "./rpc.ts";
|
||||||
import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
|
import { evaluateDocumentValidity, freshnessRequired, type ImssStatus, type WorkerImssContext } from "./document_validity.ts";
|
||||||
|
|
||||||
export type Db = PgDb;
|
export type Db = PgDb;
|
||||||
|
|
||||||
/**
|
|
||||||
* panels_product, esquema core: negocio (empresas, personal, obras,
|
|
||||||
* expedientes, presupuesto, nómina, gafetes). Aislado de `iam` a propósito
|
|
||||||
* -- este módulo no debe importar iam_db.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
let corePool: postgres.Sql | null = null;
|
let corePool: postgres.Sql | null = null;
|
||||||
let coreOwnerPool: postgres.Sql | null = null;
|
let coreOwnerPool: postgres.Sql | null = null;
|
||||||
|
|
||||||
function getCorePool(): postgres.Sql {
|
function getCorePool(): postgres.Sql {
|
||||||
if (!corePool) {
|
if (!corePool) corePool = createPool(config.databaseUrlCore, { max: 20 });
|
||||||
corePool = createPool(config.databaseUrlCore, { max: 20 });
|
|
||||||
}
|
|
||||||
return corePool;
|
return corePool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,10 +24,6 @@ export async function pingCoreDb(): Promise<void> {
|
||||||
await getCorePool()`SELECT 1`;
|
await getCorePool()`SELECT 1`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** El único camino "normal" para atender un request: abre una transacción
|
|
||||||
* con app.tenant_id fijado (Row Level Security), y la cierra sola al
|
|
||||||
* terminar `fn` (commit) o al lanzar (rollback). Ver api/pg.ts#withTenant
|
|
||||||
* y las políticas en db/core/changesets/005-rls.sql. */
|
|
||||||
export async function withCoreTenant<T>(
|
export async function withCoreTenant<T>(
|
||||||
tenantId: number | null,
|
tenantId: number | null,
|
||||||
fn: (db: Db) => Promise<T>,
|
fn: (db: Db) => Promise<T>,
|
||||||
|
|
@ -42,10 +31,6 @@ export async function withCoreTenant<T>(
|
||||||
return await withTenant(getCorePool(), tenantId, fn);
|
return await withTenant(getCorePool(), tenantId, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Conexión SIN RLS (rol _owner, dueño de las tablas) -- reservada para
|
|
||||||
* scripts (bootstrap, ETL) y para resoluciones internas puntuales que
|
|
||||||
* necesitan cruzar tenants a propósito (ej. auth.ts al enriquecer el login
|
|
||||||
* con el nombre de la empresa). NO usar en handlers de request normales. */
|
|
||||||
export async function getCoreDb(): Promise<Db> {
|
export async function getCoreDb(): Promise<Db> {
|
||||||
return new PgDb(getCoreOwnerPool());
|
return new PgDb(getCoreOwnerPool());
|
||||||
}
|
}
|
||||||
|
|
@ -76,17 +61,15 @@ export function isProjectStatus(value: string): value is ProjectStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function nextProjectCode(database: Db): Promise<string> {
|
export async function nextProjectCode(database: Db): Promise<string> {
|
||||||
const row = await database.prepare(
|
const env = await callCoreFn<{ code: string }>(database, "core.fn_next_project_code", {});
|
||||||
`SELECT COALESCE(MAX(substring(code from 5)::integer), 0) + 1 AS n
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
FROM projects WHERE code ~ '^PRY-[0-9]{4}' OR code ~ '^OBR-[0-9]{4}'`,
|
return String(env.data?.code ?? "PRY-0001");
|
||||||
).get() as { n: number };
|
|
||||||
return `PRY-${String(row.n).padStart(4, "0")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function projectById(database: Db, id: number) {
|
export async function projectById(database: Db, id: number) {
|
||||||
return await database.prepare("SELECT * FROM projects WHERE id = ?").get(id) as
|
const env = await callCoreFn<{ project: Record<string, unknown> }>(database, "core.fn_project_get", { id });
|
||||||
| { id: number; code: string; name: string; status: string }
|
if (!env.ok) return undefined;
|
||||||
| undefined;
|
return env.data?.project as { id: number; code: string; name: string; status: string } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectMustBe(
|
export function projectMustBe(
|
||||||
|
|
@ -102,104 +85,87 @@ export function projectMustBe(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function workerImssContext(database: Db, workerId: number): Promise<WorkerImssContext> {
|
export async function workerImssContext(database: Db, workerId: number): Promise<WorkerImssContext> {
|
||||||
const w = await database.prepare(
|
const env = await callCoreFn<{ imss_context: WorkerImssContext }>(
|
||||||
"SELECT imss_status, imss_alta_at, last_rehire_at FROM workers WHERE id = ?",
|
database, "core.fn_worker_checklist_raw", { worker_id: workerId },
|
||||||
).get(workerId) as {
|
);
|
||||||
imss_status: ImssStatus;
|
if (env.ok && env.data?.imss_context) return env.data.imss_context;
|
||||||
imss_alta_at: string | null;
|
return { imss_status: "sin_alta" as ImssStatus, last_rehire_at: null, current_alta_at: null };
|
||||||
last_rehire_at: string | null;
|
|
||||||
} | undefined;
|
|
||||||
const altaDoc = await database.prepare(
|
|
||||||
`SELECT imss_alta_at, uploaded_at FROM documents
|
|
||||||
WHERE worker_id = ? AND type_code = 'alta_imss' AND is_current = true
|
|
||||||
LIMIT 1`,
|
|
||||||
).get(workerId) as { imss_alta_at: string | null; uploaded_at: string } | undefined;
|
|
||||||
return {
|
|
||||||
imss_status: (w?.imss_status as ImssStatus) || "sin_alta",
|
|
||||||
last_rehire_at: w?.last_rehire_at ?? null,
|
|
||||||
current_alta_at: altaDoc?.imss_alta_at || w?.imss_alta_at || altaDoc?.uploaded_at || null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Checklist con vigencia; contexto IMSS vía imssFlagsFor / checklistItemsFor.
|
|
||||||
* tenantId (Fase 4d) resuelve la zona horaria del tenant para calcular
|
|
||||||
* "hoy" de forma consistente con nómina -- antes esta función usaba
|
|
||||||
* `new Date()` crudo (hora del servidor/UTC) sin relación con PAYROLL_TZ. */
|
|
||||||
export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) {
|
export async function checklistItemsFor(database: Db, workerId: number, tenantId: number | null = null) {
|
||||||
const tz = await tenantTimezone(database, tenantId);
|
const tz = await tenantTimezone(database, tenantId);
|
||||||
const today = todayInTimezone(tz);
|
const today = todayInTimezone(tz);
|
||||||
const types = await database.prepare(
|
const env = await callCoreFn<{
|
||||||
`SELECT code, label, required, validity_mode, freshness_days,
|
items?: Array<Record<string, unknown>>;
|
||||||
requires_issued_at, requires_expires_at, category
|
ctx?: WorkerImssContext;
|
||||||
FROM document_types`,
|
imss_context?: WorkerImssContext;
|
||||||
).all() as {
|
freshness_required?: boolean;
|
||||||
code: string;
|
document_types?: Array<Record<string, unknown>>;
|
||||||
label: string;
|
current_documents?: Array<Record<string, unknown>>;
|
||||||
required: boolean;
|
}>(database, "core.fn_worker_checklist_raw", { worker_id: workerId, tenant_id: tenantId });
|
||||||
validity_mode: string;
|
if (env.ok && env.data?.items) {
|
||||||
freshness_days: number | null;
|
|
||||||
requires_issued_at: boolean;
|
|
||||||
requires_expires_at: boolean;
|
|
||||||
category: string;
|
|
||||||
}[];
|
|
||||||
const currentDocs = await database.prepare(
|
|
||||||
`SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at
|
|
||||||
FROM documents WHERE worker_id = ? AND is_current = true`,
|
|
||||||
).all(workerId) as {
|
|
||||||
type_code: string;
|
|
||||||
issued_at: string | null;
|
|
||||||
expires_at: string | null;
|
|
||||||
uploaded_at: string;
|
|
||||||
imss_alta_at: string | null;
|
|
||||||
}[];
|
|
||||||
const byType = new Map(currentDocs.map((d) => [d.type_code, d]));
|
|
||||||
const ctx = await workerImssContext(database, workerId);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ctx,
|
ctx: (env.data.ctx ?? env.data.imss_context) as WorkerImssContext,
|
||||||
freshness_required: freshnessRequired(ctx),
|
freshness_required: Boolean(env.data.freshness_required),
|
||||||
items: types.map((t) => {
|
items: env.data.items,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const types = env.data?.document_types ?? [];
|
||||||
|
const currentDocs = env.data?.current_documents ?? [];
|
||||||
|
const ctx = (env.data?.imss_context ?? await workerImssContext(database, workerId)) as WorkerImssContext;
|
||||||
|
const byType = new Map(currentDocs.map((d) => [String(d.type_code), d]));
|
||||||
|
const items = types.map((t) => {
|
||||||
const policy = {
|
const policy = {
|
||||||
code: t.code,
|
code: String(t.code),
|
||||||
label: t.label,
|
label: String(t.label),
|
||||||
required: !!t.required,
|
required: !!t.required,
|
||||||
validity_mode: (t.validity_mode || "none") as "none" | "freshness" | "expiry",
|
validity_mode: (String(t.validity_mode || "none")) as "none" | "freshness" | "expiry",
|
||||||
freshness_days: t.freshness_days,
|
freshness_days: t.freshness_days as number | null,
|
||||||
requires_issued_at: !!t.requires_issued_at,
|
requires_issued_at: !!t.requires_issued_at,
|
||||||
requires_expires_at: !!t.requires_expires_at,
|
requires_expires_at: !!t.requires_expires_at,
|
||||||
};
|
};
|
||||||
const evaled = evaluateDocumentValidity(policy, byType.get(t.code), ctx, today);
|
const evaled = evaluateDocumentValidity(policy, byType.get(policy.code) as never, ctx, today);
|
||||||
return {
|
return {
|
||||||
code: t.code,
|
code: policy.code,
|
||||||
label: t.label,
|
label: policy.label,
|
||||||
required: !!t.required,
|
required: policy.required,
|
||||||
validity_mode: policy.validity_mode,
|
validity_mode: policy.validity_mode,
|
||||||
freshness_days: t.freshness_days,
|
freshness_days: t.freshness_days,
|
||||||
requires_issued_at: policy.requires_issued_at,
|
requires_issued_at: policy.requires_issued_at,
|
||||||
requires_expires_at: policy.requires_expires_at,
|
requires_expires_at: policy.requires_expires_at,
|
||||||
category: t.category || "otro",
|
category: String(t.category || "otro"),
|
||||||
present: evaled.present,
|
present: evaled.present,
|
||||||
valid: evaled.valid,
|
valid: evaled.valid,
|
||||||
validity_status: evaled.validity_status,
|
validity_status: evaled.validity_status,
|
||||||
issued_at: evaled.issued_at,
|
issued_at: evaled.issued_at,
|
||||||
expires_at: evaled.expires_at,
|
expires_at: evaled.expires_at,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
};
|
return { ctx, freshness_required: freshnessRequired(ctx), items };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) {
|
export async function imssFlagsFor(database: Db, workerId: number, tenantId: number | null = null) {
|
||||||
const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId);
|
const { ctx, freshness_required: needFresh, items } = await checklistItemsFor(database, workerId, tenantId);
|
||||||
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
|
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
|
||||||
const inProject = !!await database.prepare(
|
const env = await callCoreFn<{ workers: Array<Record<string, unknown>> }>(
|
||||||
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
|
database, "core.fn_worker_list", { tenant_id: tenantId },
|
||||||
).get(workerId);
|
);
|
||||||
|
const row = env.ok ? (env.data?.workers ?? []).find((w) => Number(w.id) === workerId) : undefined;
|
||||||
|
if (row) {
|
||||||
|
return {
|
||||||
|
imss_status: String(row.imss_status ?? ctx.imss_status),
|
||||||
|
freshness_required: Boolean(row.freshness_required ?? needFresh),
|
||||||
|
imss_ready: Boolean(row.imss_ready),
|
||||||
|
in_project_without_imss: Boolean(row.in_project_without_imss),
|
||||||
|
expediente_ok: Boolean(row.expediente_ok),
|
||||||
|
};
|
||||||
|
}
|
||||||
const hasImss = ctx.imss_status === "alta";
|
const hasImss = ctx.imss_status === "alta";
|
||||||
return {
|
return {
|
||||||
imss_status: ctx.imss_status,
|
imss_status: ctx.imss_status,
|
||||||
freshness_required: needFresh,
|
freshness_required: needFresh,
|
||||||
imss_ready: requiredOk && !hasImss,
|
imss_ready: requiredOk && !hasImss,
|
||||||
in_project_without_imss: inProject && !hasImss,
|
in_project_without_imss: false,
|
||||||
expediente_ok: requiredOk,
|
expediente_ok: requiredOk,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -209,82 +175,37 @@ export async function checklistFor(database: Db, workerId: number, tenantId: num
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise<void> {
|
export async function refreshPipeline(database: Db, workerId: number, tenantId: number | null = null): Promise<void> {
|
||||||
const w = await database.prepare("SELECT status FROM workers WHERE id = ?").get(workerId) as
|
const env = await callCoreFn(database, "core.fn_worker_refresh_pipeline", {
|
||||||
| { status: string }
|
worker_id: workerId, tenant_id: tenantId,
|
||||||
| undefined;
|
});
|
||||||
if (!w) return;
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
if (w.status === "baja") {
|
|
||||||
await database.prepare("UPDATE workers SET pipeline_status = 'baja' WHERE id = ?").run(workerId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const items = await checklistFor(database, workerId, tenantId);
|
|
||||||
const photo = items.find((i) => i.code === "foto");
|
|
||||||
const photoOk = photo?.present && photo?.valid;
|
|
||||||
const requiredOk = items.filter((i) => i.required).every((i) => i.present && i.valid);
|
|
||||||
const printed = await database.prepare(
|
|
||||||
`SELECT 1 FROM badge_job_people p
|
|
||||||
JOIN badge_jobs j ON j.id = p.job_id
|
|
||||||
WHERE p.worker_id = ? LIMIT 1`,
|
|
||||||
).get(workerId);
|
|
||||||
let pipeline = "incompleto";
|
|
||||||
if (requiredOk && photoOk) pipeline = printed ? "impreso" : "listo_gafete";
|
|
||||||
const assigned = await database.prepare(
|
|
||||||
"SELECT 1 FROM assignments WHERE worker_id = ? AND active = true LIMIT 1",
|
|
||||||
).get(workerId);
|
|
||||||
if (pipeline !== "incompleto" && assigned) {
|
|
||||||
pipeline = printed ? "activo" : "listo_gafete";
|
|
||||||
}
|
|
||||||
await database.prepare("UPDATE workers SET pipeline_status = ? WHERE id = ?").run(pipeline, workerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function projectChecklistFor(database: Db, projectId: number) {
|
export async function projectChecklistFor(database: Db, projectId: number) {
|
||||||
const types = await database.prepare(
|
const env = await callCoreFn<{ checklist: unknown[] }>(
|
||||||
"SELECT code, label, required, category FROM project_document_types",
|
database, "core.fn_project_checklist", { project_id: projectId },
|
||||||
).all() as { code: string; label: string; required: boolean; category: string }[];
|
);
|
||||||
const current = await database.prepare(
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
`SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = true`,
|
return env.data?.checklist ?? [];
|
||||||
).all(projectId) as { type_code: string }[];
|
|
||||||
const have = new Set(current.map((c) => c.type_code));
|
|
||||||
return types.map((t) => ({
|
|
||||||
code: t.code,
|
|
||||||
label: t.label,
|
|
||||||
required: !!t.required,
|
|
||||||
category: t.category || "otro",
|
|
||||||
present: have.has(t.code),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function companyChecklistFor(database: Db, companyId: number) {
|
export async function companyChecklistFor(database: Db, companyId: number) {
|
||||||
const types = await database.prepare(
|
const env = await callCoreFn<{ checklist: unknown[] }>(
|
||||||
"SELECT code, label, required, category FROM company_document_types",
|
database, "core.fn_company_checklist", { company_id: companyId },
|
||||||
).all() as { code: string; label: string; required: boolean; category: string }[];
|
);
|
||||||
const current = await database.prepare(
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
`SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = true`,
|
return env.data?.checklist ?? [];
|
||||||
).all(companyId) as { type_code: string }[];
|
|
||||||
const have = new Set(current.map((c) => c.type_code));
|
|
||||||
return types.map((t) => ({
|
|
||||||
code: t.code,
|
|
||||||
label: t.label,
|
|
||||||
required: !!t.required,
|
|
||||||
category: t.category || "otro",
|
|
||||||
present: have.has(t.code),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zona horaria del tenant (Fase 4d) -- reemplaza el PAYROLL_TZ
|
|
||||||
* hardcodeado. Con default sensato si el tenant no la configuró. */
|
|
||||||
export async function tenantTimezone(database: Db, tenantId: number | null): Promise<string> {
|
export async function tenantTimezone(database: Db, tenantId: number | null): Promise<string> {
|
||||||
if (tenantId == null) return "America/Mexico_City";
|
if (tenantId == null) return "America/Mexico_City";
|
||||||
const row = await database.prepare(
|
const env = await callCoreFn<{ timezone: string }>(
|
||||||
"SELECT timezone FROM tenant_settings WHERE tenant_id = ?",
|
database, "core.fn_tenant_timezone_get", { tenant_id: tenantId },
|
||||||
).get(tenantId) as { timezone: string } | undefined;
|
);
|
||||||
return row?.timezone || "America/Mexico_City";
|
if (!env.ok) return "America/Mexico_City";
|
||||||
|
return String(env.data?.timezone ?? "America/Mexico_City");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Hoy" en la zona horaria del tenant, formato ISO (YYYY-MM-DD). Usado por
|
|
||||||
* nómina y por validación de vigencia de documentos -- antes eran dos
|
|
||||||
* nociones de "hoy" distintas (PAYROLL_TZ vs. new Date() crudo); ahora es
|
|
||||||
* una sola función parametrizada por tenant. */
|
|
||||||
export function todayInTimezone(timezone: string, now = new Date()): string {
|
export function todayInTimezone(timezone: string, now = new Date()): string {
|
||||||
return now.toLocaleDateString("en-CA", { timeZone: timezone });
|
return now.toLocaleDateString("en-CA", { timeZone: timezone });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
79
api/excel.ts
79
api/excel.ts
|
|
@ -191,12 +191,12 @@ export function buildImportTemplate(companies: { code: string; name: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findExisting(db: Db, curp: string, rfc: string, nss: string) {
|
async function findExisting(db: Db, curp: string, rfc: string, nss: string) {
|
||||||
return await db.prepare(
|
const env = await callCoreFn<{
|
||||||
`SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers
|
worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null;
|
||||||
WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`,
|
found?: boolean;
|
||||||
).get(curp, rfc, nss) as
|
}>(db, "core.fn_worker_find_existing", { curp, rfc, nss });
|
||||||
| { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string }
|
if (!env.ok || !env.data?.found) return undefined;
|
||||||
| undefined;
|
return env.data.worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function storeDocument(
|
export async function storeDocument(
|
||||||
|
|
@ -256,21 +256,23 @@ export async function storeProjectDocument(
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
userId: number | null,
|
userId: number | null,
|
||||||
) {
|
) {
|
||||||
const allowed = await db.prepare("SELECT code FROM project_document_types WHERE code = ?").get(type);
|
|
||||||
if (!allowed) throw new Error("Tipo de documento no válido");
|
|
||||||
const { iv, cipher } = await encryptBytes(bytes);
|
const { iv, cipher } = await encryptBytes(bytes);
|
||||||
const hash = await sha256Hex(bytes);
|
const hash = await sha256Hex(bytes);
|
||||||
const storage = `${crypto.randomUUID()}.enc`;
|
const storage = `${crypto.randomUUID()}.enc`;
|
||||||
await putObject(projectDocKey(projectId, storage), cipher);
|
await putObject(projectDocKey(projectId, storage), cipher);
|
||||||
await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run(
|
const env = await callCoreFn(db, "core.fn_project_document_store", {
|
||||||
projectId,
|
project_id: projectId,
|
||||||
type,
|
type_code: type,
|
||||||
);
|
original_name: filename,
|
||||||
await db.prepare(
|
mime,
|
||||||
`INSERT INTO project_documents
|
size_bytes: bytes.byteLength,
|
||||||
(project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name)
|
sha256: hash,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
|
iv,
|
||||||
).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
|
storage_name: storage,
|
||||||
|
uploaded_by_id: userId,
|
||||||
|
uploaded_by_name: "",
|
||||||
|
});
|
||||||
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function storeCompanyDocument(
|
export async function storeCompanyDocument(
|
||||||
|
|
@ -282,21 +284,23 @@ export async function storeCompanyDocument(
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
userId: number | null,
|
userId: number | null,
|
||||||
) {
|
) {
|
||||||
const allowed = await db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type);
|
|
||||||
if (!allowed) throw new Error("Tipo de documento no válido");
|
|
||||||
const { iv, cipher } = await encryptBytes(bytes);
|
const { iv, cipher } = await encryptBytes(bytes);
|
||||||
const hash = await sha256Hex(bytes);
|
const hash = await sha256Hex(bytes);
|
||||||
const storage = `${crypto.randomUUID()}.enc`;
|
const storage = `${crypto.randomUUID()}.enc`;
|
||||||
await putObject(companyDocKey(companyId, storage), cipher);
|
await putObject(companyDocKey(companyId, storage), cipher);
|
||||||
await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run(
|
const env = await callCoreFn(db, "core.fn_company_document_store", {
|
||||||
companyId,
|
company_id: companyId,
|
||||||
type,
|
type_code: type,
|
||||||
);
|
original_name: filename,
|
||||||
await db.prepare(
|
mime,
|
||||||
`INSERT INTO company_documents
|
size_bytes: bytes.byteLength,
|
||||||
(company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name)
|
sha256: hash,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`,
|
iv,
|
||||||
).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId);
|
storage_name: storage,
|
||||||
|
uploaded_by_id: userId,
|
||||||
|
uploaded_by_name: "",
|
||||||
|
});
|
||||||
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPhoto(url: string): Promise<Uint8Array | null> {
|
async function fetchPhoto(url: string): Promise<Uint8Array | null> {
|
||||||
|
|
@ -385,14 +389,11 @@ async function upsertWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assign(db: Db, workerId: number, projectId: number) {
|
async function assign(db: Db, workerId: number, projectId: number) {
|
||||||
await db.prepare(
|
const env = await callCoreFn(db, "core.fn_worker_assign", {
|
||||||
`INSERT INTO assignments (worker_id, project_id, active, start_date)
|
worker_id: workerId,
|
||||||
VALUES (?, ?, true, current_date)
|
project_id: projectId,
|
||||||
ON CONFLICT(worker_id, project_id) DO UPDATE SET
|
});
|
||||||
active=true,
|
if (!env.ok) throw new RpcCallError(env);
|
||||||
start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END,
|
|
||||||
end_date=NULL`,
|
|
||||||
).run(workerId, projectId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importExcel(
|
export async function importExcel(
|
||||||
|
|
@ -402,9 +403,9 @@ export async function importExcel(
|
||||||
userId: number | null,
|
userId: number | null,
|
||||||
): Promise<ImportReport> {
|
): Promise<ImportReport> {
|
||||||
const wb = XLSX.read(bytes, { type: "array" });
|
const wb = XLSX.read(bytes, { type: "array" });
|
||||||
const risks = new Set(
|
const catEnv = await callCoreFn<{ risks?: { code: string }[] }>(db, "core.fn_catalogs", {});
|
||||||
(await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code),
|
if (!catEnv.ok) throw new RpcCallError(catEnv);
|
||||||
);
|
const risks = new Set((catEnv.data?.risks ?? []).map((r) => r.code));
|
||||||
const report: ImportReport = {
|
const report: ImportReport = {
|
||||||
inserted: 0,
|
inserted: 0,
|
||||||
existed: 0,
|
existed: 0,
|
||||||
|
|
|
||||||
15
api/http_errors_test.ts
Normal file
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);
|
||||||
|
});
|
||||||
935
api/payroll.ts
935
api/payroll.ts
|
|
@ -117,921 +117,124 @@ export function loanConditionLabel(
|
||||||
return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`;
|
return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { callCoreFn } from "./rpc.ts";
|
||||||
|
import type { RpcEnvelope } from "./http_errors.ts";
|
||||||
|
import { mapRpcToStatus } from "./http_errors.ts";
|
||||||
|
|
||||||
|
function throwFromRpc(env: RpcEnvelope): never {
|
||||||
|
const e = new Error(env.message) as Error & { status?: number };
|
||||||
|
e.status = mapRpcToStatus(String(env.code));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSettings(db: Db, tenantId: number): Promise<PayrollSettings> {
|
export async function getSettings(db: Db, tenantId: number): Promise<PayrollSettings> {
|
||||||
await seedDefaults(db, tenantId);
|
const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_get", { tenant_id: tenantId });
|
||||||
return await db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings;
|
if (!env.ok) throwFromRpc(env);
|
||||||
|
return env.data!.settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSettings(
|
export async function saveSettings(db: Db, tenantId: number, patch: Partial<PayrollSettings>): Promise<PayrollSettings> {
|
||||||
db: Db,
|
const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_save", { tenant_id: tenantId, ...patch });
|
||||||
tenantId: number,
|
if (!env.ok) throwFromRpc(env);
|
||||||
patch: Partial<PayrollSettings>,
|
return env.data!.settings;
|
||||||
): Promise<PayrollSettings> {
|
|
||||||
const cur = await getSettings(db, tenantId);
|
|
||||||
const next = {
|
|
||||||
loan_commission_enabled: patch.loan_commission_enabled ?? cur.loan_commission_enabled,
|
|
||||||
loan_commission_pct: patch.loan_commission_pct ?? cur.loan_commission_pct,
|
|
||||||
loan_small_max: patch.loan_small_max ?? cur.loan_small_max,
|
|
||||||
};
|
|
||||||
await db.prepare(
|
|
||||||
`UPDATE payroll_settings
|
|
||||||
SET loan_commission_enabled=?, loan_commission_pct=?, loan_small_max=?
|
|
||||||
WHERE tenant_id=?`,
|
|
||||||
).run(
|
|
||||||
Boolean(next.loan_commission_enabled),
|
|
||||||
Number(next.loan_commission_pct) || 0,
|
|
||||||
Number(next.loan_small_max) || 0,
|
|
||||||
tenantId,
|
|
||||||
);
|
|
||||||
return await getSettings(db, tenantId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedDefaults(db: Db, tenantId: number): Promise<void> {
|
export async function seedDefaults(db: Db, tenantId: number): Promise<void> { await getSettings(db, tenantId); }
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max)
|
|
||||||
VALUES (?, true, 10, 500)
|
|
||||||
ON CONFLICT(tenant_id) DO NOTHING`,
|
|
||||||
).run(tenantId);
|
|
||||||
const n = await db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number };
|
|
||||||
if (Number(n.n) === 0) {
|
|
||||||
const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)");
|
|
||||||
for (const u of DEFAULT_UNITS) await ins.run(tenantId, u.code, u.label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listUnits(db: Db, tenantId: number) {
|
export async function listUnits(db: Db, tenantId: number) {
|
||||||
await seedDefaults(db, tenantId);
|
const env = await callCoreFn<{ units: unknown[] }>(db, "core.fn_payroll_unit_list", { tenant_id: tenantId });
|
||||||
return await db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId);
|
if (!env.ok) throwFromRpc(env);
|
||||||
|
return env.data?.units ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise<number> {
|
export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise<number> {
|
||||||
const c = code.trim().toLowerCase().replace(/\s+/g, "");
|
const env = await callCoreFn<{ id: number }>(db, "core.fn_payroll_unit_add", { tenant_id: tenantId, code, label });
|
||||||
const l = label.trim() || c;
|
if (!env.ok) throwFromRpc(env);
|
||||||
if (!c) throw new Error("Unidad requerida");
|
return Number(env.data?.id);
|
||||||
await db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l);
|
|
||||||
return await db.lastInsertId();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function firstDueForLoan(opts: {
|
export async function createLoan(db: Db, tenantId: number, body: Record<string, unknown>): Promise<{ id: number }> {
|
||||||
delivered: number;
|
const env = await callCoreFn<{ id: number }>(db, "core.fn_loan_create", { tenant_id: tenantId, ...body });
|
||||||
smallMax: number;
|
if (!env.ok) throwFromRpc(env);
|
||||||
plan: "single" | "installments";
|
return { id: Number(env.data?.id) };
|
||||||
grantIso: string;
|
|
||||||
weekAssembled: boolean;
|
|
||||||
}): string {
|
|
||||||
const small = opts.delivered <= opts.smallMax && opts.plan === "single";
|
|
||||||
if (small) return nextSaturdayAfterThisWeek(opts.grantIso);
|
|
||||||
if (opts.weekAssembled) return nextSaturdayAfterThisWeek(opts.grantIso);
|
|
||||||
return thisSaturday(opts.grantIso);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLoan(
|
export async function setAttendance(db: Db, tenantId: number, body: { project_id: number; worker_id: number; work_date: string; present: boolean }) {
|
||||||
db: Db,
|
const env = await callCoreFn(db, "core.fn_attendance_set", { tenant_id: tenantId, ...body });
|
||||||
tenantId: number,
|
if (!env.ok) return { error: env.message, status: mapRpcToStatus(String(env.code)), other: (env.data as { other?: unknown })?.other };
|
||||||
body: {
|
return { ok: true as const };
|
||||||
worker_id: number;
|
|
||||||
delivered: number;
|
|
||||||
commission_enabled?: boolean;
|
|
||||||
commission_pct?: number;
|
|
||||||
plan?: "single" | "installments";
|
|
||||||
installments_n?: number;
|
|
||||||
note?: string;
|
|
||||||
grantIso?: string;
|
|
||||||
},
|
|
||||||
): Promise<{ id: number }> {
|
|
||||||
const worker = await db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id);
|
|
||||||
if (!worker) throw Object.assign(new Error("Persona no encontrada"), { status: 404 });
|
|
||||||
const settings = await getSettings(db, tenantId);
|
|
||||||
const delivered = roundMoney(body.delivered);
|
|
||||||
if (delivered <= 0) throw Object.assign(new Error("Monto requerido"), { status: 400 });
|
|
||||||
const enabled = body.commission_enabled ?? Boolean(settings.loan_commission_enabled);
|
|
||||||
const pct = enabled ? Number(body.commission_pct ?? settings.loan_commission_pct) || 0 : 0;
|
|
||||||
const comm = commissionAmount(delivered, pct);
|
|
||||||
const balance = roundMoney(delivered + comm);
|
|
||||||
const small = delivered <= settings.loan_small_max;
|
|
||||||
let plan = body.plan ?? "single";
|
|
||||||
let installments = Math.max(1, Math.floor(body.installments_n ?? 1));
|
|
||||||
if (small) {
|
|
||||||
plan = "single";
|
|
||||||
installments = 1;
|
|
||||||
}
|
|
||||||
if (plan === "installments" && installments < 2) installments = 2;
|
|
||||||
if (plan === "single") installments = 1;
|
|
||||||
const weekly = roundMoney(balance / installments);
|
|
||||||
const grantIso = body.grantIso ?? await resolveToday(db, tenantId);
|
|
||||||
const week = weekContaining(grantIso);
|
|
||||||
const current = await db.prepare(
|
|
||||||
"SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
|
|
||||||
).get(tenantId, week.weekStart) as { id: number; status: string } | undefined;
|
|
||||||
const assembled = current?.status === "assembled" || current?.status === "paid";
|
|
||||||
const firstDue = firstDueForLoan({
|
|
||||||
delivered,
|
|
||||||
smallMax: settings.loan_small_max,
|
|
||||||
plan,
|
|
||||||
grantIso,
|
|
||||||
weekAssembled: assembled && weekdayMon0(grantIso) >= 4,
|
|
||||||
});
|
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO loans
|
|
||||||
(worker_id, amount, delivered, balance, weekly_payment, note,
|
|
||||||
commission_pct, commission_amount, plan, installments_n, first_due)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
body.worker_id,
|
|
||||||
delivered,
|
|
||||||
delivered,
|
|
||||||
balance,
|
|
||||||
weekly,
|
|
||||||
body.note ?? null,
|
|
||||||
pct,
|
|
||||||
comm,
|
|
||||||
plan,
|
|
||||||
installments,
|
|
||||||
firstDue,
|
|
||||||
);
|
|
||||||
const id = await db.lastInsertId();
|
|
||||||
if (current && current.status !== "paid") await recalcWeek(db, current.id);
|
|
||||||
return { id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function presentOnOtherProject(
|
|
||||||
db: Db,
|
|
||||||
workerId: number,
|
|
||||||
workDate: string,
|
|
||||||
exceptProjectId?: number,
|
|
||||||
): Promise<{ project_id: number; project_name: string } | null> {
|
|
||||||
const row = await db.prepare(
|
|
||||||
`SELECT a.project_id, p.name AS project_name
|
|
||||||
FROM attendance a JOIN projects p ON p.id=a.project_id
|
|
||||||
WHERE a.worker_id=? AND a.work_date=? AND a.present=true
|
|
||||||
AND a.project_id != ?`,
|
|
||||||
).get(workerId, workDate, exceptProjectId ?? 0) as
|
|
||||||
| { project_id: number; project_name: string }
|
|
||||||
| undefined;
|
|
||||||
return row ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setAttendance(
|
|
||||||
db: Db,
|
|
||||||
tenantId: number,
|
|
||||||
body: { project_id: number; worker_id: number; work_date: string; present: boolean },
|
|
||||||
): Promise<{ ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } }> {
|
|
||||||
if (body.present) {
|
|
||||||
const other = await presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id);
|
|
||||||
if (other) {
|
|
||||||
return {
|
|
||||||
error: `Ese día ya está marcado en ${other.project_name}`,
|
|
||||||
status: 409,
|
|
||||||
other,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO attendance (worker_id, project_id, work_date, present)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`,
|
|
||||||
).run(body.worker_id, body.project_id, body.work_date, body.present);
|
|
||||||
const { weekStart } = weekContaining(body.work_date);
|
|
||||||
const week = await db.prepare(
|
|
||||||
"SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
|
|
||||||
).get(tenantId, weekStart) as { id: number; status: string } | undefined;
|
|
||||||
if (week && week.status !== "paid") await recalcWeek(db, week.id);
|
|
||||||
return { ok: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise<number> {
|
export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise<number> {
|
||||||
await seedDefaults(db, tenantId);
|
const env = await callCoreFn<{ week_id: number }>(db, "core.fn_payroll_week_ensure", { tenant_id: tenantId, week_start: weekStart });
|
||||||
const { weekEnd } = weekContaining(weekStart);
|
if (!env.ok) throwFromRpc(env);
|
||||||
const start = weekContaining(weekStart).weekStart;
|
return Number(env.data?.week_id);
|
||||||
let week = await db.prepare(
|
|
||||||
"SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
|
|
||||||
).get(tenantId, start) as { id: number } | undefined;
|
|
||||||
if (!week) {
|
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
|
|
||||||
VALUES (?, ?, 'draft', ?)`,
|
|
||||||
).run(start, weekEnd, tenantId);
|
|
||||||
week = { id: await db.lastInsertId() };
|
|
||||||
}
|
|
||||||
const weekId = week.id;
|
|
||||||
const destajo = destajoPeriodBounds(start);
|
|
||||||
const period = await db.prepare(
|
|
||||||
"SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?",
|
|
||||||
).get(tenantId, destajo.periodEnd) as { id: number } | undefined;
|
|
||||||
if (!period) {
|
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId);
|
|
||||||
}
|
|
||||||
const projects = await db.prepare(
|
|
||||||
`SELECT id FROM projects
|
|
||||||
WHERE status IN ('activo', 'pausado')
|
|
||||||
AND (? = 0 OR tenant_id = ? OR tenant_id IS NULL)
|
|
||||||
ORDER BY name`,
|
|
||||||
).all(tenantId, tenantId) as { id: number }[];
|
|
||||||
const hasSheet = db.prepare(
|
|
||||||
`SELECT id FROM payroll_sheets
|
|
||||||
WHERE week_id=? AND kind=? AND COALESCE(project_id, 0)=COALESCE(?, 0)`,
|
|
||||||
);
|
|
||||||
const insSheet = db.prepare(
|
|
||||||
`INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`,
|
|
||||||
);
|
|
||||||
const ensureSheet = async (kind: string, projectId: number | null) => {
|
|
||||||
if (await hasSheet.get(weekId, kind, projectId)) return;
|
|
||||||
await insSheet.run(weekId, kind, projectId);
|
|
||||||
};
|
|
||||||
for (const p of projects) {
|
|
||||||
await ensureSheet("obra", p.id);
|
|
||||||
await ensureSheet("destajo", p.id);
|
|
||||||
}
|
|
||||||
await ensureSheet("admin", null);
|
|
||||||
return weekId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function jornalRoster(db: Db, projectId: number, weekStart: string, weekEnd: string, weekId = 0) {
|
|
||||||
return await db.prepare(
|
|
||||||
`SELECT w.id, w.first_name, w.last_name_p, w.position, w.daily_wage, w.work_type
|
|
||||||
FROM workers w
|
|
||||||
WHERE w.status='activo' AND w.pipeline_status != 'baja'
|
|
||||||
AND (
|
|
||||||
EXISTS (
|
|
||||||
SELECT 1 FROM assignments a
|
|
||||||
WHERE a.worker_id=w.id AND a.project_id=? AND a.active=true AND w.work_type='N'
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM attendance att
|
|
||||||
WHERE att.worker_id=w.id AND att.project_id=? AND att.work_date BETWEEN ? AND ?
|
|
||||||
)
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1 FROM payroll_week_lines l
|
|
||||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
|
||||||
WHERE l.worker_id=w.id AND s.project_id=? AND s.kind='obra' AND s.week_id=?
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY w.last_name_p, w.first_name`,
|
|
||||||
).all(projectId, projectId, weekStart, weekEnd, projectId, weekId) as {
|
|
||||||
id: number;
|
|
||||||
first_name: string;
|
|
||||||
last_name_p: string;
|
|
||||||
position: string;
|
|
||||||
daily_wage: number;
|
|
||||||
work_type: string;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sheetLine(
|
|
||||||
db: Db,
|
|
||||||
sheetId: number,
|
|
||||||
workerId: number,
|
|
||||||
destajoCutLineId: number | null,
|
|
||||||
): Promise<number> {
|
|
||||||
const existing = await db.prepare(
|
|
||||||
destajoCutLineId
|
|
||||||
? "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND destajo_cut_line_id=?"
|
|
||||||
: "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=? AND destajo_cut_line_id IS NULL",
|
|
||||||
).get(sheetId, destajoCutLineId ?? workerId) as { id: number } | undefined;
|
|
||||||
if (existing) return existing.id;
|
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id)
|
|
||||||
VALUES (?, ?, ?)`,
|
|
||||||
).run(sheetId, workerId, destajoCutLineId);
|
|
||||||
return await db.lastInsertId();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function dueLoansForSaturday(db: Db, workerId: number, saturday: string): Promise<LoanRow[]> {
|
|
||||||
return await db.prepare(
|
|
||||||
`SELECT * FROM loans
|
|
||||||
WHERE worker_id=? AND balance > 0 AND first_due IS NOT NULL AND first_due <= ?
|
|
||||||
ORDER BY first_due, id`,
|
|
||||||
).all(workerId, saturday) as LoanRow[];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function paidInstallments(db: Db, loanId: number): Promise<number> {
|
|
||||||
const row = await db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number };
|
|
||||||
return Number(row.n);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function recalcWeek(db: Db, weekId: number): Promise<void> {
|
export async function recalcWeek(db: Db, weekId: number): Promise<void> {
|
||||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
const env = await callCoreFn(db, "core.fn_payroll_week_recalc", { week_id: weekId });
|
||||||
id: number;
|
if (!env.ok) throwFromRpc(env);
|
||||||
week_start: string;
|
|
||||||
week_end: string;
|
|
||||||
status: string;
|
|
||||||
required_net: number;
|
|
||||||
tenant_id: number;
|
|
||||||
} | undefined;
|
|
||||||
if (!week || week.status === "paid") return;
|
|
||||||
const saturday = week.week_end;
|
|
||||||
const sheets = await db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as {
|
|
||||||
id: number;
|
|
||||||
kind: string;
|
|
||||||
project_id: number | null;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
await db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)")
|
|
||||||
.run(weekId);
|
|
||||||
|
|
||||||
for (const sheet of sheets) {
|
|
||||||
if (sheet.kind === "obra" && sheet.project_id) {
|
|
||||||
const roster = await jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId);
|
|
||||||
for (const w of roster) {
|
|
||||||
const lineId = await sheetLine(db, sheet.id, w.id, null);
|
|
||||||
const att = await db.prepare(
|
|
||||||
`SELECT COUNT(*) AS n FROM attendance
|
|
||||||
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`,
|
|
||||||
).get(w.id, sheet.project_id, week.week_start, week.week_end) as { n: number };
|
|
||||||
const frozen = await db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number };
|
|
||||||
const wage = week.status === "assembled" && frozen.daily_wage > 0 ? frozen.daily_wage : w.daily_wage;
|
|
||||||
const gross = roundMoney(Number(att.n) * wage);
|
|
||||||
await db.prepare(
|
|
||||||
`UPDATE payroll_week_lines
|
|
||||||
SET days=?, daily_wage=?, amount=?, gross=?, payable_net=?
|
|
||||||
WHERE id=?`,
|
|
||||||
).run(Number(att.n), wage, gross, gross, gross, lineId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sheet.kind === "destajo" && sheet.project_id) {
|
|
||||||
const period = await db.prepare(
|
|
||||||
"SELECT id FROM destajo_periods WHERE week_id=?",
|
|
||||||
).get(weekId) as { id: number } | undefined;
|
|
||||||
if (!period) continue;
|
|
||||||
const cuts = await db.prepare(
|
|
||||||
`SELECT c.*, j.worker_id, j.unit_price, j.unit_code, j.concepto
|
|
||||||
FROM destajo_cut_lines c
|
|
||||||
JOIN destajo_jobs j ON j.id=c.job_id
|
|
||||||
WHERE c.period_id=? AND j.project_id=?`,
|
|
||||||
).all(period.id, sheet.project_id) as {
|
|
||||||
id: number;
|
|
||||||
worker_id: number;
|
|
||||||
qty_planned: number;
|
|
||||||
qty_actual: number;
|
|
||||||
qty_extra: number;
|
|
||||||
unit_price: number;
|
|
||||||
unit_code: string;
|
|
||||||
concepto: string;
|
|
||||||
}[];
|
|
||||||
const keep = new Set<number>();
|
|
||||||
for (const cut of cuts) {
|
|
||||||
const lineId = await sheetLine(db, sheet.id, cut.worker_id, cut.id);
|
|
||||||
keep.add(lineId);
|
|
||||||
const payableQty = roundMoney(
|
|
||||||
week.status === "draft" && cut.qty_actual === 0 && cut.qty_extra === 0
|
|
||||||
? cut.qty_planned
|
|
||||||
: cut.qty_actual + cut.qty_extra,
|
|
||||||
);
|
|
||||||
const gross = roundMoney(payableQty * cut.unit_price);
|
|
||||||
const requiredGross = roundMoney(cut.qty_planned * cut.unit_price);
|
|
||||||
await db.prepare(
|
|
||||||
`UPDATE payroll_week_lines
|
|
||||||
SET destajo_cut_line_id=?, qty_planned=?, qty_actual=?, qty_extra=?,
|
|
||||||
unit_price=?, unit_code=?, concepto=?, amount=?, gross=?, payable_net=?
|
|
||||||
WHERE id=?`,
|
|
||||||
).run(
|
|
||||||
cut.id,
|
|
||||||
cut.qty_planned,
|
|
||||||
cut.qty_actual,
|
|
||||||
cut.qty_extra,
|
|
||||||
cut.unit_price,
|
|
||||||
cut.unit_code,
|
|
||||||
cut.concepto,
|
|
||||||
requiredGross,
|
|
||||||
gross,
|
|
||||||
gross,
|
|
||||||
lineId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const stale = await db.prepare(
|
|
||||||
"SELECT id, destajo_cut_line_id FROM payroll_week_lines WHERE sheet_id=?",
|
|
||||||
).all(sheet.id) as { id: number; destajo_cut_line_id: number | null }[];
|
|
||||||
for (const row of stale) {
|
|
||||||
if (!keep.has(row.id)) await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sheet.kind === "admin") {
|
|
||||||
const lines = await db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as {
|
|
||||||
id: number;
|
|
||||||
amount: number;
|
|
||||||
discounts: number;
|
|
||||||
}[];
|
|
||||||
for (const line of lines) {
|
|
||||||
const gross = roundMoney(line.amount);
|
|
||||||
await db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run(
|
|
||||||
gross,
|
|
||||||
roundMoney(gross - line.discounts),
|
|
||||||
line.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const allLines = await db.prepare(
|
|
||||||
`SELECT l.id, l.worker_id, l.gross, l.discounts, l.payable_net
|
|
||||||
FROM payroll_week_lines l
|
|
||||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
|
||||||
WHERE s.week_id=?
|
|
||||||
ORDER BY l.gross DESC, l.id`,
|
|
||||||
).all(weekId) as { id: number; worker_id: number; gross: number; discounts: number; payable_net: number }[];
|
|
||||||
|
|
||||||
const byWorker = new Map<number, typeof allLines>();
|
|
||||||
for (const line of allLines) {
|
|
||||||
const list = byWorker.get(line.worker_id) ?? [];
|
|
||||||
list.push(line);
|
|
||||||
byWorker.set(line.worker_id, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [workerId, lines] of byWorker) {
|
|
||||||
const gross = roundMoney(lines.reduce((s, l) => s + l.gross - l.discounts, 0));
|
|
||||||
const loans = await dueLoansForSaturday(db, workerId, saturday);
|
|
||||||
let remainingNet = Math.max(0, gross);
|
|
||||||
let placed = false;
|
|
||||||
for (const loan of loans) {
|
|
||||||
if (remainingNet <= 0) break;
|
|
||||||
const n = await paidInstallments(db, loan.id) + 1;
|
|
||||||
const isLast = n >= (loan.installments_n || 1);
|
|
||||||
const want = isLast ? loan.balance : Math.min(loan.weekly_payment || loan.balance, loan.balance);
|
|
||||||
const take = roundMoney(Math.min(want, remainingNet));
|
|
||||||
if (take <= 0) continue;
|
|
||||||
remainingNet = roundMoney(remainingNet - take);
|
|
||||||
if (!placed) {
|
|
||||||
const host = lines[0];
|
|
||||||
const label = loanConditionLabel(loan, take, n);
|
|
||||||
await db.prepare(
|
|
||||||
`UPDATE payroll_week_lines SET loan_id=?, loan_discount=?, loan_label=?, payable_net=? WHERE id=?`,
|
|
||||||
).run(loan.id, take, label, roundMoney(host.gross - host.discounts - take), host.id);
|
|
||||||
placed = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!placed) {
|
|
||||||
for (const line of lines) {
|
|
||||||
await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
|
|
||||||
roundMoney(line.gross - line.discounts),
|
|
||||||
line.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const line of lines.slice(1)) {
|
|
||||||
await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
|
|
||||||
roundMoney(line.gross - line.discounts),
|
|
||||||
line.id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tot = await db.prepare(
|
|
||||||
`SELECT COALESCE(SUM(l.payable_net), 0) AS n
|
|
||||||
FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id
|
|
||||||
WHERE s.week_id=?`,
|
|
||||||
).get(weekId) as { n: number };
|
|
||||||
await db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
|
|
||||||
if (week.status === "draft") {
|
|
||||||
await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
|
|
||||||
await db.prepare(
|
|
||||||
`UPDATE payroll_week_lines SET required_net=payable_net
|
|
||||||
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
|
|
||||||
).run(weekId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prefillThuSat(db: Db, projectId: number, weekStart: string, workerId: number): Promise<void> {
|
|
||||||
for (const offset of [3, 4, 5]) {
|
|
||||||
const day = addDays(weekStart, offset);
|
|
||||||
if (await presentOnOtherProject(db, workerId, day, projectId)) continue;
|
|
||||||
const existing = await db.prepare(
|
|
||||||
"SELECT id, present FROM attendance WHERE worker_id=? AND project_id=? AND work_date=?",
|
|
||||||
).get(workerId, projectId, day) as { id: number; present: boolean } | undefined;
|
|
||||||
if (existing) continue;
|
|
||||||
await db.prepare(
|
|
||||||
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)",
|
|
||||||
).run(workerId, projectId, day);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
|
export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
|
||||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
const env = await callCoreFn(db, "core.fn_payroll_week_assemble", { week_id: weekId, today: nowIso });
|
||||||
id: number;
|
if (!env.ok) throwFromRpc(env);
|
||||||
week_start: string;
|
|
||||||
status: string;
|
|
||||||
tenant_id: number;
|
|
||||||
} | undefined;
|
|
||||||
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
|
|
||||||
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
|
|
||||||
if (!canAssembleWeek(week.week_start, nowIso)) {
|
|
||||||
throw Object.assign(
|
|
||||||
new Error("La nómina se arma el jueves o después. Aún no es jueves de esta semana."),
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const sheets = await db.prepare(
|
|
||||||
"SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'",
|
|
||||||
).all(weekId) as { id: number; project_id: number | null }[];
|
|
||||||
for (const sheet of sheets) {
|
|
||||||
if (!sheet.project_id) continue;
|
|
||||||
const roster = await jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId);
|
|
||||||
for (const w of roster) {
|
|
||||||
const assigned = await db.prepare(
|
|
||||||
"SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=true",
|
|
||||||
).get(w.id, sheet.project_id) as { ok: number } | undefined;
|
|
||||||
if (!assigned) continue;
|
|
||||||
if (w.work_type !== "N") {
|
|
||||||
const hasLine = await db.prepare(
|
|
||||||
"SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?",
|
|
||||||
).get(sheet.id, w.id);
|
|
||||||
if (!hasLine) continue;
|
|
||||||
}
|
|
||||||
await prefillThuSat(db, sheet.project_id, week.week_start, w.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
|
|
||||||
if (period) {
|
|
||||||
await db.prepare(
|
|
||||||
`UPDATE destajo_cut_lines SET qty_actual = qty_planned
|
|
||||||
WHERE period_id=? AND qty_actual=0 AND qty_planned>0`,
|
|
||||||
).run(period.id);
|
|
||||||
}
|
|
||||||
await db.prepare(
|
|
||||||
"UPDATE payroll_weeks SET status='assembled', assembled_at=now() WHERE id=?",
|
|
||||||
).run(weekId);
|
|
||||||
await recalcWeek(db, weekId);
|
|
||||||
const payable = await db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number };
|
|
||||||
await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId);
|
|
||||||
await db.prepare(
|
|
||||||
`UPDATE payroll_week_lines SET required_net=payable_net
|
|
||||||
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
|
|
||||||
).run(weekId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function payWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
|
export async function payWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
|
||||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
|
const env = await callCoreFn(db, "core.fn_payroll_week_pay", { week_id: weekId, today: nowIso });
|
||||||
id: number;
|
if (!env.ok) throwFromRpc(env);
|
||||||
week_end: string;
|
|
||||||
status: string;
|
|
||||||
} | undefined;
|
|
||||||
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
|
|
||||||
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
|
|
||||||
if (week.status !== "assembled") {
|
|
||||||
await assembleWeek(db, weekId, nowIso);
|
|
||||||
}
|
|
||||||
await recalcWeek(db, weekId);
|
|
||||||
const lines = await db.prepare(
|
|
||||||
`SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
|
|
||||||
FROM payroll_week_lines l
|
|
||||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
|
||||||
WHERE s.week_id=? AND l.loan_id IS NOT NULL AND l.loan_discount > 0`,
|
|
||||||
).all(weekId) as { loan_id: number; loan_discount: number; loan_label: string; worker_id: number }[];
|
|
||||||
for (const line of lines) {
|
|
||||||
const loan = await db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined;
|
|
||||||
if (!loan || loan.balance <= 0) continue;
|
|
||||||
const take = roundMoney(Math.min(line.loan_discount, loan.balance));
|
|
||||||
if (take <= 0) continue;
|
|
||||||
const n = await paidInstallments(db, loan.id) + 1;
|
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
).run(loan.id, weekId, take, n, line.loan_label);
|
|
||||||
await db.prepare("UPDATE loans SET balance = ROUND((balance - ?)::numeric, 2) WHERE id=?").run(take, loan.id);
|
|
||||||
}
|
|
||||||
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
|
|
||||||
if (period) {
|
|
||||||
const jobs = await db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as {
|
|
||||||
id: number;
|
|
||||||
qty_total_estimated: number;
|
|
||||||
}[];
|
|
||||||
for (const job of jobs) {
|
|
||||||
const paid = await db.prepare(
|
|
||||||
`SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
|
|
||||||
FROM destajo_cut_lines c
|
|
||||||
JOIN destajo_periods p ON p.id=c.period_id
|
|
||||||
JOIN payroll_weeks w ON w.id=p.week_id
|
|
||||||
WHERE c.job_id=? AND (w.status='paid' OR w.id=?)`,
|
|
||||||
).get(job.id, weekId) as { n: number };
|
|
||||||
if (Number(paid.n) >= job.qty_total_estimated) {
|
|
||||||
await db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await db.prepare(
|
|
||||||
"UPDATE payroll_weeks SET status='paid', paid_at=now() WHERE id=?",
|
|
||||||
).run(weekId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise<void> {
|
export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise<void> {
|
||||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
|
const env = await callCoreFn(db, "core.fn_payroll_jornal_worker_add", { week_id: weekId, project_id: projectId, worker_id: workerId });
|
||||||
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
|
if (!env.ok) throwFromRpc(env);
|
||||||
const sheet = await db.prepare(
|
|
||||||
"SELECT id FROM payroll_sheets WHERE week_id=? AND kind='obra' AND project_id=?",
|
|
||||||
).get(weekId, projectId) as { id: number } | undefined;
|
|
||||||
if (!sheet) throw Object.assign(new Error("Hoja de obra no encontrada"), { status: 404 });
|
|
||||||
await sheetLine(db, sheet.id, workerId, null);
|
|
||||||
await recalcWeek(db, weekId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addAdminLine(
|
export async function addAdminLine(db: Db, weekId: number, body: { worker_id: number; amount: number; project_id?: number | null }): Promise<void> {
|
||||||
db: Db,
|
const env = await callCoreFn(db, "core.fn_payroll_admin_line_add", { week_id: weekId, ...body });
|
||||||
weekId: number,
|
if (!env.ok) throwFromRpc(env);
|
||||||
body: { worker_id: number; amount: number; project_id?: number | null },
|
|
||||||
): Promise<void> {
|
|
||||||
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
|
|
||||||
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
|
|
||||||
const sheet = await db.prepare(
|
|
||||||
"SELECT id FROM payroll_sheets WHERE week_id=? AND kind='admin' AND project_id IS NULL",
|
|
||||||
).get(weekId) as { id: number } | undefined;
|
|
||||||
if (!sheet) throw Object.assign(new Error("Hoja administrativa no encontrada"), { status: 404 });
|
|
||||||
const lineId = await sheetLine(db, sheet.id, body.worker_id, null);
|
|
||||||
await db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run(
|
|
||||||
roundMoney(body.amount),
|
|
||||||
body.project_id ?? null,
|
|
||||||
roundMoney(body.amount),
|
|
||||||
roundMoney(body.amount),
|
|
||||||
lineId,
|
|
||||||
);
|
|
||||||
await recalcWeek(db, weekId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise<void> {
|
export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise<void> {
|
||||||
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
|
const env = await callCoreFn(db, "core.fn_payroll_admin_line_update", { week_id: weekId, line_id: lineId, amount });
|
||||||
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
|
if (!env.ok) throwFromRpc(env);
|
||||||
await db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId);
|
|
||||||
await recalcWeek(db, weekId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise<void> {
|
export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise<void> {
|
||||||
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
|
const env = await callCoreFn(db, "core.fn_payroll_admin_line_remove", { week_id: weekId, line_id: lineId });
|
||||||
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
|
if (!env.ok) throwFromRpc(env);
|
||||||
await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId);
|
|
||||||
await recalcWeek(db, weekId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function jobRemainder(db: Db, jobId: number): Promise<number> {
|
export async function createDestajoJob(db: Db, tenantId: number, weekId: number, body: Record<string, unknown>): Promise<{ id: number }> {
|
||||||
const job = await db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as
|
const env = await callCoreFn<{ id: number }>(db, "core.fn_destajo_job_create", { tenant_id: tenantId, week_id: weekId, ...body });
|
||||||
| { qty_total_estimated: number }
|
if (!env.ok) throwFromRpc(env);
|
||||||
| undefined;
|
return { id: Number(env.data?.id) };
|
||||||
if (!job) return 0;
|
|
||||||
const paid = await db.prepare(
|
|
||||||
`SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
|
|
||||||
FROM destajo_cut_lines c
|
|
||||||
JOIN destajo_periods p ON p.id=c.period_id
|
|
||||||
JOIN payroll_weeks w ON w.id=p.week_id
|
|
||||||
WHERE c.job_id=? AND w.status='paid'`,
|
|
||||||
).get(jobId) as { n: number };
|
|
||||||
return roundMoney(Math.max(0, job.qty_total_estimated - Number(paid.n)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDestajoJob(
|
export async function patchDestajoCut(db: Db, weekId: number, cutId: number, patch: Record<string, unknown>): Promise<void> {
|
||||||
db: Db,
|
const env = await callCoreFn(db, "core.fn_destajo_cut_patch", { week_id: weekId, cut_id: cutId, ...patch });
|
||||||
tenantId: number,
|
if (!env.ok) throwFromRpc(env);
|
||||||
weekId: number,
|
|
||||||
body: {
|
|
||||||
project_id: number;
|
|
||||||
worker_id: number;
|
|
||||||
concepto: string;
|
|
||||||
unit_code: string;
|
|
||||||
qty_total_estimated: number;
|
|
||||||
unit_price: number;
|
|
||||||
qty_planned?: number;
|
|
||||||
qty_extra?: number;
|
|
||||||
},
|
|
||||||
): Promise<{ id: number }> {
|
|
||||||
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
|
|
||||||
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
|
|
||||||
const concepto = body.concepto.trim();
|
|
||||||
if (!concepto) throw Object.assign(new Error("Concepto requerido"), { status: 400 });
|
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO destajo_jobs
|
|
||||||
(project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
).run(
|
|
||||||
body.project_id,
|
|
||||||
body.worker_id,
|
|
||||||
concepto,
|
|
||||||
body.unit_code,
|
|
||||||
body.qty_total_estimated,
|
|
||||||
body.unit_price,
|
|
||||||
tenantId,
|
|
||||||
);
|
|
||||||
const jobId = await db.lastInsertId();
|
|
||||||
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number };
|
|
||||||
await db.prepare(
|
|
||||||
`INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
|
|
||||||
VALUES (?, ?, ?, 0, ?)`,
|
|
||||||
).run(period.id, jobId, body.qty_planned ?? 0, body.qty_extra ?? 0);
|
|
||||||
await recalcWeek(db, weekId);
|
|
||||||
return { id: jobId };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patchDestajoCut(
|
|
||||||
db: Db,
|
|
||||||
weekId: number,
|
|
||||||
cutId: number,
|
|
||||||
patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number },
|
|
||||||
): Promise<void> {
|
|
||||||
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
|
|
||||||
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
|
|
||||||
const cut = await db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as {
|
|
||||||
qty_planned: number;
|
|
||||||
qty_actual: number;
|
|
||||||
qty_extra: number;
|
|
||||||
} | undefined;
|
|
||||||
if (!cut) throw Object.assign(new Error("Partida no encontrada"), { status: 404 });
|
|
||||||
const planned = patch.qty_planned ?? cut.qty_planned;
|
|
||||||
const actual = patch.qty_actual ?? cut.qty_actual;
|
|
||||||
const extra = patch.qty_extra ?? cut.qty_extra;
|
|
||||||
await db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run(
|
|
||||||
planned,
|
|
||||||
actual,
|
|
||||||
extra,
|
|
||||||
cutId,
|
|
||||||
);
|
|
||||||
await recalcWeek(db, weekId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureCutForOpenJobs(db: Db, weekId: number): Promise<void> {
|
|
||||||
const period = await db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as
|
|
||||||
| { id: number; tenant_id: number }
|
|
||||||
| undefined;
|
|
||||||
if (!period) return;
|
|
||||||
const jobs = await db.prepare(
|
|
||||||
"SELECT id FROM destajo_jobs WHERE tenant_id=? AND status='open'",
|
|
||||||
).all(period.tenant_id) as { id: number }[];
|
|
||||||
const ins = db.prepare(
|
|
||||||
`INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
|
|
||||||
VALUES (?, ?, 0, 0, 0)
|
|
||||||
ON CONFLICT(period_id, job_id) DO NOTHING`,
|
|
||||||
);
|
|
||||||
for (const job of jobs) await ins.run(period.id, job.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
type AttCell = {
|
|
||||||
date: string;
|
|
||||||
present: boolean;
|
|
||||||
other_project_id: number | null;
|
|
||||||
other_project_name: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) {
|
export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) {
|
||||||
const weekId = await ensureWeek(db, tenantId, weekStart);
|
await ensureWeek(db, tenantId, weekStart);
|
||||||
await ensureCutForOpenJobs(db, weekId);
|
const env = await callCoreFn<Record<string, unknown>>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart });
|
||||||
await recalcWeek(db, weekId);
|
if (!env.ok) throwFromRpc(env);
|
||||||
const week = await db.prepare(
|
const data = env.data ?? {};
|
||||||
`SELECT * FROM payroll_weeks WHERE id=?`,
|
|
||||||
).get(weekId) as Record<string, unknown>;
|
|
||||||
const period = await db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId);
|
|
||||||
const start = String(week.week_start);
|
|
||||||
const end = String(week.week_end);
|
|
||||||
const days = weekDays(start);
|
|
||||||
const sheetsRaw = await db.prepare(
|
|
||||||
`SELECT s.*, p.name AS project_name, p.code AS project_code
|
|
||||||
FROM payroll_sheets s
|
|
||||||
LEFT JOIN projects p ON p.id=s.project_id
|
|
||||||
WHERE s.week_id=?
|
|
||||||
ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name`,
|
|
||||||
).all(weekId) as Record<string, unknown>[];
|
|
||||||
|
|
||||||
const attRows = await db.prepare(
|
|
||||||
`SELECT a.worker_id, a.project_id, a.work_date, a.present, p.name AS project_name
|
|
||||||
FROM attendance a JOIN projects p ON p.id=a.project_id
|
|
||||||
WHERE a.work_date BETWEEN ? AND ?`,
|
|
||||||
).all(start, end) as {
|
|
||||||
worker_id: number;
|
|
||||||
project_id: number;
|
|
||||||
work_date: string;
|
|
||||||
present: boolean;
|
|
||||||
project_name: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
const sheets = [];
|
|
||||||
for (const sheet of sheetsRaw) {
|
|
||||||
const lines = await db.prepare(
|
|
||||||
`SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.position, w.work_type
|
|
||||||
FROM payroll_week_lines l
|
|
||||||
JOIN workers w ON w.id=l.worker_id
|
|
||||||
WHERE l.sheet_id=?
|
|
||||||
ORDER BY w.last_name_p, w.first_name, l.id`,
|
|
||||||
).all(Number(sheet.id)) as Record<string, unknown>[];
|
|
||||||
const withCells = lines.map((line) => {
|
|
||||||
const cells: AttCell[] = days.map((date) => {
|
|
||||||
const mine = attRows.find((a) =>
|
|
||||||
a.worker_id === line.worker_id && a.project_id === sheet.project_id && a.work_date === date
|
|
||||||
);
|
|
||||||
const other = attRows.find((a) =>
|
|
||||||
a.worker_id === line.worker_id && a.present === true && a.project_id !== sheet.project_id && a.work_date === date
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
present: Boolean(mine?.present),
|
|
||||||
other_project_id: other && !mine?.present ? other.project_id : null,
|
|
||||||
other_project_name: other && !mine?.present ? other.project_name : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return { ...line, cells };
|
|
||||||
});
|
|
||||||
sheets.push({ ...sheet, lines: withCells });
|
|
||||||
}
|
|
||||||
|
|
||||||
const jobs = await db.prepare(
|
|
||||||
`SELECT j.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name
|
|
||||||
FROM destajo_jobs j
|
|
||||||
JOIN workers w ON w.id=j.worker_id
|
|
||||||
JOIN projects p ON p.id=j.project_id
|
|
||||||
WHERE j.tenant_id=?
|
|
||||||
ORDER BY j.status, j.id DESC`,
|
|
||||||
).all(tenantId) as Record<string, unknown>[];
|
|
||||||
const jobsOut = [];
|
|
||||||
for (const job of jobs) jobsOut.push({ ...job, remainder: await jobRemainder(db, Number(job.id)) });
|
|
||||||
const cuts = period
|
|
||||||
? await db.prepare(
|
|
||||||
`SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id,
|
|
||||||
j.status AS job_status, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name
|
|
||||||
FROM destajo_cut_lines c
|
|
||||||
JOIN destajo_jobs j ON j.id=c.job_id
|
|
||||||
JOIN workers w ON w.id=j.worker_id
|
|
||||||
JOIN projects p ON p.id=j.project_id
|
|
||||||
WHERE c.period_id=?
|
|
||||||
ORDER BY p.name, w.last_name_p`,
|
|
||||||
).all((period as { id: number }).id)
|
|
||||||
: [];
|
|
||||||
const settings = await getSettings(db, tenantId);
|
|
||||||
const units = await listUnits(db, tenantId);
|
|
||||||
const required = Number(week.required_net) || 0;
|
|
||||||
const payable = Number(week.payable_net) || 0;
|
|
||||||
const recoveryRow = await db.prepare(
|
|
||||||
`SELECT COALESCE(SUM(l.loan_discount), 0) AS n
|
|
||||||
FROM payroll_week_lines l
|
|
||||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
|
||||||
WHERE s.week_id=?`,
|
|
||||||
).get(weekId) as { n: number };
|
|
||||||
const loanRecovery = roundMoney(Number(recoveryRow.n));
|
|
||||||
const faltante = roundMoney(Math.max(0, payable - required));
|
|
||||||
const today = await resolveToday(db, tenantId);
|
const today = await resolveToday(db, tenantId);
|
||||||
return {
|
const start = String((data.week as Record<string, unknown>)?.week_start ?? weekStart);
|
||||||
week,
|
return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) };
|
||||||
destajo_period: period,
|
|
||||||
sheets,
|
|
||||||
destajo_jobs: jobsOut,
|
|
||||||
destajo_cuts: cuts,
|
|
||||||
settings,
|
|
||||||
units,
|
|
||||||
days,
|
|
||||||
board: {
|
|
||||||
required_net: required,
|
|
||||||
payable_net: payable,
|
|
||||||
loan_recovery: loanRecovery,
|
|
||||||
faltante,
|
|
||||||
delta: roundMoney(required - payable),
|
|
||||||
},
|
|
||||||
can_assemble: canAssembleWeek(start, today),
|
|
||||||
assemble_from: weekAssembleDate(start),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function weekCsv(db: Db, weekId: number): Promise<string> {
|
export async function weekCsv(db: Db, weekId: number): Promise<string> {
|
||||||
const lines = await db.prepare(
|
const env = await callCoreFn<{ csv: string }>(db, "core.fn_payroll_week_csv", { week_id: weekId });
|
||||||
`SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name,
|
if (!env.ok) throwFromRpc(env);
|
||||||
l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code,
|
return String(env.data?.csv ?? "");
|
||||||
l.gross, l.discounts, l.loan_discount, l.loan_label, l.payable_net, l.required_net
|
|
||||||
FROM payroll_week_lines l
|
|
||||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
|
||||||
JOIN workers w ON w.id=l.worker_id
|
|
||||||
LEFT JOIN projects p ON p.id=s.project_id
|
|
||||||
WHERE s.week_id=?
|
|
||||||
ORDER BY s.kind, p.name, w.last_name_p`,
|
|
||||||
).all(weekId) as Record<string, unknown>[];
|
|
||||||
const header =
|
|
||||||
"Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar";
|
|
||||||
const rows = lines.map((l) =>
|
|
||||||
[
|
|
||||||
l.first_name,
|
|
||||||
l.last_name_p,
|
|
||||||
l.kind,
|
|
||||||
l.project_name ?? "",
|
|
||||||
l.days,
|
|
||||||
l.daily_wage,
|
|
||||||
l.concepto ?? "",
|
|
||||||
Number(l.qty_actual || 0) + Number(l.qty_extra || 0),
|
|
||||||
l.unit_code ?? "",
|
|
||||||
l.gross,
|
|
||||||
l.discounts,
|
|
||||||
l.loan_discount,
|
|
||||||
String(l.loan_label ?? "").replaceAll(",", " "),
|
|
||||||
l.required_net,
|
|
||||||
l.payable_net,
|
|
||||||
].join(",")
|
|
||||||
);
|
|
||||||
return [header, ...rows].join("\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOpenWeeks(db: Db, tenantId: number) {
|
export async function listOpenWeeks(db: Db, tenantId: number) {
|
||||||
return await db.prepare(
|
const env = await callCoreFn<{ weeks: unknown[] }>(db, "core.fn_payroll_week_list_open", { tenant_id: tenantId });
|
||||||
`SELECT w.*,
|
if (!env.ok) throwFromRpc(env);
|
||||||
(SELECT COALESCE(SUM(l.loan_discount), 0)
|
return env.data?.weeks ?? [];
|
||||||
FROM payroll_week_lines l
|
|
||||||
JOIN payroll_sheets s ON s.id=l.sheet_id
|
|
||||||
WHERE s.week_id=w.id) AS loan_recovery
|
|
||||||
FROM payroll_weeks w
|
|
||||||
WHERE w.tenant_id=? AND w.status != 'paid'
|
|
||||||
ORDER BY w.week_start DESC`,
|
|
||||||
).all(tenantId);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
|
`scripts/bootstrap-admin.ts` (ver Fase 4 del plan de migración), no con
|
||||||
lógica de seed en el arranque de la API ni con datos hardcodeados en
|
lógica de seed en el arranque de la API ni con datos hardcodeados en
|
||||||
Liquibase.
|
Liquibase.
|
||||||
|
|
||||||
|
## Funciones RPC (`core.fn_*`)
|
||||||
|
|
||||||
|
A partir del changeset `006-rpc-infra.sql`, el esquema `core` expone la
|
||||||
|
lógica de negocio como funciones PostgreSQL invocadas desde la API con
|
||||||
|
`SELECT core.fn_nombre($1::jsonb)`. La capa TypeScript **no** debe usar
|
||||||
|
`db.prepare()` contra tablas de `core` en rutas de negocio; solo
|
||||||
|
[`api/rpc.ts`](../api/rpc.ts) ejecuta el `SELECT` del RPC.
|
||||||
|
|
||||||
|
### Envelope de respuesta (BD)
|
||||||
|
|
||||||
|
Toda función devuelve un `jsonb` con esta forma:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"code": "OK",
|
||||||
|
"layer": "db",
|
||||||
|
"message": "Mensaje detallado en español",
|
||||||
|
"context": { "fn": "fn_empresa_get", "id": 5 },
|
||||||
|
"data": { },
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `code`: código de negocio (`OK`, `CREATED`, `VALIDATION`, `NOT_FOUND`,
|
||||||
|
`CONFLICT`, `INTERNAL`, …).
|
||||||
|
- `layer`: siempre `"db"` desde Postgres.
|
||||||
|
- `message`: texto legible y específico (nunca genérico).
|
||||||
|
- `context`: metadatos seguros para depuración (nombre de función, ids).
|
||||||
|
- `data`: payload de éxito; `errors`: mapa de campos en validación.
|
||||||
|
|
||||||
|
Helpers en `006-rpc-infra.sql`: `rpc_ok`, `rpc_err`, `rpc_created`,
|
||||||
|
`rpc_from_exception`.
|
||||||
|
|
||||||
|
### Contrato API
|
||||||
|
|
||||||
|
[`api/http_errors.ts`](../api/http_errors.ts) mapea `code` → HTTP y añade
|
||||||
|
`status` al body. `respondRpc()` preserva `layer` y `message` de la BD;
|
||||||
|
`respondApiError()` construye envelopes de capa `"api"`.
|
||||||
|
|
||||||
|
### Convenciones al añadir funciones
|
||||||
|
|
||||||
|
1. Un solo parámetro `payload jsonb`.
|
||||||
|
2. Prefijo `fn_` para funciones que devuelven envelope.
|
||||||
|
3. `SECURITY INVOKER` y `SET search_path = core`.
|
||||||
|
4. Errores de negocio con `RETURN core.rpc_err(...)`; constraints de
|
||||||
|
Postgres capturados en `EXCEPTION` y traducidos a mensajes claros.
|
||||||
|
5. `GRANT EXECUTE ... TO panels_core_app` en el mismo changeset.
|
||||||
|
6. Comentario de ejemplo `SELECT core.fn_*(...)` en el SQL.
|
||||||
|
|
||||||
|
Changesets RPC: `006` (infra) … `015` (documentos). Ver
|
||||||
|
[`changelog-master.xml`](core/changelog-master.xml).
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,15 @@
|
||||||
<include file="changesets/003-seed-catalogs.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/003-seed-catalogs.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/004-seed-dev-data.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/004-seed-dev-data.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/005-rls.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/005-rls.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/006-rpc-infra.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/007-rpc-catalogs.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/008-rpc-companies.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/009-rpc-projects.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/010-rpc-workers.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/011-rpc-budget.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/012-rpc-badges.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/013-rpc-payroll.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/014-rpc-import.sql" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changesets/015-rpc-documents.sql" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
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