diff --git a/api/budget.ts b/api/budget.ts index 38eca1a..8dc7311 100644 --- a/api/budget.ts +++ b/api/budget.ts @@ -801,10 +801,49 @@ const FORMAT_LABEL: Record = { none: "No reconocido", }; +export const EXCEL_ONLY_MESSAGE = "Solo se aceptan archivos Excel (.xlsx o .xls)."; +export const BUDGET_FORMAT_MESSAGE = + "El Excel no tiene el formato de presupuesto esperado. Use la plantilla del panel o un archivo Opus/Neodata con columnas CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."; + +export function excelFileTypeError(filename: string): string | null { + const name = filename.trim(); + if (/\.(xlsx|xls)$/i.test(name)) return null; + const shown = name || "sin nombre"; + return `${EXCEL_ONLY_MESSAGE} «${shown}» no es un Excel.`; +} + +export function budgetReadErrorMessage(preview: { errors: { messages: string[] }[] }): string { + return preview.errors[0]?.messages?.[0] || BUDGET_FORMAT_MESSAGE; +} + +function startsWith(bytes: Uint8Array, sig: number[]): boolean { + if (bytes.length < sig.length) return false; + return sig.every((value, i) => bytes[i] === value); +} + +function unrecognizedBinaryMessage(bytes: Uint8Array): string | null { + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46])) { + return "El archivo es un PDF, no un Excel. Suba un .xlsx o .xls con el formato de presupuesto."; + } + if (startsWith(bytes, [0xff, 0xd8, 0xff]) || startsWith(bytes, [0x89, 0x50, 0x4e, 0x47])) { + return "El archivo es una imagen, no un Excel. Suba un .xlsx o .xls."; + } + return null; +} + function readBudgetSheet(bytes: Uint8Array): { sheet: string; rows: unknown[][] } | { error: string } { - const wb = XLSX.read(bytes, { type: "array" }); + const binaryError = unrecognizedBinaryMessage(bytes); + if (binaryError) return { error: binaryError }; + let wb: XLSX.WorkBook; + try { + wb = XLSX.read(bytes, { type: "array" }); + } catch { + return { + error: "No se pudo abrir el archivo. Suba un Excel .xlsx o .xls con el formato de presupuesto (plantilla del panel u Opus/Neodata).", + }; + } const sheet = wb.SheetNames.find((name) => /presupuesto/i.test(name)) || wb.SheetNames[0]; - if (!sheet) return { error: "El archivo no tiene hojas" }; + if (!sheet) return { error: "El archivo Excel no tiene hojas. Revise que no esté vacío o dañado." }; return { sheet, rows: XLSX.utils.sheet_to_json(wb.Sheets[sheet], { header: 1, defval: "" }) as unknown[][] }; } @@ -863,7 +902,7 @@ export function previewBudgetExcel(bytes: Uint8Array): BudgetPreview { const iva = Math.round(subtotal * BUDGET_IVA * 100) / 100; const errors = parsed.items.length ? [] - : [{ row: 0, messages: ["No se encontraron partidas. Revise que el Excel tenga Código/Concepto o la plantilla WBS."] }]; + : [{ row: 0, messages: [BUDGET_FORMAT_MESSAGE] }]; return { format: parsed.format, format_label: FORMAT_LABEL[parsed.format], @@ -902,7 +941,7 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes: chapters: 0, inserted: 0, skipped: parsed.skipped, - errors: [{ row: 0, messages: ["No se encontraron partidas. Use la plantilla o un Excel con CLAVE, DESCRIPCION, UNIDAD, CANTIDAD y PRECIO."] }], + errors: [{ row: 0, messages: [BUDGET_FORMAT_MESSAGE] }], }; } await replaceBudgetFromParsed(database, projectId, parsed); diff --git a/api/budget_test.ts b/api/budget_test.ts index b728114..a946ec1 100644 --- a/api/budget_test.ts +++ b/api/budget_test.ts @@ -1,6 +1,13 @@ import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1"; import * as XLSX from "xlsx"; -import { buildBudgetTemplate, parseBudgetRows, parseMoney, previewBudgetExcel } from "./budget.ts"; +import { + BUDGET_FORMAT_MESSAGE, + buildBudgetTemplate, + excelFileTypeError, + parseBudgetRows, + parseMoney, + previewBudgetExcel, +} from "./budget.ts"; Deno.test("parsea partidas estilo CLAVE DESCRIPCION UNIDAD", () => { const parsed = parseBudgetRows([ @@ -96,3 +103,25 @@ Deno.test("preview de plantilla no guarda y arma arbol", () => { assertEquals(preview.tree[0]?.children.length, 2); assertEquals(preview.totals.item_count, 4); }); + +Deno.test("rechaza archivos que no son Excel por nombre", () => { + assertEquals(excelFileTypeError("obra.pdf")?.includes(".xlsx"), true); + assertEquals(excelFileTypeError("notas.docx")?.includes("«notas.docx»"), true); + assertEquals(excelFileTypeError("presupuesto.xlsx"), null); + assertEquals(excelFileTypeError("PRESUPUESTO.XLS"), null); +}); + +Deno.test("preview de PDF explica que no es Excel", () => { + const preview = previewBudgetExcel(new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34])); + assertEquals(preview.totals.item_count, 0); + assertStringIncludes(preview.errors[0]?.messages[0] || "", "PDF"); +}); + +Deno.test("preview de Excel sin columnas de presupuesto explica el formato", () => { + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([["Nombre", "Notas"], ["Juan", "sin partidas"]]), "Hoja1"); + const bytes = XLSX.write(wb, { type: "array", bookType: "xlsx" }); + const preview = previewBudgetExcel(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)); + assertEquals(preview.totals.item_count, 0); + assertEquals(preview.errors[0]?.messages[0], BUDGET_FORMAT_MESSAGE); +}); diff --git a/api/cost_modules_test.ts b/api/cost_modules_test.ts index 735b684..ce4a2b9 100644 --- a/api/cost_modules_test.ts +++ b/api/cost_modules_test.ts @@ -202,6 +202,59 @@ Deno.test("RPC almacén: central, obra, entrada, transferencia, salida", async ( global: true, }); assertEquals(globalStock.ok, true); + const consolidated = (globalStock.data as { consolidated: Array<{ material_id: number; qty_on_hand: number }> }).consolidated; + const cem = consolidated.find((row) => Number(row.material_id) === materialId); + assertExists(cem); + assertEquals(Number(cem.qty_on_hand), 45); + + await db.prepare( + `INSERT INTO projects (code, name, status, theme_id, company_id, tenant_id) + VALUES ('COST-0002', 'Obra sin almacén', 'activo', 'arctec-dos-logos-fold', ?, ?)`, + ).run(fx.companyId, TENANT_ID); + const listedAgain = await callCoreFn(db, "core.fn_warehouse_list", base); + const warehouses = (listedAgain.data as { warehouses: Array<{ kind: string; project_id?: number }> }).warehouses; + assertEquals(warehouses.filter((w) => w.kind === "central").length, 1); + assertEquals(warehouses.filter((w) => w.kind === "project").length, 1); + + const supplier = await callCoreFn(db, "core.fn_supplier_create", { + tenant_id: TENANT_ID, + name: "Cemex Test", + }); + assertEquals(supplier.ok, true); + const supplierId = Number(((supplier.data as { supplier: { id: number } }).supplier).id); + + const units = await callCoreFn(db, "core.fn_measure_unit_list", base); + assertEquals(units.ok, true); + const bulto = ((units.data as { units: Array<{ id: number; code: string }> }).units) + .find((u) => u.code === "bulto"); + assertExists(bulto); + + const catalog = await callCoreFn(db, "core.fn_warehouse_material_upsert", { + ...base, + code: "VAR001", + name: "Varilla 3/8", + unit_id: bulto.id, + supplier_ids: [supplierId], + }); + assertEquals(catalog.ok, true); + const listedMats = await callCoreFn(db, "core.fn_warehouse_material_list", base); + assertEquals(listedMats.ok, true); + const mats = (listedMats.data as { materials: Array<{ code: string; suppliers: Array<{ id: number }> }> }).materials; + const varilla = mats.find((m) => m.code === "VAR001"); + assertExists(varilla); + assertEquals(varilla.suppliers.length, 1); + + const closed = await callCoreFn(db, "core.fn_warehouse_close_project", { + project_id: fx.projectId, + transfer_remaining: true, + closed_by_name: "Tester", + close_notes: "Cierre de prueba", + }); + assertEquals(closed.ok, true); + const afterClose = await callCoreFn(db, "core.fn_warehouse_list", base); + const openProjectWh = ((afterClose.data as { warehouses: Array<{ kind: string; status: string }> }).warehouses) + .filter((w) => w.kind === "project" && w.status === "open"); + assertEquals(openProjectWh.length, 0); }); }); @@ -233,6 +286,37 @@ Deno.test("RPC control presupuestal: summary, items, chapters, deviations", asyn }); }); +Deno.test("RPC catálogos: proveedores y unidades de medida", async () => { + await withTestDb(async (db) => { + const created = await callCoreFn(db, "core.fn_supplier_create", { + tenant_id: TENANT_ID, + name: "Home Depot", + rfc: "HDM123456ABC", + phone: "9980000000", + }); + assertEquals(created.ok, true, `crear proveedor: ${JSON.stringify(created)}`); + const supplier = (created.data as { supplier: { id: number; rfc: string } }).supplier; + assertEquals(supplier.rfc, "HDM123456ABC"); + + const listed = await callCoreFn(db, "core.fn_supplier_list", { tenant_id: TENANT_ID, status: "activo" }); + assertEquals(listed.ok, true); + const suppliers = (listed.data as { suppliers: { name: string }[] }).suppliers; + assert(suppliers.some((s) => s.name === "Home Depot")); + + const units = await callCoreFn(db, "core.fn_measure_unit_list", { tenant_id: TENANT_ID }); + assertEquals(units.ok, true); + const unitRows = (units.data as { units: { code: string }[] }).units; + assert(unitRows.some((u) => u.code === "bulto")); + + const extra = await callCoreFn(db, "core.fn_measure_unit_add", { + tenant_id: TENANT_ID, + code: "tinaco", + label: "Tinaco", + }); + assertEquals(extra.ok, true); + }); +}); + Deno.test("RPC IAM v2: roles y permisos por role_id", async () => { const url = Deno.env.get("DATABASE_URL_IAM"); if (!url) throw new Error("DATABASE_URL_IAM requerido"); diff --git a/api/document_rpc.ts b/api/document_rpc.ts new file mode 100644 index 0000000..7634a94 --- /dev/null +++ b/api/document_rpc.ts @@ -0,0 +1,10 @@ +/** + * Payload para core.fn_*_document_get. + * Las RPC leen `doc_id`; historicamente la API enviaba `id` y el documento + * nunca se encontraba (mensaje "documento id= no encontrado"). + */ +export function normalizeDocumentGetPayload( + payload: Record, +): Record { + return { ...payload, doc_id: payload.doc_id ?? payload.id }; +} diff --git a/api/document_rpc_test.ts b/api/document_rpc_test.ts new file mode 100644 index 0000000..8bcec8d --- /dev/null +++ b/api/document_rpc_test.ts @@ -0,0 +1,13 @@ +import { assertEquals } from "jsr:@std/assert@1"; +import { normalizeDocumentGetPayload } from "./document_rpc.ts"; + +Deno.test("document get payload usa doc_id cuando la API envía id", () => { + const payload = normalizeDocumentGetPayload({ project_id: 1, id: 42 }); + assertEquals(payload.doc_id, 42); + assertEquals(payload.project_id, 1); +}); + +Deno.test("document get payload respeta doc_id si ya viene", () => { + const payload = normalizeDocumentGetPayload({ company_id: 3, doc_id: 7, id: 99 }); + assertEquals(payload.doc_id, 7); +}); diff --git a/api/document_validity.ts b/api/document_validity.ts index 8ecfc39..4dbeddb 100644 --- a/api/document_validity.ts +++ b/api/document_validity.ts @@ -82,18 +82,10 @@ export function evaluateDocumentValidity( const issuedAt = doc.issued_at; const expiresAt = doc.expires_at; + const uploadedAt = doc.uploaded_at; const needFresh = freshnessRequired(ctx); if (policy.validity_mode === "expiry") { - if (policy.requires_expires_at && !expiresAt) { - return { - present: true, - valid: false, - validity_status: "missing_dates", - issued_at: issuedAt, - expires_at: expiresAt, - }; - } const exp = parseDay(expiresAt); const todayDate = parseDay(today); if (exp && todayDate && exp < todayDate) { @@ -115,20 +107,11 @@ export function evaluateDocumentValidity( } if (policy.validity_mode === "freshness") { - if (policy.requires_issued_at && !issuedAt) { - return { - present: true, - valid: false, - validity_status: "missing_dates", - issued_at: issuedAt, - expires_at: expiresAt, - }; - } - if (needFresh && issuedAt) { - const issued = parseDay(issuedAt); + if (needFresh) { + const uploaded = parseDay(uploadedAt); const days = policy.freshness_days ?? 90; const todayForFreshness = parseDay(today) ?? new Date(); - if (issued && daysBetween(issued, todayForFreshness) > days) { + if (uploaded && daysBetween(uploaded, todayForFreshness) > days) { return { present: true, valid: false, @@ -170,11 +153,11 @@ export type CatalogDocType = { /** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */ export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [ { code: "foto", label: "Foto", required: 1, category: "identidad", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, - { code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 }, - { code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, - { code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, - { code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, - { code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 }, { code: "alta_imss", label: "Alta IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, { code: "baja_imss", label: "Baja IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, { code: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, diff --git a/api/document_validity_test.ts b/api/document_validity_test.ts index 216bcff..b2649f7 100644 --- a/api/document_validity_test.ts +++ b/api/document_validity_test.ts @@ -15,7 +15,7 @@ const curpPolicy: DocTypePolicy = { required: true, validity_mode: "freshness", freshness_days: 90, - requires_issued_at: true, + requires_issued_at: false, requires_expires_at: false, }; @@ -26,7 +26,7 @@ const inePolicy: DocTypePolicy = { validity_mode: "expiry", freshness_days: null, requires_issued_at: false, - requires_expires_at: true, + requires_expires_at: false, }; Deno.test("freshnessRequired: sin alta exige frescor", () => { @@ -69,7 +69,7 @@ Deno.test("freshnessRequired: rehire con alta posterior no exige frescor", () => ); }); -Deno.test("CURP stale when freshness required", () => { +Deno.test("CURP stale when freshness required and upload is older than 90 days", () => { const ctx: WorkerImssContext = { imss_status: "sin_alta", last_rehire_at: null, @@ -85,6 +85,22 @@ Deno.test("CURP stale when freshness required", () => { assertEquals(result.valid, false); }); +Deno.test("CURP recent upload is fresh even if issued_at is old", () => { + const ctx: WorkerImssContext = { + imss_status: "sin_alta", + last_rehire_at: null, + current_alta_at: null, + }; + const result = evaluateDocumentValidity( + curpPolicy, + { type_code: "curp", issued_at: "2024-01-01", expires_at: null, uploaded_at: "2026-08-01" }, + ctx, + "2026-08-20", + ); + assertEquals(result.validity_status, "ok"); + assertEquals(result.valid, true); +}); + Deno.test("CURP old but ok when already alta", () => { const ctx: WorkerImssContext = { imss_status: "alta", @@ -117,13 +133,26 @@ Deno.test("INE expired always invalid", () => { assertEquals(result.valid, false); }); -Deno.test("missing issued_at for freshness", () => { +Deno.test("freshness without issued_at uses uploaded_at", () => { const result = evaluateDocumentValidity( curpPolicy, { type_code: "curp", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" }, { imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null }, + "2026-08-20", ); - assertEquals(result.validity_status, "missing_dates"); + assertEquals(result.validity_status, "ok"); + assertEquals(result.valid, true); +}); + +Deno.test("INE without expires_at is valid; reviewer checks the file", () => { + const result = evaluateDocumentValidity( + inePolicy, + { type_code: "ine", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" }, + { imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null }, + "2026-08-20", + ); + assertEquals(result.validity_status, "ok"); + assertEquals(result.valid, true); }); Deno.test("núcleo de persona no se agranda", () => { diff --git a/api/expense_catalogs_http.ts b/api/expense_catalogs_http.ts new file mode 100644 index 0000000..f2d133c --- /dev/null +++ b/api/expense_catalogs_http.ts @@ -0,0 +1,67 @@ +import type { Hono } from "hono"; +import type { AuthUser } from "./auth.ts"; +import { tenantScope } from "./auth.ts"; +import { requireCoreAuth } from "./scope.ts"; +import { requireAnyPermission } from "./permissions.ts"; +import { callCoreFn } from "./rpc.ts"; +import { respondRpc, routeLabel } from "./http_errors.ts"; + +type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; +type Db = import("./db.ts").Db; + +function tid(c: { get: (k: "user") => AuthUser }): number { + return tenantScope(c.get("user")) ?? 0; +} + +export function registerExpenseCatalogRoutes(app: App) { + const view = [...requireCoreAuth, requireAnyPermission("expenses.view", "settings.view", "warehouse.view")] as const; + const write = [...requireCoreAuth, requireAnyPermission("expenses.create", "settings.update", "warehouse.update")] as const; + + app.get("/v1/suppliers", ...view, async (c) => { + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_supplier_list", { + tenant_id: tid(c), + status: c.req.query("status"), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.post("/v1/suppliers", ...write, async (c) => { + const body = await c.req.json>(); + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_supplier_create", { + ...body, + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.patch("/v1/suppliers/:id", ...write, async (c) => { + const body = await c.req.json>(); + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_supplier_update", { + ...body, + id: Number(c.req.param("id")), + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.get("/v1/measure-units", ...view, async (c) => { + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_measure_unit_list", { + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.post("/v1/measure-units", ...write, async (c) => { + const body = await c.req.json>(); + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_measure_unit_add", { + ...body, + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); +} diff --git a/api/main.ts b/api/main.ts index b400b4f..fe77bab 100644 --- a/api/main.ts +++ b/api/main.ts @@ -16,6 +16,7 @@ import { type Db, } from "./db.ts"; import { callCoreFn } from "./rpc.ts"; +import { normalizeDocumentGetPayload } from "./document_rpc.ts"; import { respondRpc, respondApiError, @@ -65,6 +66,7 @@ import { import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts"; import { registerPayrollRoutes } from "./payroll_http.ts"; import { registerExpenseRoutes } from "./expenses_http.ts"; +import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts"; import { registerWarehouseRoutes } from "./warehouse_http.ts"; import { registerCostControlRoutes } from "./cost_control_http.ts"; import { registerWorkProgramRoutes } from "./work_program_http.ts"; @@ -72,6 +74,8 @@ import { registerIamRoutes } from "./iam_http.ts"; import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts"; import { buildBudgetTemplate, + budgetReadErrorMessage, + excelFileTypeError, exportBudgetWorkbook, previewBudgetExcel, importBudgetExcel, @@ -100,7 +104,12 @@ async function rpcDocumentForDownload( payload: Record, route: string, ): Promise<{ doc?: StorageDoc; envelope: RpcEnvelope }> { - const envelope = await callCoreFn<{ document: StorageDoc }>(db, fn, payload, { route }); + const envelope = await callCoreFn<{ document: StorageDoc }>( + db, + fn, + normalizeDocumentGetPayload(payload), + { route }, + ); if (!envelope.ok) return { envelope }; const doc = envelope.data?.document; if (!doc?.storage_name) { @@ -119,6 +128,17 @@ async function rpcDocumentForDownload( return { doc, envelope }; } +async function readExcelUpload( + c: { req: { formData: () => Promise } }, +): Promise<{ file: File; bytes: Uint8Array } | { error: string }> { + const form = await c.req.formData(); + const file = form.get("file"); + if (!(file instanceof File)) return { error: "Seleccione un archivo Excel (.xlsx o .xls)." }; + const typeError = excelFileTypeError(file.name); + if (typeError) return { error: typeError }; + return { file, bytes: new Uint8Array(await file.arrayBuffer()) }; +} + async function scopedCompany( db: Db, id: number, @@ -447,7 +467,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermiss const { doc, envelope } = await rpcDocumentForDownload( db, "core.fn_company_document_get", - { company_id: companyId, id: docId }, + { company_id: companyId, doc_id: docId }, route, ); if (!doc) return respondRpc(c, envelope); @@ -689,7 +709,7 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermissi const { doc, envelope } = await rpcDocumentForDownload( db, "core.fn_project_document_get", - { project_id: projectId, id: docId }, + { project_id: projectId, doc_id: docId }, route, ); if (!doc) return respondRpc(c, envelope); @@ -732,40 +752,59 @@ app.get("/v1/projects/:id/budget/export", ...requireCoreAuth, requirePermission( }); app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { - const form = await c.req.formData(); - const file = form.get("file"); - if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); - const bytes = new Uint8Array(await file.arrayBuffer()); - const preview = previewBudgetExcel(bytes); - if (preview.errors.length && !preview.items.length) return c.json(preview, 400); + const upload = await readExcelUpload(c); + if ("error" in upload) { + return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c) }); + } + const preview = previewBudgetExcel(upload.bytes); + if (preview.errors.length && !preview.items.length) { + return respondApiError(c, "VALIDATION", budgetReadErrorMessage(preview), { + route: routeLabel(c), + filename: upload.file.name, + format: preview.format, + }, preview.errors.flatMap((row) => row.messages)); + } return c.json(preview); }); app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; - const form = await c.req.formData(); - const file = form.get("file"); - if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); + const upload = await readExcelUpload(c); + if ("error" in upload) { + return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c), project_id: id }); + } const db = c.get("db"); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const bytes = new Uint8Array(await file.arrayBuffer()); - const preview = previewBudgetExcel(bytes); - if (preview.errors.length && !preview.items.length) return c.json(preview, 400); + const preview = previewBudgetExcel(upload.bytes); + if (preview.errors.length && !preview.items.length) { + return respondApiError(c, "VALIDATION", budgetReadErrorMessage(preview), { + route: routeLabel(c), + project_id: id, + filename: upload.file.name, + format: preview.format, + }, preview.errors.flatMap((row) => row.messages)); + } return c.json(preview); }); app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => { const id = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, id)) return; - const form = await c.req.formData(); - const file = form.get("file"); - if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400); + const upload = await readExcelUpload(c); + if ("error" in upload) { + return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c), project_id: id }); + } const db = c.get("db"); if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404); - const bytes = new Uint8Array(await file.arrayBuffer()); - const report = await importBudgetExcel(db, id, bytes); - if (report.errors.length && !report.inserted) return c.json(report, 400); + const report = await importBudgetExcel(db, id, upload.bytes); + if (report.errors.length && !report.inserted) { + return respondApiError(c, "VALIDATION", budgetReadErrorMessage(report), { + route: routeLabel(c), + project_id: id, + filename: upload.file.name, + }, report.errors.flatMap((row) => row.messages)); + } return c.json(report); }); @@ -1174,11 +1213,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc {}, { route }, ); - const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as { - validity_mode: string; - requires_issued_at: boolean; - requires_expires_at: boolean; - } | undefined; + const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type); if (!policy) { return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, { route, @@ -1192,13 +1227,6 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc const imssCompanyId = Number(form.get("imss_company_id") || 0) || null; const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null; const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null; - - if (policy.requires_issued_at && !issuedAt) { - return respondApiError(c, "VALIDATION", `Indique la fecha de emisión del documento en ${route}`, { route, worker_id: id }); - } - if (policy.requires_expires_at && !expiresAt) { - return respondApiError(c, "VALIDATION", `Indique la fecha de vigencia / vencimiento en ${route}`, { route, worker_id: id }); - } if (type === "alta_imss" && !imssCompanyId) { return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id }); } @@ -1237,7 +1265,7 @@ app.get("/v1/workers/:id/documents/:docId", ...requireCoreAuth, requirePermissio const { doc, envelope } = await rpcDocumentForDownload( db, "core.fn_worker_document_get", - { worker_id: workerId, id: docId }, + { worker_id: workerId, doc_id: docId }, route, ); if (!doc) return respondRpc(c, envelope); @@ -1355,8 +1383,9 @@ app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, requirePermission("documen { job_id: id }, { route }, ); - if (!jobEnv.ok) return respondRpc(c, jobEnv); - const pdfPath = jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id); + const pdfPath = jobEnv.ok + ? (jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id)) + : badgeJobPdfKey(id); const bytes = await getObject(pdfPath); c.header("Content-Type", "application/pdf"); c.header("Content-Disposition", `attachment; filename="gafetes-${id}.pdf"`); @@ -1378,6 +1407,7 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePerm registerPayrollRoutes(app); registerExpenseRoutes(app); +registerExpenseCatalogRoutes(app); registerWarehouseRoutes(app); registerCostControlRoutes(app); registerWorkProgramRoutes(app); diff --git a/api/payroll.ts b/api/payroll.ts index 4698762..615f514 100644 --- a/api/payroll.ts +++ b/api/payroll.ts @@ -217,14 +217,109 @@ export async function patchDestajoCut(db: Db, weekId: number, cutId: number, pat if (!env.ok) throwFromRpc(env); } +type AttendanceRow = { + worker_id: number; + project_id: number; + work_date: string; + present: boolean | number; + project_name?: string | null; +}; + +function isoDay(value: unknown): string { + return String(value ?? "").slice(0, 10); +} + +export function weekBoard(week: Record | null | undefined) { + const required = Number(week?.required_net || 0); + const payable = Number(week?.payable_net || 0); + const loanRecovery = Math.max(0, roundMoney(required - payable)); + const faltante = Math.max(0, roundMoney(payable - required)); + return { + required_net: required, + payable_net: payable, + loan_recovery: loanRecovery, + faltante, + delta: roundMoney(payable - required), + }; +} + +async function attachJornalCells( + db: Db, + sheets: Array>, + days: string[], + tenantId: number, +): Promise>> { + const from = days[0]; + const to = days[days.length - 1]; + if (!from || !to) return sheets; + const rows = 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 ? + AND p.tenant_id = ?`, + ).all(from, to, tenantId) as AttendanceRow[]; + + return sheets.map((sheet) => { + if (sheet.kind !== "obra") return sheet; + const projectId = Number(sheet.project_id); + const lines = ((sheet.lines as Array>) ?? []).map((line) => { + const workerId = Number(line.worker_id); + const cells = days.map((date) => { + const here = rows.find((r) => + Number(r.worker_id) === workerId && + isoDay(r.work_date) === date && + Number(r.project_id) === projectId + ); + const other = rows.find((r) => + Number(r.worker_id) === workerId && + isoDay(r.work_date) === date && + Number(r.project_id) !== projectId && + Boolean(r.present) + ); + return { + date, + present: Boolean(here?.present), + other_project_id: other ? Number(other.project_id) : null, + other_project_name: other?.project_name ?? null, + }; + }); + return { ...line, cells }; + }); + return { ...sheet, lines }; + }); +} + export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) { 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); - const start = String((data.week as Record)?.week_start ?? weekStart); - return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) }; + const week = (data.week ?? null) as Record | null; + const start = isoDay(week?.week_start) || weekStart; + const days = weekDays(start); + const sheets = await attachJornalCells( + db, + (data.sheets as Array>) ?? [], + days, + tenantId, + ); + const bounds = destajoPeriodBounds(start); + return { + ...data, + week, + sheets, + days, + destajo_period: data.destajo_period ?? { + period_start: bounds.periodStart, + period_end: bounds.periodEnd, + }, + board: data.board ?? weekBoard(week), + units: data.units ?? [...DEFAULT_UNITS], + can_assemble: canAssembleWeek(start, today), + assemble_from: weekAssembleDate(start), + }; } export async function weekCsv(db: Db, weekId: number): Promise { diff --git a/api/payroll_test.ts b/api/payroll_test.ts index 981ea10..d2e1f84 100644 --- a/api/payroll_test.ts +++ b/api/payroll_test.ts @@ -21,6 +21,7 @@ import { thisSaturday, weekAssembleDate, weekContaining, + weekDays, } from "./payroll.ts"; // tenant_id fijo y solo-de-tests: cada test corre en su propia transacción @@ -81,6 +82,14 @@ Deno.test("semana lun-sáb y corte destajo jue-jue", () => { const w = weekContaining("2026-08-26"); assertEquals(w.weekStart, "2026-08-24"); assertEquals(w.weekEnd, "2026-08-29"); + assertEquals(weekDays("2026-08-24"), [ + "2026-08-24", + "2026-08-25", + "2026-08-26", + "2026-08-27", + "2026-08-28", + "2026-08-29", + ]); const d = destajoPeriodBounds("2026-08-24"); assertEquals(d.periodEnd, "2026-08-27"); assertEquals(d.periodStart, "2026-08-20"); @@ -265,6 +274,59 @@ Deno.test("un día no se marca en dos obras", async () => { }); }); +type JornalCell = { + date: string; + present: boolean; + other_project_id: number | null; + other_project_name: string | null; +}; + +Deno.test("paquete de semana incluye días L–S y celdas de asistencia", async () => { + await withTestDb(async (db) => { + const { companyId, projectId, workerId } = await seedFixture(db); + const project2Id = await addSecondProject(db, companyId); + await setAttendance(db, TENANT_ID, { + project_id: projectId, + worker_id: workerId, + work_date: "2026-08-24", + present: true, + }); + await setAttendance(db, TENANT_ID, { + project_id: project2Id, + worker_id: workerId, + work_date: "2026-08-25", + present: true, + }); + const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-24"); + assertEquals(bundle.days, [ + "2026-08-24", + "2026-08-25", + "2026-08-26", + "2026-08-27", + "2026-08-28", + "2026-08-29", + ]); + const obra = (bundle.sheets as Array<{ + kind: string; + project_id: number; + lines: Array<{ worker_id: number; cells?: JornalCell[] }>; + }>).find((sheet) => sheet.kind === "obra" && Number(sheet.project_id) === projectId); + assertExists(obra); + const line = obra.lines.find((row) => Number(row.worker_id) === workerId); + assertExists(line); + assertEquals(line.cells?.length, 6); + assertEquals(line.cells?.[0], { + date: "2026-08-24", + present: true, + other_project_id: null, + other_project_name: null, + }); + assertEquals(line.cells?.[1]?.present, false); + assertEquals(line.cells?.[1]?.other_project_id, project2Id); + assertEquals(line.cells?.[1]?.other_project_name, "Obra Sur"); + }); +}); + Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", async () => { await withTestDb(async (db) => { const { companyId, projectId, workerId } = await seedFixture(db); diff --git a/api/pdf.ts b/api/pdf.ts index 6a40a3b..e19bd4c 100644 --- a/api/pdf.ts +++ b/api/pdf.ts @@ -101,16 +101,34 @@ export async function generateBadgePdf( workerIds: number[], ): Promise { const project = await db.prepare( - "SELECT name, code, theme_id, logo_left_path, logo_right_path FROM projects WHERE id = ?", + `SELECT p.name, p.code, p.theme_id, p.logo_left_path, p.logo_right_path, p.tenant_id, + COALESCE(NULLIF(c.nombre_comercial, ''), NULLIF(c.name, '')) AS company_name + FROM projects p + LEFT JOIN companies c ON c.id = p.company_id + WHERE p.id = ?`, ).get(projectId) as { name: string; code: string; theme_id: string; logo_left_path: string | null; logo_right_path: string | null; + tenant_id?: number | null; + company_name?: string | null; } | undefined; if (!project) throw new Error("Proyecto no encontrado"); + let companyName = String(project.company_name || "").trim(); + if (!companyName && project.tenant_id) { + const principal = await db.prepare( + `SELECT COALESCE(NULLIF(nombre_comercial, ''), name) AS company_name + FROM companies + WHERE tenant_id = ? AND kind = 'principal' + ORDER BY id + LIMIT 1`, + ).get(project.tenant_id) as { company_name?: string } | undefined; + companyName = String(principal?.company_name || "").trim(); + } + const workers = await db.prepare( `SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.curp, w.nss, w.blood_type, w.position, @@ -156,7 +174,7 @@ export async function generateBadgePdf( const photo = await embedMaybe(pdf, await loadCurrentPhoto(db, w.id)); const qrPng = await badgeQrPng(w.curp); const qrImg = await pdf.embedPng(qrPng); - drawFront(page, font, fontBold, x, topY, w, photo, logoL, logoR); + drawFront(page, font, fontBold, x, topY, w, photo, logoL, logoR, companyName); drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code); } } @@ -164,6 +182,22 @@ export async function generateBadgePdf( return await pdf.save(); } +function drawFittedLeft( + page: PDFPage, + font: PDFFont, + text: string, + x: number, + y: number, + maxWidth: number, + size: number, + color = rgb(0, 0, 0), +) { + if (!text) return; + let s = size; + while (s > 5 && font.widthOfTextAtSize(text, s) > maxWidth) s -= 0.4; + page.drawText(text, { x, y, size: s, font, color }); +} + function drawFront( page: PDFPage, font: PDFFont, @@ -174,6 +208,7 @@ function drawFront( photo: PDFImage | null, logoL: PDFImage | null, logoR: PDFImage | null, + companyName: string, ) { page.drawRectangle({ x, @@ -193,12 +228,15 @@ function drawFront( height: logoL.height * scale, }); } else { - page.drawText("Construcciones Arctec", { - x: x + 6, - y: y + CARD_H - 22, - size: 7, - font: fontBold, - }); + drawFittedLeft( + page, + fontBold, + companyName.toUpperCase(), + x + 6, + y + CARD_H - 22, + CARD_W / 2 - 10, + 7, + ); } if (logoR) { const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height); @@ -208,14 +246,6 @@ function drawFront( width: logoR.width * scale, height: logoR.height * scale, }); - } else { - page.drawText("Zendala", { - x: x + CARD_W / 2 + 8, - y: y + CARD_H - 22, - size: 9, - font: fontBold, - color: rgb(0.1, 0.25, 0.55), - }); } const photoW = 3.5 * CM; diff --git a/api/warehouse_http.ts b/api/warehouse_http.ts index 471e78c..b3e7e20 100644 --- a/api/warehouse_http.ts +++ b/api/warehouse_http.ts @@ -68,12 +68,15 @@ export function registerWarehouseRoutes(app: App) { app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { const projectId = Number(c.req.param("id")); if (!await denyUnlessProjectScope(c, projectId)) return; - const body = await c.req.json<{ force?: boolean; close_notes?: string }>().catch(() => ({} as { force?: boolean; close_notes?: string })); + const body = await c.req.json<{ force?: boolean; close_notes?: string; transfer_remaining?: boolean }>().catch( + () => ({} as { force?: boolean; close_notes?: string; transfer_remaining?: boolean }), + ); const db = c.get("db"); const a = actor(c); const env = await callCoreFn(db, "core.fn_warehouse_close_project", { project_id: projectId, force: body.force, + transfer_remaining: body.transfer_remaining, closed_by_name: a.name, close_notes: body.close_notes, }, { route: routeLabel(c) }); @@ -119,12 +122,32 @@ export function registerWarehouseRoutes(app: App) { if (!await denyUnlessWarehouseCentral(c)) return; } else if (!await denyUnlessProjectScope(c, wh.project_id)) return; const env = await callCoreFn(db, "core.fn_warehouse_movement_list", { + tenant_id: tid(c), warehouse_id: whId, material_id: c.req.query("material_id"), }, { route: routeLabel(c) }); return respondRpc(c, env); }); + app.get("/v1/warehouses/movements/global", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => { + if (!await denyUnlessWarehouseCentral(c)) return; + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_warehouse_movement_list", { + tenant_id: tid(c), + global: true, + material_id: c.req.query("material_id"), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.get("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.view"), async (c) => { + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_warehouse_material_list", { + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => { const body = await c.req.json>(); const db = c.get("db"); @@ -132,6 +155,17 @@ export function registerWarehouseRoutes(app: App) { return respondRpc(c, env); }); + app.patch("/v1/warehouse-materials/:id", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => { + const body = await c.req.json>(); + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_warehouse_material_upsert", { + ...body, + id: Number(c.req.param("id")), + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => { const whId = Number(c.req.param("id")); const db = c.get("db"); diff --git a/api/work_program_excel_test.ts b/api/work_program_excel_test.ts index 4e2f990..bcc9b50 100644 --- a/api/work_program_excel_test.ts +++ b/api/work_program_excel_test.ts @@ -1,6 +1,6 @@ import { assertEquals, assertGreater } from "jsr:@std/assert@1"; import { readFileSync } from "node:fs"; -import { parseBarString, parseWorkProgramRows, parseWorkProgramWorkbook } from "./work_program_excel.ts"; +import { parseBarString, parseWorkProgramRows, parseWorkProgramWorkbook, matchWorkProgramImport } from "./work_program_excel.ts"; Deno.test("parseBarString decodifica formato Neodata", () => { const slots = parseBarString("Barra=14.29-100,0-100,0-14.29,0-0,0-0,0-0,0-0,0-0,0-0"); @@ -36,8 +36,36 @@ Deno.test("detecta programa por concepto con Barra=", () => { assertEquals(parsed.concepts[0].slots.length, 3); }); -const conceptoPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Concepto_a_Est_ndar__E__4-9-2026_Hr18Mn46_85cd.xlsx"; -const partidaPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Partida_a_Est_ndar__E__4-9-2026_Hr18Mn43_18ff.xlsx"; +Deno.test("detecta programa por partida con columna vacía antes de Sem (Neodata)", () => { + const parsed = parseWorkProgramRows([ + ["PARTIDA", "PARTIDA", "", "Sem 1", "Sem 2", "Sem 3", "Total"], + ["A", "A", "", 0, 0, 0, 0], + ["A001", "PRELIMINARES Y ALBAÑILERÍA", "", 19493.27, 32531.38, 1161.31, 123219.19], + ["", "", "", "Barra=14.29-100,0-100,0-14.29"], + ["A002", "INSTALACION HIDROSANITARIA", "", 1889.2, 19822.48, 3668.97, 48630.1], + ]); + assertEquals(parsed.format, "partida"); + assertEquals(parsed.period_count, 3); + assertEquals(parsed.partidas.length, 2); + assertEquals(parsed.partidas[0].code, "A001"); + assertEquals(parsed.partidas[0].amounts[0], 19493.27); +}); + +Deno.test("match de partidas por código A001 del presupuesto", () => { + const parsed = parseWorkProgramRows([ + ["PARTIDA", "PARTIDA", "Sem 1", "Sem 2", "Total"], + ["A001", "PRELIMINARES Y ALBAÑILERÍA", 100, 200, 300], + ]); + const matched = matchWorkProgramImport({ ...parsed, sheet: "Por partida" }, { + items: [], + chapters: [{ id: 11, code: "A001", name: "Preliminares y albañilería", amount: 300 }], + }); + assertEquals(matched.preview.unmatched_codes, []); + assertEquals(matched.payload.partida_amounts.length, 2); +}); + +const conceptoPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Concepto_a_Est_ndar__E__4-9-2026_Hr18Mn46.xlsx"; +const partidaPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Partida_a_Est_ndar__E__4-9-2026_Hr18Mn43_da4d.xlsx"; Deno.test("lee Excel real por concepto del usuario", () => { try { diff --git a/api/work_program_http.ts b/api/work_program_http.ts index 9e45b4c..5a56e30 100644 --- a/api/work_program_http.ts +++ b/api/work_program_http.ts @@ -3,8 +3,9 @@ import type { AuthUser } from "./auth.ts"; import { requireCoreAuth } from "./scope.ts"; import type { Db } from "./db.ts"; import { projectById } from "./db.ts"; -import { requireAnyPermission, requirePermission } from "./permissions.ts"; +import { requirePermission } from "./permissions.ts"; import { denyUnlessProjectScope } from "./scope_enforcement.ts"; +import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts"; import { createEmptyWorkProgram, exportWorkProgram, @@ -29,11 +30,38 @@ async function ensureProject(c: Context, id: number): Promise { return await denyUnlessProjectScope(c, id); } -async function readUpload(c: { req: { formData: () => Promise } }): Promise { +function respondOk(c: Context, data: T, message: string) { + return respondRpc(c, { + ok: true, + code: "OK", + layer: "api", + message, + data, + }); +} + +async function readUpload(c: Context): Promise<{ bytes: Uint8Array } | { response: Response }> { const form = await c.req.formData(); const file = form.get("file"); - if (!(file instanceof File)) throw new Error("Archivo Excel requerido (campo file)"); - return new Uint8Array(await file.arrayBuffer()); + if (!(file instanceof File)) { + return { + response: respondApiError(c, "VALIDATION", "Seleccione un archivo Excel (.xlsx o .xls) de programa de obra", { + route: routeLabel(c), + }), + }; + } + const name = file.name.trim().toLowerCase(); + if (!name.endsWith(".xlsx") && !name.endsWith(".xls")) { + return { + response: respondApiError( + c, + "VALIDATION", + `Solo se aceptan archivos Excel (.xlsx o .xls). «${file.name}» no es un Excel de programa de obra.`, + { route: routeLabel(c), filename: file.name }, + ), + }; + } + return { bytes: new Uint8Array(await file.arrayBuffer()) }; } export function registerWorkProgramRoutes(app: App) { @@ -42,7 +70,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await getWorkProgram(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, data.program ? "Programa de obra cargado" : "La obra no tiene programa de obra"); }); app.get("/v1/projects/:id/work-program/concepts", ...viewAuth, async (c) => { @@ -50,7 +78,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await listConcepts(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, "Conceptos del programa de obra"); }); app.get("/v1/projects/:id/work-program/partidas", ...viewAuth, async (c) => { @@ -58,7 +86,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await listPartidas(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, "Partidas del programa de obra"); }); app.get("/v1/projects/:id/work-program/vs-cost", ...requireCoreAuth, requirePermission("cost_control.view"), async (c) => { @@ -66,26 +94,35 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await getVsCost(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, "Comparativo programado vs costos"); }); app.post("/v1/projects/:id/work-program/preview", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { const id = Number(c.req.param("id")); if (!await ensureProject(c, id)) return; const db = c.get("db"); - const bytes = await readUpload(c); - const preview = await previewWorkProgramExcel(bytes, id, db); - return c.json({ ok: true, data: preview }); + const upload = await readUpload(c); + if ("response" in upload) return upload.response; + const preview = await previewWorkProgramExcel(upload.bytes, id, db); + const found = preview.format === "concepto" ? preview.concepts.length : preview.partidas.length; + return respondOk( + c, + preview, + preview.format === "none" + ? "No se reconoció el formato del programa de obra" + : `${preview.format_label}: ${found} renglón(es), ${preview.period_count} semana(s)`, + ); }); app.post("/v1/projects/:id/work-program/import", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { const id = Number(c.req.param("id")); if (!await ensureProject(c, id)) return; const db = c.get("db"); - const bytes = await readUpload(c); + const upload = await readUpload(c); + if ("response" in upload) return upload.response; const createChapters = c.req.query("create_chapters") !== "false"; - const data = await importWorkProgramExcel(db, id, bytes, { createChapters }); - return c.json({ ok: true, data }); + const data = await importWorkProgramExcel(db, id, upload.bytes, { createChapters }); + return respondOk(c, data, "Programa de obra importado"); }); app.post("/v1/projects/:id/work-program", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { @@ -93,7 +130,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const data = await createEmptyWorkProgram(db, id); - return c.json({ ok: true, data }); + return respondOk(c, data, "Programa de obra creado en blanco"); }); app.post("/v1/projects/:id/work-program/generate", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => { @@ -102,7 +139,7 @@ export function registerWorkProgramRoutes(app: App) { const db = c.get("db"); const body = await c.req.json().catch(() => ({})) as { strategy?: string }; const data = await generateWorkProgram(db, id, body.strategy || "keyword"); - return c.json({ ok: true, data }); + return respondOk(c, data, "Programa de obra generado"); }); app.post("/v1/projects/:id/work-program/recalculate-partidas", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { @@ -110,7 +147,7 @@ export function registerWorkProgramRoutes(app: App) { if (!await ensureProject(c, id)) return; const db = c.get("db"); const env = await recalculatePartidas(db, id); - return c.json({ ok: true, data: env }); + return respondOk(c, env, "Erogaciones por partida recalculadas"); }); app.patch("/v1/projects/:id/work-program/concepts/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { @@ -126,7 +163,7 @@ export function registerWorkProgramRoutes(app: App) { Number(body.start_pct ?? 0), Number(body.end_pct ?? 0), ); - return c.json({ ok: true, data }); + return respondOk(c, data, "Avance de concepto actualizado"); }); app.patch("/v1/projects/:id/work-program/partidas/:chapterId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { @@ -141,7 +178,7 @@ export function registerWorkProgramRoutes(app: App) { Number(c.req.param("periodId")), Number(body.amount ?? 0), ); - return c.json({ ok: true, data }); + return respondOk(c, data, "Erogación de partida actualizada"); }); app.patch("/v1/projects/:id/work-program/progress/:itemId/periods/:periodId", ...requireCoreAuth, requirePermission("work_program.update"), async (c) => { @@ -156,7 +193,7 @@ export function registerWorkProgramRoutes(app: App) { Number(c.req.param("periodId")), Number(body.pct_complete ?? 0), ); - return c.json({ ok: true, data }); + return respondOk(c, data, "Porcentaje de avance actualizado"); }); app.get("/v1/projects/:id/work-program/export", ...viewAuth, async (c) => { diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index ecb60c8..5c4f997 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -38,5 +38,12 @@ + + + + + + + diff --git a/db/core/changesets/032-document-validity-from-upload.sql b/db/core/changesets/032-document-validity-from-upload.sql new file mode 100644 index 0000000..6fcecd8 --- /dev/null +++ b/db/core/changesets/032-document-validity-from-upload.sql @@ -0,0 +1,98 @@ +--liquibase formatted sql +-- PANELS · core · vigencia documental desde la fecha de carga + +--changeset panel:core-032a-doc-types-no-required-dates endDelimiter:; splitStatements:true +UPDATE core.document_types + SET requires_issued_at = false, + requires_expires_at = false; + +--changeset panel:core-032b-fn-worker-eval-doc-validity splitStatements:false +CREATE OR REPLACE FUNCTION core._worker_eval_doc_validity( + p_policy jsonb, + p_doc jsonb, + p_ctx jsonb, + p_today date +) +RETURNS jsonb +LANGUAGE plpgsql +IMMUTABLE +AS $$ +DECLARE + v_mode text := COALESCE(p_policy->>'validity_mode', 'none'); + v_issued date; + v_expires date; + v_uploaded date; + v_need_fresh boolean; + v_days integer; +BEGIN + IF p_doc IS NULL THEN + RETURN jsonb_build_object( + 'present', false, + 'valid', false, + 'validity_status', 'missing', + 'issued_at', NULL, + 'expires_at', NULL + ); + END IF; + v_issued := NULLIF(p_doc->>'issued_at', '')::date; + v_expires := NULLIF(p_doc->>'expires_at', '')::date; + BEGIN + v_uploaded := COALESCE( + NULLIF(left(p_doc->>'uploaded_at', 10), '')::date, + NULL + ); + EXCEPTION WHEN OTHERS THEN + v_uploaded := NULL; + END; + v_need_fresh := core._worker_freshness_required(p_ctx); + IF v_mode = 'expiry' THEN + IF v_expires IS NOT NULL AND v_expires < p_today THEN + RETURN jsonb_build_object( + 'present', true, + 'valid', false, + 'validity_status', 'expired', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + RETURN jsonb_build_object( + 'present', true, + 'valid', true, + 'validity_status', 'ok', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + IF v_mode = 'freshness' THEN + IF v_need_fresh AND v_uploaded IS NOT NULL THEN + v_days := COALESCE((p_policy->>'freshness_days')::integer, 90); + IF (p_today - v_uploaded) > v_days THEN + RETURN jsonb_build_object( + 'present', true, + 'valid', false, + 'validity_status', 'stale_for_alta', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + END IF; + RETURN jsonb_build_object( + 'present', true, + 'valid', true, + 'validity_status', 'ok', + 'issued_at', v_issued, + 'expires_at', v_expires + ); + END IF; + RETURN jsonb_build_object( + 'present', true, + 'valid', true, + 'validity_status', 'ok', + 'issued_at', v_issued, + 'expires_at', v_expires + ); +END; +$$; + +--changeset panel:core-032c-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core._worker_eval_doc_validity(jsonb, jsonb, jsonb, date) TO panels_core_app; diff --git a/db/core/changesets/032-fix-work-program-jsonb-object-length.sql b/db/core/changesets/032-fix-work-program-jsonb-object-length.sql new file mode 100644 index 0000000..b2cb279 --- /dev/null +++ b/db/core/changesets/032-fix-work-program-jsonb-object-length.sql @@ -0,0 +1,123 @@ +--liquibase formatted sql +-- PANELS · core · fn_work_program_replace: jsonb_object_length no existe en PostgreSQL + +--changeset panel:core-032a-fn-work-program-replace-period-count splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_work_program_replace(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_source text := COALESCE(nullif(btrim(payload->>'source'), ''), 'import'); + v_status text := COALESCE(nullif(btrim(payload->>'status'), ''), 'active'); + v_start date; + v_end date; + v_program_id bigint; + v_period jsonb; + v_period_id bigint; + v_period_map jsonb := '{}'::jsonb; + v_period_count integer := 0; + v_slot jsonb; + v_partida jsonb; + v_chapter jsonb; + v_ch jsonb; + v_slot_count integer := 0; + v_partida_count integer := 0; + v_err jsonb; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_work_program_replace: project_id es obligatorio', + jsonb_build_object('fn', 'fn_work_program_replace')); + END IF; + + v_err := core._wp_assert_project_ready(v_project_id); + IF v_err IS NOT NULL THEN RETURN v_err; END IF; + + SELECT start_date, end_date INTO v_start, v_end FROM projects WHERE id = v_project_id; + + DELETE FROM work_programs WHERE project_id = v_project_id; + + INSERT INTO work_programs (project_id, start_date, end_date, duration_days, source, status) + VALUES (v_project_id, v_start, v_end, (v_end - v_start + 1), v_source, v_status) + RETURNING id INTO v_program_id; + + FOR v_period IN SELECT * FROM jsonb_array_elements(core._wp_generate_periods(v_start, v_end)) LOOP + INSERT INTO work_program_periods (program_id, period_index, label, start_date, end_date) + VALUES ( + v_program_id, + (v_period->>'period_index')::integer, + v_period->>'label', + (v_period->>'start_date')::date, + (v_period->>'end_date')::date + ) + RETURNING id INTO v_period_id; + v_period_map := v_period_map || jsonb_build_object(v_period->>'period_index', v_period_id); + v_period_count := v_period_count + 1; + END LOOP; + + FOR v_slot IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'concept_slots', '[]'::jsonb)) LOOP + v_period_id := (v_period_map->>(v_slot->>'period_index'))::bigint; + IF v_period_id IS NULL THEN CONTINUE; END IF; + IF NOT EXISTS ( + SELECT 1 FROM budget_items bi + WHERE bi.id = (v_slot->>'budget_item_id')::bigint AND bi.project_id = v_project_id + ) THEN CONTINUE; END IF; + IF core._wp_slot_weight((v_slot->>'start_pct')::numeric, (v_slot->>'end_pct')::numeric) <= 0 THEN CONTINUE; END IF; + INSERT INTO work_program_concept_slots (program_id, budget_item_id, period_id, start_pct, end_pct) + VALUES ( + v_program_id, + (v_slot->>'budget_item_id')::bigint, + v_period_id, + COALESCE((v_slot->>'start_pct')::numeric, 0), + COALESCE((v_slot->>'end_pct')::numeric, 0) + ); + v_slot_count := v_slot_count + 1; + END LOOP; + + FOR v_ch IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'chapters_to_create', '[]'::jsonb)) LOOP + INSERT INTO budget_chapters (project_id, parent_id, code, name, wbs, sort_order) + VALUES ( + v_project_id, + NULLIF(v_ch->>'parent_id', '')::bigint, + upper(COALESCE(nullif(btrim(v_ch->>'code'), ''), '')), + COALESCE(nullif(btrim(v_ch->>'name'), ''), 'Partida'), + COALESCE(nullif(btrim(v_ch->>'wbs'), ''), ''), + COALESCE((v_ch->>'sort_order')::integer, 0) + ); + END LOOP; + + FOR v_partida IN SELECT * FROM jsonb_array_elements(COALESCE(payload->'partida_amounts', '[]'::jsonb)) LOOP + v_period_id := (v_period_map->>(v_partida->>'period_index'))::bigint; + IF v_period_id IS NULL THEN CONTINUE; END IF; + IF NOT EXISTS ( + SELECT 1 FROM budget_chapters bc + WHERE bc.id = (v_partida->>'budget_chapter_id')::bigint AND bc.project_id = v_project_id + ) THEN CONTINUE; END IF; + IF COALESCE((v_partida->>'amount')::numeric, 0) = 0 THEN CONTINUE; END IF; + INSERT INTO work_program_partida_amounts (program_id, budget_chapter_id, period_id, amount) + VALUES ( + v_program_id, + (v_partida->>'budget_chapter_id')::bigint, + v_period_id, + round(COALESCE((v_partida->>'amount')::numeric, 0), 2) + ); + v_partida_count := v_partida_count + 1; + END LOOP; + + RETURN core.rpc_ok( + jsonb_build_object( + 'program_id', v_program_id, + 'concept_slots', v_slot_count, + 'partida_amounts', v_partida_count, + 'period_count', v_period_count + ), + format('Programa de obra del proyecto %s reemplazado (%s slots, %s erogaciones)', + v_project_id, v_slot_count, v_partida_count), + jsonb_build_object('fn', 'fn_work_program_replace', 'project_id', v_project_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_work_program_replace', SQLSTATE, SQLERRM); +END; +$$; diff --git a/db/core/changesets/032-fn-badge-job-get.sql b/db/core/changesets/032-fn-badge-job-get.sql new file mode 100644 index 0000000..f67b447 --- /dev/null +++ b/db/core/changesets/032-fn-badge-job-get.sql @@ -0,0 +1,80 @@ +--liquibase formatted sql +-- PANELS · core · fn_badge_job_get: ORDER BY del subquery de personas + +--changeset panel:core-032-fn-badge-job-get-people-order splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_badge_job_get(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_job_id bigint := COALESCE( + NULLIF(payload->>'job_id', '')::bigint, + NULLIF(payload->>'id', '')::bigint + ); + v_job jsonb; + v_people jsonb; +BEGIN + IF v_job_id IS NULL THEN + RETURN core.rpc_err( + 'VALIDATION', + 'fn_badge_job_get: job_id es obligatorio', + jsonb_build_object('fn', 'fn_badge_job_get', 'field', 'job_id') + ); + END IF; + + SELECT to_jsonb(x) + INTO v_job + FROM ( + SELECT + j.*, + p.code AS project_code, + p.name AS project_name + FROM badge_jobs j + JOIN projects p ON p.id = j.project_id + WHERE j.id = v_job_id + ) x; + + IF v_job IS NULL THEN + RETURN core.rpc_err( + 'NOT_FOUND', + format('fn_badge_job_get: job de gafetes id=%s no encontrado', v_job_id), + jsonb_build_object('fn', 'fn_badge_job_get', 'job_id', v_job_id) + ); + END IF; + + SELECT COALESCE(jsonb_agg(to_jsonb(p) ORDER BY p.last_name_p, p.first_name), '[]'::jsonb) + INTO v_people + FROM ( + SELECT + bp.job_id, + bp.worker_id, + bp.delivered, + bp.delivered_at, + w.first_name, + w.last_name_p, + w.last_name_m, + w.position + FROM badge_job_people bp + JOIN workers w ON w.id = bp.worker_id + WHERE bp.job_id = v_job_id::integer + ) p; + + RETURN core.rpc_ok( + jsonb_build_object('job', v_job, 'people', v_people), + format('Job de gafetes #%s cargado: %s persona(s) en el lote', + v_job_id, jsonb_array_length(v_people)), + jsonb_build_object( + 'fn', 'fn_badge_job_get', + 'job_id', v_job_id, + 'people_count', jsonb_array_length(v_people) + ) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_badge_job_get', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-032-fn-badge-job-get-grant endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_badge_job_get(jsonb) TO panels_core_app; diff --git a/db/core/changesets/032-rpc-document-get-id.sql b/db/core/changesets/032-rpc-document-get-id.sql new file mode 100644 index 0000000..e7e394f --- /dev/null +++ b/db/core/changesets/032-rpc-document-get-id.sql @@ -0,0 +1,99 @@ +--liquibase formatted sql +-- PANELS · core · fn_*_document_get acepta doc_id o id +-- La API HTTP enviaba { id } y las RPC leían payload->>'doc_id', dejando el id vacío. + +--changeset panel:core-032a-fn-project-document-get splitStatements:false +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 := NULLIF(payload->>'project_id', '')::bigint; + v_doc_id bigint := NULLIF(COALESCE(payload->>'doc_id', payload->>'id'), '')::bigint; + v_doc jsonb; +BEGIN + IF v_project_id IS NULL OR v_doc_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', + 'fn_project_document_get: project_id y doc_id son obligatorios', + jsonb_build_object('fn', 'fn_project_document_get', 'project_id', v_project_id, 'doc_id', v_doc_id)); + END IF; + 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; +$$; + +--changeset panel:core-032b-fn-company-document-get splitStatements:false +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 := NULLIF(payload->>'company_id', '')::bigint; + v_doc_id bigint := NULLIF(COALESCE(payload->>'doc_id', payload->>'id'), '')::bigint; + v_doc jsonb; +BEGIN + IF v_company_id IS NULL OR v_doc_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', + 'fn_company_document_get: company_id y doc_id son obligatorios', + jsonb_build_object('fn', 'fn_company_document_get', 'company_id', v_company_id, 'doc_id', v_doc_id)); + END IF; + 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; +$$; + +--changeset panel:core-032c-fn-worker-document-get splitStatements:false +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 := NULLIF(payload->>'worker_id', '')::bigint; + v_doc_id bigint := NULLIF(COALESCE(payload->>'doc_id', payload->>'id'), '')::bigint; + v_doc jsonb; +BEGIN + IF v_worker_id IS NULL OR v_doc_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', + 'fn_worker_document_get: worker_id y doc_id son obligatorios', + jsonb_build_object('fn', 'fn_worker_document_get', 'worker_id', v_worker_id, 'doc_id', v_doc_id)); + END IF; + 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; +$$; diff --git a/db/core/changesets/033-catalog-suppliers-units.sql b/db/core/changesets/033-catalog-suppliers-units.sql new file mode 100644 index 0000000..8a94123 --- /dev/null +++ b/db/core/changesets/033-catalog-suppliers-units.sql @@ -0,0 +1,397 @@ +--liquibase formatted sql +-- PANELS · core · catálogo de proveedores y unidades de medida (gastos) + +--changeset panel:core-033a-suppliers-table endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='suppliers' +CREATE TABLE core.suppliers ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + legal_name TEXT NOT NULL DEFAULT '', + rfc TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + city TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + postal_code TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX idx_core_suppliers_rfc + ON core.suppliers (tenant_id, rfc) + WHERE rfc <> ''; +CREATE INDEX idx_core_suppliers_tenant_name ON core.suppliers (tenant_id, lower(name)); +GRANT SELECT, INSERT, UPDATE, DELETE ON core.suppliers TO panels_core_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app; + +--changeset panel:core-033b-measure-units-table endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='measure_units' +CREATE TABLE core.measure_units ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id INTEGER NOT NULL, + code TEXT NOT NULL, + label TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, code) +); +CREATE INDEX idx_core_measure_units_tenant ON core.measure_units (tenant_id); +GRANT SELECT, INSERT, UPDATE, DELETE ON core.measure_units TO panels_core_app; + +--changeset panel:core-033c-expense-supplier-id endDelimiter:; splitStatements:true +ALTER TABLE core.expense_entries + ADD COLUMN IF NOT EXISTS supplier_id BIGINT REFERENCES core.suppliers(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_core_expense_entries_supplier ON core.expense_entries (supplier_id); + +--changeset panel:core-033d-rls-suppliers-units endDelimiter:; splitStatements:true +ALTER TABLE core.suppliers ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON core.suppliers + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer); + +ALTER TABLE core.measure_units ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON core.measure_units + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer); + +--changeset panel:core-033e-fn-measure-units-seed splitStatements:false +CREATE OR REPLACE FUNCTION core._measure_units_seed(p_tenant_id integer) +RETURNS void +LANGUAGE plpgsql +SET search_path = core +AS $$ +BEGIN + IF p_tenant_id IS NULL OR p_tenant_id <= 0 THEN + RETURN; + END IF; + IF EXISTS (SELECT 1 FROM measure_units WHERE tenant_id = p_tenant_id) THEN + RETURN; + END IF; + INSERT INTO measure_units (tenant_id, code, label, sort_order) VALUES + (p_tenant_id, 'pza', 'Pieza', 10), + (p_tenant_id, 'm', 'Metro', 20), + (p_tenant_id, 'ml', 'Metro lineal', 30), + (p_tenant_id, 'm2', 'Metro cuadrado', 40), + (p_tenant_id, 'm3', 'Metro cúbico', 50), + (p_tenant_id, 'kg', 'Kilogramo', 60), + (p_tenant_id, 'ton', 'Tonelada', 70), + (p_tenant_id, 'lt', 'Litro', 80), + (p_tenant_id, 'gl', 'Galón', 90), + (p_tenant_id, 'bulto', 'Bulto', 100), + (p_tenant_id, 'saco', 'Saco', 110), + (p_tenant_id, 'jgo', 'Juego', 120), + (p_tenant_id, 'lote', 'Lote', 130), + (p_tenant_id, 'par', 'Par', 140), + (p_tenant_id, 'hr', 'Hora', 150), + (p_tenant_id, 'dia', 'Día', 160), + (p_tenant_id, 'via', 'Viaje', 170); +END; +$$; + +--changeset panel:core-033f-fn-supplier-list splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_supplier_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_status text := nullif(btrim(payload->>'status'), ''); + v_rows jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY lower(s.name), s.id), '[]'::jsonb) + INTO v_rows + FROM suppliers s + WHERE s.tenant_id = v_tid + AND (v_status IS NULL OR s.status = v_status); + RETURN core.rpc_ok( + jsonb_build_object('suppliers', v_rows, 'count', jsonb_array_length(v_rows)), + format('Catálogo de proveedores: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid), + jsonb_build_object('fn', 'fn_supplier_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_supplier_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033g-fn-supplier-create splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_supplier_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_name text := coalesce(nullif(btrim(payload->>'name'), ''), ''); + v_legal text := coalesce(nullif(btrim(payload->>'legal_name'), ''), ''); + v_rfc text := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g')); + v_email text := coalesce(nullif(btrim(payload->>'email'), ''), ''); + v_phone text := coalesce(nullif(btrim(payload->>'phone'), ''), ''); + v_address text := coalesce(nullif(btrim(payload->>'address'), ''), ''); + v_city text := coalesce(nullif(btrim(payload->>'city'), ''), ''); + v_state text := coalesce(nullif(btrim(payload->>'state'), ''), ''); + v_zip text := coalesce(nullif(btrim(payload->>'postal_code'), ''), ''); + v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), ''); + v_id bigint; + v_row jsonb; +BEGIN + IF v_name = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_supplier_create: el nombre del proveedor es obligatorio', + jsonb_build_object('fn', 'fn_supplier_create', 'field', 'name')); + END IF; + IF v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN + RETURN core.rpc_err('VALIDATION', format('fn_supplier_create: RFC %s no tiene 12 o 13 caracteres', v_rfc), + jsonb_build_object('fn', 'fn_supplier_create', 'field', 'rfc')); + END IF; + INSERT INTO suppliers ( + tenant_id, name, legal_name, rfc, email, phone, address, city, state, postal_code, notes + ) VALUES ( + v_tid, v_name, v_legal, v_rfc, v_email, v_phone, v_address, v_city, v_state, v_zip, v_notes + ) RETURNING id INTO v_id; + SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id; + RETURN core.rpc_created( + jsonb_build_object('supplier', v_row), + format('Proveedor %s registrado', v_name), + jsonb_build_object('fn', 'fn_supplier_create', 'id', v_id, 'tenant_id', v_tid) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', format('Ya existe un proveedor con RFC %s en este tenant', v_rfc), + jsonb_build_object('fn', 'fn_supplier_create', 'rfc', v_rfc)); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_supplier_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033h-fn-supplier-update splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_supplier_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_tid integer := core._cost_tenant_id(payload); + v_rfc text; + v_row jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_supplier_update: id es obligatorio', + jsonb_build_object('fn', 'fn_supplier_update', 'field', 'id')); + END IF; + IF NOT EXISTS (SELECT 1 FROM suppliers WHERE id = v_id AND tenant_id = v_tid) THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_supplier_update: proveedor id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id)); + END IF; + v_rfc := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g')); + IF payload ? 'rfc' AND v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN + RETURN core.rpc_err('VALIDATION', format('fn_supplier_update: RFC %s no tiene 12 o 13 caracteres', v_rfc), + jsonb_build_object('fn', 'fn_supplier_update', 'field', 'rfc')); + END IF; + UPDATE suppliers SET + name = CASE WHEN payload ? 'name' THEN coalesce(nullif(btrim(payload->>'name'), ''), name) ELSE name END, + legal_name = CASE WHEN payload ? 'legal_name' THEN coalesce(nullif(btrim(payload->>'legal_name'), ''), '') ELSE legal_name END, + rfc = CASE WHEN payload ? 'rfc' THEN v_rfc ELSE rfc END, + email = CASE WHEN payload ? 'email' THEN coalesce(nullif(btrim(payload->>'email'), ''), '') ELSE email END, + phone = CASE WHEN payload ? 'phone' THEN coalesce(nullif(btrim(payload->>'phone'), ''), '') ELSE phone END, + address = CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE address END, + city = CASE WHEN payload ? 'city' THEN coalesce(nullif(btrim(payload->>'city'), ''), '') ELSE city END, + state = CASE WHEN payload ? 'state' THEN coalesce(nullif(btrim(payload->>'state'), ''), '') ELSE state END, + postal_code = CASE WHEN payload ? 'postal_code' THEN coalesce(nullif(btrim(payload->>'postal_code'), ''), '') ELSE postal_code END, + notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END, + status = CASE WHEN payload ? 'status' THEN coalesce(nullif(btrim(payload->>'status'), ''), status) ELSE status END, + updated_at = now() + WHERE id = v_id AND tenant_id = v_tid; + SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('supplier', v_row), + format('Proveedor id=%s actualizado', v_id), + jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', 'Ya existe otro proveedor con ese RFC en este tenant', + jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id)); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_supplier_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033i-fn-measure-unit-list splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_measure_unit_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_rows jsonb; +BEGIN + PERFORM core._measure_units_seed(v_tid); + SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.sort_order, u.code), '[]'::jsonb) + INTO v_rows + FROM measure_units u + WHERE u.tenant_id = v_tid + AND u.status = 'activo'; + RETURN core.rpc_ok( + jsonb_build_object('units', v_rows, 'count', jsonb_array_length(v_rows)), + format('Unidades de medida del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_measure_unit_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_measure_unit_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033j-fn-measure-unit-add splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_measure_unit_add(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_code text := lower(regexp_replace(btrim(COALESCE(payload->>'code', '')), '\s+', '', 'g')); + v_label text := COALESCE(NULLIF(btrim(payload->>'label'), ''), v_code); + v_id bigint; + v_row jsonb; +BEGIN + IF v_code = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_measure_unit_add: el código de unidad es obligatorio', + jsonb_build_object('fn', 'fn_measure_unit_add', 'field', 'code')); + END IF; + PERFORM core._measure_units_seed(v_tid); + INSERT INTO measure_units (tenant_id, code, label, sort_order) + VALUES (v_tid, v_code, v_label, 500) + RETURNING id INTO v_id; + SELECT to_jsonb(u) INTO v_row FROM measure_units u WHERE id = v_id; + RETURN core.rpc_created( + jsonb_build_object('unit', v_row), + format('Unidad de medida %s (%s) agregada', v_label, v_code), + jsonb_build_object('fn', 'fn_measure_unit_add', 'id', v_id, 'tenant_id', v_tid) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', format('Ya existe la unidad %s en este tenant', v_code), + jsonb_build_object('fn', 'fn_measure_unit_add', 'code', v_code)); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_measure_unit_add', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033k-fn-expense-create-supplier splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_expense_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint; + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_expense_date date := COALESCE(NULLIF(payload->>'expense_date', '')::date, CURRENT_DATE); + v_description text := coalesce(nullif(btrim(payload->>'description'), ''), ''); + v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint; + v_budget_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint; + v_quantity numeric := COALESCE((payload->>'quantity')::numeric, 0); + v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), ''); + v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0); + v_subtotal_in numeric := COALESCE((payload->>'subtotal')::numeric, 0); + v_iva_rate numeric; + v_tax_included boolean := COALESCE((payload->>'tax_included')::boolean, false); + v_total_in numeric := (payload->>'total')::numeric; + v_subtotal numeric; v_iva_amount numeric; v_total numeric; + v_is_deductible boolean := COALESCE((payload->>'is_deductible')::boolean, true); + v_counts_toward_budget boolean := COALESCE((payload->>'counts_toward_budget')::boolean, true); + v_supplier_id bigint := NULLIF(payload->>'supplier_id', '')::bigint; + v_supplier_name text := coalesce(nullif(btrim(payload->>'supplier_name'), ''), ''); + v_supplier_rfc text := upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), '')); + v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), ''); + v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer; + v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), ''); + v_id bigint; + v_warning jsonb; + v_row jsonb; +BEGIN + IF v_company_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_expense_create: company_id es obligatorio', + jsonb_build_object('fn', 'fn_expense_create', 'field', 'company_id')); + END IF; + IF v_description = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_expense_create: description es obligatoria', + jsonb_build_object('fn', 'fn_expense_create', 'field', 'description')); + END IF; + IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN + RETURN core.rpc_err('VALIDATION', format('fn_expense_create: empresa id=%s no encontrada', v_company_id), + jsonb_build_object('fn', 'fn_expense_create', 'company_id', v_company_id)); + END IF; + IF v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_expense_create', 'project_id', v_project_id)); + END IF; + IF v_supplier_id IS NOT NULL THEN + SELECT name, rfc INTO v_supplier_name, v_supplier_rfc + FROM suppliers + WHERE id = v_supplier_id AND tenant_id = v_tid AND status = 'activo'; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proveedor id=%s no encontrado', v_supplier_id), + jsonb_build_object('fn', 'fn_expense_create', 'supplier_id', v_supplier_id)); + END IF; + END IF; + IF v_subtotal_in <= 0 AND v_quantity > 0 AND v_unit_price > 0 THEN + v_subtotal_in := round(v_quantity * v_unit_price, 2); + END IF; + SELECT default_iva_rate INTO v_iva_rate FROM core._cost_settings(v_tid); + v_iva_rate := COALESCE((payload->>'iva_rate')::numeric, v_iva_rate); + SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total + FROM core._cost_calc_amounts(v_subtotal_in, v_iva_rate, v_tax_included, v_total_in) c; + IF v_budget_item_id IS NOT NULL THEN + SELECT chapter_id INTO v_budget_chapter_id FROM budget_items WHERE id = v_budget_item_id; + END IF; + INSERT INTO expense_entries ( + tenant_id, company_id, project_id, expense_date, source, description, + supplier_id, supplier_name, supplier_rfc, budget_chapter_id, budget_item_id, + quantity, unit, unit_price, subtotal, iva_rate, iva_amount, total, + tax_included, is_deductible, counts_toward_budget, status, + created_by_id, created_by_name, notes + ) VALUES ( + v_tid, v_company_id, v_project_id, v_expense_date, 'manual', v_description, + v_supplier_id, v_supplier_name, v_supplier_rfc, v_budget_chapter_id, v_budget_item_id, + v_quantity, v_unit, v_unit_price, v_subtotal, v_iva_rate, v_iva_amount, v_total, + v_tax_included, v_is_deductible, v_counts_toward_budget, 'confirmed', + v_created_by_id, v_created_by_name, v_notes + ) RETURNING id INTO v_id; + IF v_counts_toward_budget AND v_budget_item_id IS NOT NULL THEN + v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0); + END IF; + SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id; + RETURN core.rpc_created( + jsonb_build_object('expense', v_row, 'budget_warning', v_warning), + format('Gasto registrado por %s (subtotal %s, IVA %s)', v_description, v_subtotal, v_iva_amount), + jsonb_build_object('fn', 'fn_expense_create', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_expense_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033l-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core._measure_units_seed(integer) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_supplier_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_supplier_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_supplier_update(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_measure_unit_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_measure_unit_add(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_expense_create(jsonb) TO panels_core_app; diff --git a/db/core/changesets/034-fn-expense-update-reclassify.sql b/db/core/changesets/034-fn-expense-update-reclassify.sql new file mode 100644 index 0000000..15e1c3b --- /dev/null +++ b/db/core/changesets/034-fn-expense-update-reclassify.sql @@ -0,0 +1,111 @@ +--liquibase formatted sql +-- PANELS · core · reclasificar gasto (concepto, proveedor, obra) + +--changeset panel:core-034a-fn-expense-update-reclassify splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_expense_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_tid integer := core._cost_tenant_id(payload); + v_cur expense_entries%ROWTYPE; + v_warning jsonb; + v_row jsonb; + v_subtotal numeric; v_iva_amount numeric; v_total numeric; + v_line numeric; + v_supplier_id bigint; + v_supplier_name text; + v_supplier_rfc text; + v_budget_item_id bigint; + v_chapter_id bigint; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_expense_update: id es obligatorio', + jsonb_build_object('fn', 'fn_expense_update', 'field', 'id')); + END IF; + SELECT * INTO v_cur FROM expense_entries WHERE id = v_id; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_expense_update: gasto id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_expense_update', 'id', v_id)); + END IF; + IF v_cur.is_locked OR v_cur.source <> 'manual' THEN + RETURN core.rpc_err('VALIDATION', 'fn_expense_update: solo se editan gastos manuales no bloqueados', + jsonb_build_object('fn', 'fn_expense_update', 'id', v_id, 'source', v_cur.source)); + END IF; + + IF payload ? 'supplier_id' THEN + v_supplier_id := NULLIF(payload->>'supplier_id', '')::bigint; + IF v_supplier_id IS NOT NULL THEN + SELECT name, rfc INTO v_supplier_name, v_supplier_rfc + FROM suppliers + WHERE id = v_supplier_id AND tenant_id = v_tid AND status = 'activo'; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_expense_update: proveedor id=%s no encontrado', v_supplier_id), + jsonb_build_object('fn', 'fn_expense_update', 'supplier_id', v_supplier_id)); + END IF; + ELSE + v_supplier_name := ''; + v_supplier_rfc := ''; + END IF; + END IF; + + IF payload ? 'budget_item_id' THEN + v_budget_item_id := NULLIF(payload->>'budget_item_id', '')::bigint; + IF v_budget_item_id IS NOT NULL THEN + SELECT chapter_id INTO v_chapter_id FROM budget_items WHERE id = v_budget_item_id; + END IF; + END IF; + + UPDATE expense_entries SET + expense_date = COALESCE(NULLIF(payload->>'expense_date', '')::date, expense_date), + description = COALESCE(nullif(btrim(payload->>'description'), ''), description), + project_id = CASE WHEN payload ? 'project_id' THEN NULLIF(payload->>'project_id', '')::bigint ELSE project_id END, + supplier_id = CASE WHEN payload ? 'supplier_id' THEN v_supplier_id ELSE supplier_id END, + supplier_name = CASE + WHEN payload ? 'supplier_id' THEN coalesce(v_supplier_name, '') + WHEN payload ? 'supplier_name' THEN coalesce(nullif(btrim(payload->>'supplier_name'), ''), '') + ELSE supplier_name END, + supplier_rfc = CASE + WHEN payload ? 'supplier_id' THEN coalesce(v_supplier_rfc, '') + WHEN payload ? 'supplier_rfc' THEN upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), '')) + ELSE supplier_rfc END, + budget_item_id = CASE WHEN payload ? 'budget_item_id' THEN v_budget_item_id ELSE budget_item_id END, + budget_chapter_id = CASE WHEN payload ? 'budget_item_id' THEN v_chapter_id ELSE budget_chapter_id END, + quantity = CASE WHEN payload ? 'quantity' THEN COALESCE((payload->>'quantity')::numeric, 0) ELSE quantity END, + unit = CASE WHEN payload ? 'unit' THEN coalesce(nullif(btrim(payload->>'unit'), ''), '') ELSE unit END, + unit_price = CASE WHEN payload ? 'unit_price' THEN COALESCE((payload->>'unit_price')::numeric, 0) ELSE unit_price END, + tax_included = CASE WHEN payload ? 'tax_included' THEN COALESCE((payload->>'tax_included')::boolean, false) ELSE tax_included END, + notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END + WHERE id = v_id; + SELECT * INTO v_cur FROM expense_entries WHERE id = v_id; + v_line := CASE + WHEN v_cur.quantity > 0 AND v_cur.unit_price > 0 THEN round(v_cur.quantity * v_cur.unit_price, 2) + ELSE v_cur.subtotal + END; + SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total + FROM core._cost_calc_amounts( + v_line, + v_cur.iva_rate, + v_cur.tax_included, + CASE WHEN v_cur.tax_included THEN v_line ELSE NULL END + ) c; + UPDATE expense_entries SET subtotal = v_subtotal, iva_amount = v_iva_amount, total = v_total WHERE id = v_id; + IF v_cur.counts_toward_budget AND v_cur.budget_item_id IS NOT NULL THEN + v_warning := core._cost_budget_warning(v_tid, v_cur.budget_item_id, 0, 0); + END IF; + SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('expense', v_row, 'budget_warning', v_warning), + format('Gasto id=%s actualizado', v_id), + jsonb_build_object('fn', 'fn_expense_update', 'id', v_id, 'budget_item_id', v_cur.budget_item_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_expense_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-034b-fn-expense-update-grant endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_expense_update(jsonb) TO panels_core_app; diff --git a/db/core/changesets/035-warehouse-materials-catalog.sql b/db/core/changesets/035-warehouse-materials-catalog.sql new file mode 100644 index 0000000..5193cc4 --- /dev/null +++ b/db/core/changesets/035-warehouse-materials-catalog.sql @@ -0,0 +1,464 @@ +--liquibase formatted sql +-- PANELS · core · catálogo de materiales de almacén (unidades + proveedores) + +--changeset panel:core-035a-warehouse-materials-unit-id endDelimiter:; splitStatements:true +ALTER TABLE core.warehouse_materials + ADD COLUMN IF NOT EXISTS unit_id BIGINT REFERENCES core.measure_units(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_core_warehouse_materials_unit ON core.warehouse_materials(unit_id); + +--changeset panel:core-035b-warehouse-material-suppliers endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='warehouse_material_suppliers' +CREATE TABLE core.warehouse_material_suppliers ( + material_id BIGINT NOT NULL REFERENCES core.warehouse_materials(id) ON DELETE CASCADE, + supplier_id BIGINT NOT NULL REFERENCES core.suppliers(id) ON DELETE CASCADE, + PRIMARY KEY (material_id, supplier_id) +); +CREATE INDEX idx_core_wh_mat_suppliers_supplier ON core.warehouse_material_suppliers(supplier_id); +GRANT SELECT, INSERT, UPDATE, DELETE ON core.warehouse_material_suppliers TO panels_core_app; + +--changeset panel:core-035c-rls-warehouse-material-suppliers endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pg_policies WHERE schemaname='core' AND tablename='warehouse_material_suppliers' AND policyname='tenant_isolation' +ALTER TABLE core.warehouse_material_suppliers ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON core.warehouse_material_suppliers + USING (EXISTS ( + SELECT 1 FROM core.warehouse_materials m + WHERE m.id = material_id + AND m.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM core.warehouse_materials m + WHERE m.id = material_id + AND m.tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer + )); + +--changeset panel:core-035d-fn-warehouse-material-list splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_warehouse_material_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_rows jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY lower(x.name), x.code), '[]'::jsonb) + INTO v_rows + FROM ( + SELECT + m.id, + m.code, + m.name, + m.unit, + m.unit_id, + COALESCE(u.label, m.unit) AS unit_label, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY lower(s.name)) + FROM warehouse_material_suppliers ms + JOIN suppliers s ON s.id = ms.supplier_id + WHERE ms.material_id = m.id + ), '[]'::jsonb) AS suppliers + FROM warehouse_materials m + LEFT JOIN measure_units u ON u.id = m.unit_id + WHERE m.tenant_id = v_tid + ) x; + RETURN core.rpc_ok( + jsonb_build_object('materials', v_rows, 'count', jsonb_array_length(v_rows)), + format('Catálogo de materiales del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_warehouse_material_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_warehouse_material_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-035e-fn-warehouse-material-upsert splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_warehouse_material_upsert(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_code text := upper(coalesce(nullif(btrim(payload->>'code'), ''), '')); + v_name text := coalesce(nullif(btrim(payload->>'name'), ''), ''); + v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), ''); + v_unit_id bigint := NULLIF(payload->>'unit_id', '')::bigint; + v_row jsonb; +BEGIN + IF v_unit_id IS NOT NULL THEN + SELECT code INTO v_unit FROM measure_units WHERE id = v_unit_id AND tenant_id = v_tid; + IF NOT FOUND THEN + RETURN core.rpc_err('VALIDATION', format('fn_warehouse_material_upsert: unidad id=%s no encontrada', v_unit_id), + jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'unit_id', v_unit_id)); + END IF; + END IF; + IF v_id IS NOT NULL THEN + IF NOT EXISTS (SELECT 1 FROM warehouse_materials WHERE id = v_id AND tenant_id = v_tid) THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_warehouse_material_upsert: material id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id)); + END IF; + IF v_code = '' THEN + SELECT code INTO v_code FROM warehouse_materials WHERE id = v_id; + END IF; + IF v_name = '' THEN + SELECT name INTO v_name FROM warehouse_materials WHERE id = v_id; + END IF; + UPDATE warehouse_materials + SET code = v_code, + name = v_name, + unit = CASE WHEN payload ? 'unit' OR payload ? 'unit_id' THEN v_unit ELSE unit END, + unit_id = CASE WHEN payload ? 'unit_id' THEN v_unit_id ELSE unit_id END + WHERE id = v_id AND tenant_id = v_tid; + ELSE + IF v_code = '' OR v_name = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_material_upsert: code y name son obligatorios', + jsonb_build_object('fn', 'fn_warehouse_material_upsert')); + END IF; + INSERT INTO warehouse_materials (tenant_id, code, name, unit, unit_id) + VALUES (v_tid, v_code, v_name, v_unit, v_unit_id) + ON CONFLICT (tenant_id, code) DO UPDATE + SET name = EXCLUDED.name, + unit = EXCLUDED.unit, + unit_id = COALESCE(EXCLUDED.unit_id, warehouse_materials.unit_id) + RETURNING id INTO v_id; + END IF; + IF payload ? 'supplier_ids' THEN + DELETE FROM warehouse_material_suppliers WHERE material_id = v_id; + INSERT INTO warehouse_material_suppliers (material_id, supplier_id) + SELECT v_id, x.sid + FROM ( + SELECT DISTINCT NULLIF(elem #>> '{}', '')::bigint AS sid + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(payload->'supplier_ids') = 'array' + THEN payload->'supplier_ids' + ELSE '[]'::jsonb + END + ) elem + ) x + WHERE x.sid IS NOT NULL + AND EXISTS (SELECT 1 FROM suppliers s WHERE s.id = x.sid AND s.tenant_id = v_tid); + END IF; + SELECT to_jsonb(x) INTO v_row + FROM ( + SELECT + m.id, m.code, m.name, m.unit, m.unit_id, + COALESCE(u.label, m.unit) AS unit_label, + COALESCE(( + SELECT jsonb_agg(jsonb_build_object('id', s.id, 'name', s.name) ORDER BY lower(s.name)) + FROM warehouse_material_suppliers ms + JOIN suppliers s ON s.id = ms.supplier_id + WHERE ms.material_id = m.id + ), '[]'::jsonb) AS suppliers + FROM warehouse_materials m + LEFT JOIN measure_units u ON u.id = m.unit_id + WHERE m.id = v_id + ) x; + RETURN core.rpc_ok( + v_row, + format('Material %s (%s) registrado', v_code, v_name), + jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'id', v_id) + ); +EXCEPTION WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', format('Ya existe un material con clave %s', v_code), + jsonb_build_object('fn', 'fn_warehouse_material_upsert', 'code', v_code)); +WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_warehouse_material_upsert', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-035f-fn-warehouse-stock-list splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_warehouse_stock_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint; + v_global boolean := COALESCE((payload->>'global')::boolean, false); + v_tid integer := core._cost_tenant_id(payload); + v_rows jsonb; + v_consolidated jsonb := '[]'::jsonb; +BEGIN + IF v_global THEN + SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM ( + SELECT + w.id AS warehouse_id, + w.kind, + w.project_id, + p.code AS project_code, + p.name AS project_name, + CASE WHEN w.kind = 'central' THEN 'Almacén global' + ELSE COALESCE(p.code || ' — ' || p.name, 'Obra') + END AS warehouse_label, + m.id AS material_id, + m.code AS material_code, + m.name AS material_name, + m.unit, + COALESCE(u.label, m.unit) AS unit_label, + s.qty_on_hand, + s.avg_unit_cost, + round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value + FROM warehouse_stock s + JOIN warehouses w ON w.id = s.warehouse_id + JOIN warehouse_materials m ON m.id = s.material_id + LEFT JOIN measure_units u ON u.id = m.unit_id + LEFT JOIN projects p ON p.id = w.project_id + WHERE w.tenant_id = v_tid AND w.status = 'open' AND s.qty_on_hand > 0 + ) x; + SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_consolidated FROM ( + SELECT + m.id AS material_id, + m.code AS material_code, + m.name AS material_name, + m.unit, + COALESCE(u.label, m.unit) AS unit_label, + SUM(s.qty_on_hand) AS qty_on_hand, + CASE WHEN SUM(s.qty_on_hand) > 0 + THEN round(SUM(s.qty_on_hand * s.avg_unit_cost) / SUM(s.qty_on_hand), 4) + ELSE 0 + END AS avg_unit_cost, + round(SUM(s.qty_on_hand * s.avg_unit_cost), 2) AS stock_value, + COUNT(*) FILTER (WHERE s.qty_on_hand > 0)::integer AS warehouse_count + FROM warehouse_stock s + JOIN warehouses w ON w.id = s.warehouse_id + JOIN warehouse_materials m ON m.id = s.material_id + LEFT JOIN measure_units u ON u.id = m.unit_id + WHERE w.tenant_id = v_tid AND w.status = 'open' AND s.qty_on_hand > 0 + GROUP BY m.id, m.code, m.name, m.unit, u.label + ) x; + ELSE + IF v_warehouse_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_stock_list: warehouse_id es obligatorio si global=false', + jsonb_build_object('fn', 'fn_warehouse_stock_list')); + END IF; + SELECT COALESCE(jsonb_agg(to_jsonb(x)), '[]'::jsonb) INTO v_rows FROM ( + SELECT + s.*, + m.code AS material_code, + m.name AS material_name, + m.unit, + COALESCE(u.label, m.unit) AS unit_label, + CASE WHEN w.kind = 'central' THEN 'Almacén global' + ELSE COALESCE(p.code || ' — ' || p.name, 'Obra') + END AS warehouse_label, + w.kind, + w.project_id, + p.code AS project_code, + round(s.qty_on_hand * s.avg_unit_cost, 2) AS stock_value + FROM warehouse_stock s + JOIN warehouses w ON w.id = s.warehouse_id + JOIN warehouse_materials m ON m.id = s.material_id + LEFT JOIN measure_units u ON u.id = m.unit_id + LEFT JOIN projects p ON p.id = w.project_id + WHERE s.warehouse_id = v_warehouse_id AND s.qty_on_hand > 0 + ) x; + END IF; + RETURN core.rpc_ok( + jsonb_build_object('stock', COALESCE(v_rows, '[]'::jsonb), 'consolidated', v_consolidated), + format('Stock consultado: %s línea(s)', jsonb_array_length(COALESCE(v_rows, '[]'::jsonb))), + jsonb_build_object('fn', 'fn_warehouse_stock_list', 'warehouse_id', v_warehouse_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_warehouse_stock_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-035g-fn-warehouse-transfer-create splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_warehouse_transfer_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_from_id bigint := NULLIF(payload->>'from_warehouse_id', '')::bigint; + v_to_id bigint := NULLIF(payload->>'to_warehouse_id', '')::bigint; + v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint; + v_qty numeric := COALESCE((payload->>'quantity')::numeric, 0); + v_transfer_date date := COALESCE(NULLIF(payload->>'transfer_date', '')::date, CURRENT_DATE); + v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), ''); + v_from record; + v_to record; + v_stock record; + v_unit_cost numeric; + v_transfer_id bigint; + v_created_by text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), ''); +BEGIN + IF v_from_id IS NULL OR v_to_id IS NULL OR v_material_id IS NULL OR v_qty <= 0 OR v_from_id = v_to_id THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: origen, destino, material y cantidad válidos son obligatorios', + jsonb_build_object('fn', 'fn_warehouse_transfer_create')); + END IF; + SELECT * INTO v_from FROM warehouses WHERE id = v_from_id AND tenant_id = v_tid; + IF NOT FOUND OR v_from.status <> 'open' THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: el almacén origen no está abierto', + jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'from_warehouse_id', v_from_id)); + END IF; + SELECT * INTO v_to FROM warehouses WHERE id = v_to_id AND tenant_id = v_tid; + IF NOT FOUND OR v_to.status <> 'open' THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: el almacén destino no está abierto', + jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'to_warehouse_id', v_to_id)); + END IF; + SELECT * INTO v_stock FROM warehouse_stock WHERE warehouse_id = v_from_id AND material_id = v_material_id; + IF NOT FOUND OR v_stock.qty_on_hand < v_qty THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_transfer_create: stock insuficiente en almacén origen', + jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'from_warehouse_id', v_from_id)); + END IF; + v_unit_cost := v_stock.avg_unit_cost; + INSERT INTO warehouse_transfers ( + tenant_id, from_warehouse_id, to_warehouse_id, material_id, quantity, unit_cost, + status, transfer_date, notes, created_by_name + ) VALUES ( + v_tid, v_from_id, v_to_id, v_material_id, v_qty, v_unit_cost, + 'completed', v_transfer_date, v_notes, v_created_by + ) RETURNING id INTO v_transfer_id; + PERFORM core._warehouse_stock_apply(v_from_id, v_material_id, -v_qty, v_unit_cost); + PERFORM core._warehouse_stock_apply(v_to_id, v_material_id, v_qty, v_unit_cost); + INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name) + VALUES (v_tid, v_from_id, v_material_id, 'transfer_out', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by); + INSERT INTO warehouse_movements (tenant_id, warehouse_id, material_id, movement_type, transfer_id, movement_date, quantity, unit_cost, total_cost, created_by_name) + VALUES (v_tid, v_to_id, v_material_id, 'transfer_in', v_transfer_id, v_transfer_date, v_qty, v_unit_cost, round(v_qty*v_unit_cost,2), v_created_by); + RETURN core.rpc_created( + jsonb_build_object('transfer_id', v_transfer_id, 'quantity', v_qty, 'unit_cost', v_unit_cost), + format('Transferencia %s unidades del almacén %s al %s', v_qty, v_from_id, v_to_id), + jsonb_build_object('fn', 'fn_warehouse_transfer_create', 'transfer_id', v_transfer_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_warehouse_transfer_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-035h-fn-warehouse-movement-list splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_warehouse_movement_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_warehouse_id bigint := NULLIF(payload->>'warehouse_id', '')::bigint; + v_material_id bigint := NULLIF(payload->>'material_id', '')::bigint; + v_global boolean := COALESCE((payload->>'global')::boolean, false); + v_rows jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.movement_date DESC, x.id DESC), '[]'::jsonb) INTO v_rows + FROM ( + SELECT + m.*, + mat.code AS material_code, + mat.name AS material_name, + bi.description AS budget_item_description, + CASE WHEN w.kind = 'central' THEN 'Almacén global' + ELSE COALESCE(p.code || ' — ' || p.name, 'Obra') + END AS warehouse_label + FROM warehouse_movements m + JOIN warehouse_materials mat ON mat.id = m.material_id + JOIN warehouses w ON w.id = m.warehouse_id + LEFT JOIN projects p ON p.id = w.project_id + LEFT JOIN budget_items bi ON bi.id = m.budget_item_id + WHERE (v_tid <= 0 OR m.tenant_id = v_tid) + AND (v_warehouse_id IS NULL OR m.warehouse_id = v_warehouse_id) + AND (v_material_id IS NULL OR m.material_id = v_material_id) + ) x; + RETURN core.rpc_ok(jsonb_build_object('movements', v_rows), + format('Kardex: %s movimiento(s)', jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_warehouse_movement_list', 'warehouse_id', v_warehouse_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_warehouse_movement_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-035i-fn-warehouse-close-project splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_warehouse_close_project(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_force boolean := COALESCE((payload->>'force')::boolean, false); + v_transfer_remaining boolean := COALESCE((payload->>'transfer_remaining')::boolean, false); + v_closed_by text := coalesce(nullif(btrim(payload->>'closed_by_name'), ''), ''); + v_notes text := coalesce(nullif(btrim(payload->>'close_notes'), ''), ''); + v_wh record; + v_central_id bigint; + v_stock_count integer; + v_line record; + v_xfer jsonb; +BEGIN + IF v_project_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_warehouse_close_project: project_id es obligatorio', + jsonb_build_object('fn', 'fn_warehouse_close_project')); + END IF; + SELECT * INTO v_wh FROM warehouses WHERE project_id = v_project_id AND kind = 'project'; + IF NOT FOUND THEN + RETURN core.rpc_ok(jsonb_build_object('closed', false, 'reason', 'no_warehouse'), + format('Proyecto %s no tenía almacén abierto', v_project_id), + jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id)); + END IF; + IF v_wh.status = 'closed' THEN + RETURN core.rpc_ok(jsonb_build_object('warehouse_id', v_wh.id, 'status', 'closed'), + format('Almacén del proyecto %s ya estaba cerrado', v_project_id), + jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id)); + END IF; + IF v_transfer_remaining THEN + PERFORM core.fn_warehouse_ensure_central(jsonb_build_object('tenant_id', v_wh.tenant_id)); + SELECT id INTO v_central_id FROM warehouses WHERE tenant_id = v_wh.tenant_id AND kind = 'central' LIMIT 1; + IF v_central_id IS NOT NULL THEN + FOR v_line IN + SELECT material_id, qty_on_hand + FROM warehouse_stock + WHERE warehouse_id = v_wh.id AND qty_on_hand > 0 + LOOP + v_xfer := core.fn_warehouse_transfer_create(jsonb_build_object( + 'tenant_id', v_wh.tenant_id, + 'from_warehouse_id', v_wh.id, + 'to_warehouse_id', v_central_id, + 'material_id', v_line.material_id, + 'quantity', v_line.qty_on_hand, + 'notes', 'Traspaso automático al cerrar almacén de obra', + 'created_by_name', v_closed_by + )); + IF NOT COALESCE((v_xfer->>'ok')::boolean, false) THEN + RETURN v_xfer; + END IF; + END LOOP; + END IF; + END IF; + SELECT COUNT(*)::integer INTO v_stock_count FROM warehouse_stock + WHERE warehouse_id = v_wh.id AND qty_on_hand > 0; + IF v_stock_count > 0 AND NOT v_force THEN + RETURN core.rpc_err('VALIDATION', + format('fn_warehouse_close_project: quedan %s material(es) con stock; transfiera al almacén global o ajuste antes de cerrar', v_stock_count), + jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id, 'stock_lines', v_stock_count)); + END IF; + UPDATE warehouses SET status = 'closed', closed_at = now(), closed_by_name = v_closed_by, close_notes = v_notes + WHERE id = v_wh.id; + RETURN core.rpc_ok( + jsonb_build_object( + 'warehouse_id', v_wh.id, + 'status', 'closed', + 'forced', v_force, + 'transferred_remaining', v_transfer_remaining + ), + format('Almacén del proyecto %s cerrado', v_project_id), + jsonb_build_object('fn', 'fn_warehouse_close_project', 'project_id', v_project_id)); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_warehouse_close_project', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-035j-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core.fn_warehouse_material_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_warehouse_material_upsert(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_warehouse_stock_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_warehouse_transfer_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_warehouse_movement_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_warehouse_close_project(jsonb) TO panels_core_app; diff --git a/web-panel/assets/css/desktop-v2.css b/web-panel/assets/css/desktop-v2.css index 44ee5e9..aec53a5 100644 --- a/web-panel/assets/css/desktop-v2.css +++ b/web-panel/assets/css/desktop-v2.css @@ -1844,7 +1844,8 @@ html.dark .p-tieredmenu, html.dark .p-popover, html.dark .p-datepicker, html.dark .p-select-overlay, -html.dark .p-multiselect-overlay { +html.dark .p-multiselect-overlay, +html.dark .p-autocomplete-overlay { --p-content-background: #111b2e; } @@ -2165,7 +2166,8 @@ html.dark .p-select-label.p-placeholder { html.dark .p-select-overlay, html.dark .p-datepicker-panel, -html.dark .p-multiselect-overlay { +html.dark .p-multiselect-overlay, +html.dark .p-autocomplete-overlay { background: #111b2e !important; border-color: #243044 !important; color: #e2e8f0 !important; @@ -2237,6 +2239,58 @@ html.dark .p-dialog .p-dialog-header-icon { .cost-critical { background: #ffebee !important; color: #b42318 !important; } .cost-critical-text { color: #b42318; font-weight: 600; } +.budget-item-picker-overlay.p-select-overlay, +.budget-item-picker-overlay.p-autocomplete-overlay, +.budget-item-picker-overlay { + overflow-x: hidden; + box-sizing: border-box; +} +.budget-item-picker-overlay .p-select-list, +.budget-item-picker-overlay .p-autocomplete-list, +.budget-item-picker-overlay ul { + width: 100%; + margin: 0; + padding: 4px 0; + list-style: none; +} +.budget-item-picker-overlay .p-select-option, +.budget-item-picker-overlay .p-autocomplete-option, +.budget-item-picker-overlay li { + white-space: normal; + overflow-wrap: anywhere; + align-items: flex-start; +} +.budget-item-picker .budget-item-value { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + max-width: 100%; +} +.budget-item-picker .budget-item-value-desc { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #5b6573; +} +.budget-item-option { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + max-width: 100%; +} +.budget-item-option-desc { + color: #5b6573; + font-size: 12px; + line-height: 1.35; + overflow-wrap: anywhere; +} +html.dark .budget-item-option-desc, +html.dark .budget-item-picker .budget-item-value-desc { + color: #94a3b8; +} + /* Grouped menubar: single row, icons, card-style submenus. */ .menubar.p-menubar { display: flex; diff --git a/web-panel/components/BadgeCard.vue b/web-panel/components/BadgeCard.vue index c9b6659..8e55885 100644 --- a/web-panel/components/BadgeCard.vue +++ b/web-panel/components/BadgeCard.vue @@ -2,7 +2,7 @@