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