Unir fixes de las pruebas (PDF, presupuesto, programa, gastos, almacén, documentos, gafete, nómina)

<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
Unifica los PRs de las pruebas de hoy en una sola rama contra `main`, con el changelog de Liquibase ya reconciliado.

**Incluye**
- #11 ver PDF de proyecto (`doc_id`)
- #12 toast al subir presupuesto que no es Excel
- #13 preview del programa de obra
- #14 ribbon de Control presupuesto (ya venía dentro de #15)
- #15 captura de gastos, proveedores y unidades
- #16 almacén global / abrir-cerrar / catálogo
- #17 documentos sin fecha de emisión ni vencimiento
- #18 gafete: `fn_badge_job_get` + empresa del tenant
- #19 asistencia y faltas L–S en Nómina

**No mergear los PRs sueltos** (#11–#19) si mergeas este: chocan en `changelog-master.xml` porque varios añadieron un changeset `032`. Aquí están todos incluidos con IDs distintos.

**Cómo probar (rápido)**
- Proyecto: abrir un PDF del expediente
- Presupuesto: subir un archivo que no sea Excel y ver el toast
- Programa de obra: importar Excel y ver preview
- Gastos: alta con PrimeVue y reclasificar sin concepto
- Almacén: global + abrir/cerrar obra + catálogo
- Padrón: subir documento sin fechas
- Gafetes: consultar/descargar job y ver el nombre de la empresa
- Nómina → Jornal: columnas L–S para marcar asistencia/falta

Mergea **este** PR. Después se pueden cerrar #11–#19.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-d8a5d34a-ead9-4912-8296-322cff799783?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-d8a5d34a-ead9-4912-8296-322cff799783&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
This commit is contained in:
Alberto Martinez 2026-09-08 21:23:23 +00:00
commit 4750dab95f
44 changed files with 3940 additions and 391 deletions

View file

@ -801,10 +801,49 @@ const FORMAT_LABEL: Record<ParsedBudget["format"], string> = {
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);

View file

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

View file

@ -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");

10
api/document_rpc.ts Normal file
View file

@ -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<string, unknown>,
): Record<string, unknown> {
return { ...payload, doc_id: payload.doc_id ?? payload.id };
}

13
api/document_rpc_test.ts Normal file
View file

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

View file

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

View file

@ -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", () => {

View file

@ -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<Record<string, unknown>>();
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<Record<string, unknown>>();
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<Record<string, unknown>>();
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);
});
}

View file

@ -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<string, unknown>,
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<FormData> } },
): 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);

View file

@ -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<string, unknown> | 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<Record<string, unknown>>,
days: string[],
tenantId: number,
): Promise<Array<Record<string, unknown>>> {
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<Record<string, unknown>>) ?? []).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<Record<string, unknown>>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart });
if (!env.ok) throwFromRpc(env);
const data = env.data ?? {};
const today = await resolveToday(db, tenantId);
const start = String((data.week as Record<string, unknown>)?.week_start ?? weekStart);
return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) };
const week = (data.week ?? null) as Record<string, unknown> | null;
const start = isoDay(week?.week_start) || weekStart;
const days = weekDays(start);
const sheets = await attachJornalCells(
db,
(data.sheets as Array<Record<string, unknown>>) ?? [],
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<string> {

View file

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

View file

@ -101,16 +101,34 @@ export async function generateBadgePdf(
workerIds: number[],
): Promise<Uint8Array> {
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;

View file

@ -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<Record<string, unknown>>();
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<Record<string, unknown>>();
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");

View file

@ -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 {

View file

@ -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<boolean> {
return await denyUnlessProjectScope(c, id);
}
async function readUpload(c: { req: { formData: () => Promise<FormData> } }): Promise<Uint8Array> {
function respondOk<T>(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) => {

View file

@ -38,5 +38,12 @@
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-rpc-document-get-id.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-fix-work-program-jsonb-object-length.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-document-validity-from-upload.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-fn-badge-job-get.sql" relativeToChangelogFile="true"/>
<include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
<include file="changesets/034-fn-expense-update-reclassify.sql" relativeToChangelogFile="true"/>
<include file="changesets/035-warehouse-materials-catalog.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

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

View file

@ -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;
$$;

View file

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

View file

@ -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;
$$;

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@
<article class="badge" :class="{ 'badge-back-rot': side === 'back' && rotated, fluid }">
<template v-if="side === 'front'">
<div class="badge-logos">
<div class="logo-l">Construcciones<br />Arctec</div>
<div class="logo-l">{{ companyName || "—" }}</div>
<div class="logo-r">{{ projectName }}</div>
</div>
<div class="badge-photo">
@ -47,6 +47,7 @@ const props = defineProps<{
blood: string;
riskColor: string;
riskText: string;
companyName?: string;
projectName: string;
projectCode: string;
photoSrc?: string;

View file

@ -9,16 +9,17 @@
:content-style="{ overflow: 'auto', maxHeight: 'calc(100vh - 9rem)' }"
>
<Message severity="info" :closable="false">
Suba el Excel de Opus/Neodata, uno plano o la plantilla del panel. Primero se muestra lo encontrado;
Suba un Excel .xlsx o .xls (Opus/Neodata, plano o la plantilla del panel). Primero se muestra lo encontrado;
nada se guarda hasta que confirme. Reemplazará el presupuesto de <strong>{{ projectName }}</strong>.
</Message>
<div class="flex flex-wrap align-items-center gap-2 mt-3">
<Button label="Descargar plantilla" icon="pi pi-download" outlined :loading="templateLoading" @click="downloadTemplate" />
<input ref="fileInput" type="file" accept=".xlsx,.xls,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" class="hidden-file" @change="onNativeFile" />
<input ref="fileInput" type="file" :accept="EXCEL_ACCEPT" class="hidden-file" @change="onNativeFile" />
<Button label="Seleccionar Excel" icon="pi pi-plus" :disabled="busy" @click="pickFile" />
<span v-if="pending" class="muted">{{ pending.name }}</span>
</div>
<div class="muted mt-2">Solo archivos Excel (.xlsx o .xls). Otros tipos se rechazan antes de subirlos.</div>
<ProgressBar v-if="busy" mode="indeterminate" class="mt-3" style="height: 4px" />
<template v-if="preview">
@ -82,6 +83,8 @@
<script setup lang="ts">
import type { TreeNode } from "primevue/treenode";
import { displayErrorMessage } from "~/composables/api-response";
import { EXCEL_ACCEPT, excelOnlyMessage, isExcelSpreadsheet } from "~/utils/excelFile";
type PreviewNode = {
wbs: string;
@ -172,10 +175,21 @@ function onNativeFile(event: Event) {
if (file) readFile(file);
}
function showReadError(message: string, summary = "No se pudo importar el presupuesto") {
error.value = message;
toast.add({ severity: "error", summary, detail: message, life: 5000 });
}
async function readFile(file: File) {
if (!file) return;
if (!props.projectId) {
error.value = "Seleccione una obra en el árbol antes de importar.";
showReadError("Seleccione una obra en el árbol antes de importar.");
return;
}
if (!isExcelSpreadsheet(file)) {
pending.value = null;
preview.value = null;
showReadError(excelOnlyMessage(file.name), "Archivo no permitido");
return;
}
reading.value = true;
@ -187,13 +201,18 @@ async function readFile(file: File) {
body.append("file", file);
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/budget/preview`, { method: "POST", body });
if (preview.value.errors?.length && !preview.value.totals.item_count) {
error.value = preview.value.errors[0]?.messages?.[0] || "No se pudo leer el Excel";
const message = preview.value.errors[0]?.messages?.[0] || "El Excel no tiene el formato de presupuesto esperado.";
pending.value = null;
preview.value = null;
showReadError(message, "Formato de Excel no válido");
}
} catch (err) {
pending.value = null;
const detail = err as { data?: { errors?: { messages: string[] }[] } };
error.value = detail.data?.errors?.[0]?.messages?.[0] || (err instanceof Error ? err.message : "No se pudo leer el Excel");
toast.add({ severity: "error", summary: error.value, life: 3500 });
const detail = err as { data?: unknown };
showReadError(
displayErrorMessage(detail.data ?? err, err instanceof Error ? err.message : "No se pudo leer el Excel"),
"Formato de Excel no válido",
);
} finally {
reading.value = false;
}
@ -217,8 +236,9 @@ async function confirmSave() {
emit("imported");
visibleModel.value = false;
} catch (err) {
error.value = err instanceof Error ? err.message : "No se pudo guardar";
toast.add({ severity: "error", summary: error.value, life: 3500 });
const detail = err as { data?: unknown };
error.value = displayErrorMessage(detail.data ?? err, err instanceof Error ? err.message : "No se pudo guardar el presupuesto");
toast.add({ severity: "error", summary: "No se pudo guardar el presupuesto", detail: error.value, life: 5000 });
} finally {
saving.value = false;
}

View file

@ -1,14 +1,35 @@
<template>
<AutoComplete
v-model="selected"
:suggestions="filtered"
<Select
:model-value="modelValue ?? null"
:options="items"
option-label="label"
option-value="id"
placeholder="Buscar concepto del presupuesto..."
class="w-full"
force-selection
@complete="onSearch"
@item-select="onSelect"
/>
filter
filter-placeholder="Clave o descripción"
show-clear
fluid
class="w-full budget-item-picker"
overlay-class="budget-item-picker-overlay"
panel-class="budget-item-picker-overlay"
empty-filter-message="Sin conceptos que coincidan"
empty-message="Esta obra no tiene partidas cargadas"
@update:model-value="onUpdate"
>
<template #value="{ value, placeholder }">
<span v-if="selected" class="budget-item-value">
<span class="identifier">{{ selected.code || "—" }}</span>
<span class="budget-item-value-desc">{{ selected.description }}</span>
</span>
<span v-else class="p-select-placeholder">{{ placeholder }}</span>
</template>
<template #option="{ option }">
<div class="budget-item-option">
<span class="identifier">{{ option.code || "—" }}</span>
<span class="budget-item-option-desc">{{ option.description }}</span>
</div>
</template>
</Select>
</template>
<script setup lang="ts">
@ -29,8 +50,8 @@ type BudgetOption = {
const { api } = useApi();
const items = ref<BudgetOption[]>([]);
const selected = ref<BudgetOption | null>(null);
const filtered = ref<BudgetOption[]>([]);
const selected = computed(() => items.value.find((i) => i.id === props.modelValue) ?? null);
async function load() {
if (!props.projectId) {
@ -49,25 +70,13 @@ async function load() {
amount: Number(row.amount ?? 0),
label: [row.code, row.description].filter(Boolean).join(" — "),
}));
filtered.value = items.value.slice(0, 30);
}
function onSearch(event: { query: string }) {
const q = event.query.toLowerCase();
filtered.value = items.value.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 30);
}
function onSelect(event: { value: BudgetOption }) {
emit("update:modelValue", event.value?.id ?? null);
emit("select", event.value ?? null);
function onUpdate(id: number | null) {
const option = id == null ? null : items.value.find((i) => i.id === id) ?? null;
emit("update:modelValue", option?.id ?? null);
emit("select", option);
}
watch(() => props.projectId, load, { immediate: true });
watch(
() => props.modelValue,
(id) => {
if (!id) selected.value = null;
else selected.value = items.value.find((i) => i.id === id) ?? null;
},
);
</script>

View file

@ -0,0 +1,269 @@
<template>
<Dialog
v-model:visible="visible"
header="Catálogo de materiales"
modal
:style="{ width: '920px' }"
:breakpoints="{ '960px': '95vw' }"
>
<DeskTableBar v-model="query" placeholder="Buscar clave, material o proveedor">
<Chip :label="`${filtered.length} materiales`" icon="pi pi-box" />
<Button label="Nuevo material" icon="pi pi-plus" size="small" @click="openNew" />
</DeskTableBar>
<DataTable
:value="filtered"
data-key="id"
paginator
:rows="12"
scrollable
scroll-height="22rem"
empty-message="No hay materiales. Capture el catálogo para usarlo en entradas y traspasos."
@row-click="(e: { data: Material }) => openEdit(e.data)"
>
<Column field="code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.code }}</span></template>
</Column>
<Column field="name" header="Material" />
<Column field="unit_label" header="Unidad" style="width: 8rem">
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
</Column>
<Column header="Proveedores">
<template #body="{ data }">{{ supplierNames(data) }}</template>
</Column>
</DataTable>
<Dialog
v-model:visible="showForm"
:header="form.id ? 'Editar material' : 'Nuevo material'"
modal
:style="{ width: '640px' }"
>
<div class="form-grid form-grid-compact">
<FormField label="Clave" required>
<InputText v-model="form.code" class="w-full identifier-input" placeholder="CEM-001" />
</FormField>
<FormField label="Unidad" required>
<div class="flex gap-2 align-items-center">
<Select
v-model="form.unit_id"
:options="units"
option-label="label"
option-value="id"
placeholder="Catálogo de unidades"
filter
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nueva unidad" title="Nueva unidad" @click="showUnitForm = true" />
</div>
</FormField>
<FormField label="Nombre" required class="span-2">
<InputText v-model="form.name" class="w-full" placeholder="Cemento gris 50 kg" />
</FormField>
<FormField label="Proveedores" class="span-2">
<div class="flex gap-2 align-items-center">
<MultiSelect
v-model="form.supplier_ids"
:options="supplierOptions"
option-label="label"
option-value="id"
placeholder="Uno o varios proveedores"
filter
display="chip"
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nuevo proveedor" title="Nuevo proveedor" @click="showSupplierForm = true" />
</div>
</FormField>
</div>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showForm = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<Dialog v-model:visible="showUnitForm" header="Nueva unidad" modal :style="{ width: '420px' }">
<div class="form-grid form-grid-compact">
<FormField label="Código" required>
<InputText v-model="unitForm.code" class="w-full identifier-input" placeholder="bulto" />
</FormField>
<FormField label="Nombre" required>
<InputText v-model="unitForm.label" class="w-full" placeholder="Bulto" />
</FormField>
</div>
<Message v-if="unitError" severity="error" class="mt-3" :closable="false">{{ unitError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showUnitForm = false" />
<Button label="Guardar" icon="pi pi-check" @click="saveUnit" />
</template>
</Dialog>
<SupplierFormDialog v-model="showSupplierForm" @saved="onSupplierSaved" />
</Dialog>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
import { displayErrorMessage } from "~/composables/api-response";
export type Material = {
id: number;
code: string;
name: string;
unit?: string;
unit_id?: number | null;
unit_label?: string;
suppliers?: { id: number; name: string }[];
};
type MeasureUnit = { id: number; code: string; label: string };
const visible = defineModel<boolean>({ default: false });
const emit = defineEmits<{ saved: [material: Material] }>();
const { api } = useApi();
const toast = useToast();
const query = ref("");
const materials = ref<Material[]>([]);
const units = ref<MeasureUnit[]>([]);
const suppliers = ref<Supplier[]>([]);
const showForm = ref(false);
const showUnitForm = ref(false);
const showSupplierForm = ref(false);
const saving = ref(false);
const formError = ref("");
const unitError = ref("");
const unitForm = reactive({ code: "", label: "" });
const form = reactive({
id: 0,
code: "",
name: "",
unit_id: null as number | null,
supplier_ids: [] as number[],
});
const supplierOptions = computed(() =>
suppliers.value.map((s) => ({
id: s.id,
label: s.rfc ? `${s.name} · ${s.rfc}` : s.name,
})),
);
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
return materials.value.filter((m) =>
!q || [m.code, m.name, m.unit, m.unit_label, supplierNames(m)].join(" ").toLowerCase().includes(q),
);
});
function supplierNames(row: Material) {
return (row.suppliers ?? []).map((s) => s.name).join(", ") || "—";
}
function resetForm() {
form.id = 0;
form.code = "";
form.name = "";
form.unit_id = units.value[0]?.id ?? null;
form.supplier_ids = [];
formError.value = "";
}
function openNew() {
resetForm();
showForm.value = true;
}
function openEdit(row: Material) {
form.id = row.id;
form.code = row.code;
form.name = row.name;
form.unit_id = row.unit_id ?? null;
form.supplier_ids = (row.suppliers ?? []).map((s) => s.id);
formError.value = "";
showForm.value = true;
}
async function load() {
const [mats, un, sup] = await Promise.all([
api<{ materials?: Material[] }>("/v1/warehouse-materials"),
api<{ units?: MeasureUnit[] }>("/v1/measure-units"),
api<{ suppliers?: Supplier[] }>("/v1/suppliers?status=activo"),
]);
materials.value = mats.materials ?? [];
units.value = un.units ?? [];
suppliers.value = sup.suppliers ?? [];
}
async function save() {
if (!form.code.trim() || !form.name.trim()) {
formError.value = "Clave y nombre son obligatorios";
return;
}
if (!form.unit_id) {
formError.value = "Seleccione una unidad del catálogo";
return;
}
saving.value = true;
formError.value = "";
try {
const payload = {
code: form.code,
name: form.name,
unit_id: form.unit_id,
supplier_ids: form.supplier_ids,
};
const r = form.id
? await api<Material>(`/v1/warehouse-materials/${form.id}`, { method: "PATCH", json: payload })
: await api<Material>("/v1/warehouse-materials", { method: "POST", json: payload });
toast.add({ severity: "success", summary: "Material guardado", life: 2000 });
showForm.value = false;
await load();
const saved = materials.value.find((m) => m.id === Number(r.id)) ?? r;
emit("saved", saved);
} catch (e) {
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
} finally {
saving.value = false;
}
}
async function saveUnit() {
unitError.value = "";
if (!unitForm.code.trim()) {
unitError.value = "El código es obligatorio";
return;
}
try {
const r = await api<{ unit?: MeasureUnit }>("/v1/measure-units", {
method: "POST",
json: { code: unitForm.code, label: unitForm.label },
});
const created = r.unit;
if (created) {
units.value = [...units.value.filter((u) => u.id !== created.id), created]
.sort((a, b) => a.label.localeCompare(b.label, "es"));
form.unit_id = created.id;
}
unitForm.code = "";
unitForm.label = "";
showUnitForm.value = false;
} catch (e) {
unitError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
}
function onSupplierSaved(supplier: Supplier) {
suppliers.value = [...suppliers.value.filter((s) => s.id !== supplier.id), supplier]
.sort((a, b) => a.name.localeCompare(b.name, "es"));
if (!form.supplier_ids.includes(supplier.id)) form.supplier_ids = [...form.supplier_ids, supplier.id];
}
watch(visible, async (on) => {
if (on) await load();
});
defineExpose({ load, materials });
</script>

View file

@ -0,0 +1,129 @@
<template>
<Dialog
v-model:visible="visible"
:header="supplierId ? 'Editar proveedor' : 'Nuevo proveedor'"
modal
:style="{ width: '640px' }"
>
<div class="form-grid form-grid-compact">
<FormField label="Nombre comercial" required>
<InputText v-model="form.name" class="w-full" placeholder="Home Depot" />
</FormField>
<FormField label="Razón social">
<InputText v-model="form.legal_name" class="w-full" />
</FormField>
<FormField label="RFC">
<InputText v-model="form.rfc" class="w-full identifier-input" maxlength="13" placeholder="XAXX010101000" />
</FormField>
<FormField label="Teléfono">
<InputText v-model="form.phone" class="w-full" />
</FormField>
<FormField label="Correo">
<InputText v-model="form.email" class="w-full no-caps" />
</FormField>
<FormField label="Ciudad">
<InputText v-model="form.city" class="w-full" />
</FormField>
<FormField label="Domicilio" class="span-2">
<InputText v-model="form.address" class="w-full" />
</FormField>
<FormField label="Notas" class="span-2">
<Textarea v-model="form.notes" class="w-full" rows="2" auto-resize />
</FormField>
</div>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cancelar" text @click="visible = false" />
<Button label="Guardar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import { displayErrorMessage } from "~/composables/api-response";
export type Supplier = {
id: number;
name: string;
legal_name?: string;
rfc?: string;
email?: string;
phone?: string;
address?: string;
city?: string;
state?: string;
postal_code?: string;
notes?: string;
status?: string;
};
const props = defineProps<{
modelValue: boolean;
supplier?: Supplier | null;
}>();
const emit = defineEmits<{
"update:modelValue": [boolean];
saved: [Supplier];
}>();
const { api } = useApi();
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const empty = () => ({
name: "",
legal_name: "",
rfc: "",
email: "",
phone: "",
address: "",
city: "",
notes: "",
});
const form = reactive(empty());
const error = ref("");
const saving = ref(false);
const supplierId = computed(() => props.supplier?.id ?? null);
watch(
() => [props.modelValue, props.supplier] as const,
([open, supplier]) => {
if (!open) return;
error.value = "";
Object.assign(form, empty(), supplier || {});
},
);
async function save() {
error.value = "";
if (!form.name.trim()) {
error.value = "El nombre del proveedor es obligatorio";
return;
}
saving.value = true;
try {
const payload = { ...form, rfc: form.rfc.trim().toUpperCase() };
const r = supplierId.value
? await api<{ supplier?: Supplier; data?: { supplier?: Supplier } }>(`/v1/suppliers/${supplierId.value}`, {
method: "PATCH",
json: payload,
})
: await api<{ supplier?: Supplier; data?: { supplier?: Supplier } }>("/v1/suppliers", {
method: "POST",
json: payload,
});
const saved = r.supplier ?? r.data?.supplier;
if (!saved) throw new Error("No se recibió el proveedor guardado");
emit("saved", saved);
visible.value = false;
} catch (e) {
error.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
} finally {
saving.value = false;
}
}
</script>

View file

@ -20,28 +20,67 @@
<template v-if="preview">
<div class="import-summary mt-3">
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div>
<div><strong>{{ preview.period_count }}</strong><span>Semanas</span></div>
<div><strong>{{ preview.format_label || "—" }}</strong><span>Formato</span></div>
<div><strong>{{ preview.period_count || 0 }}</strong><span>Semanas</span></div>
<div v-if="preview.format === 'concepto'"><strong>{{ preview.concepts.length }}</strong><span>Conceptos</span></div>
<div v-if="preview.format === 'partida'"><strong>{{ preview.partidas.length }}</strong><span>Partidas</span></div>
<div><strong>{{ preview.unmatched_codes?.length || 0 }}</strong><span>Sin match</span></div>
</div>
<Message v-for="(w, i) in preview.warnings" :key="i" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
<Message v-if="preview.unmatched_codes?.length" severity="warn" class="mt-2" :closable="false">
<Message v-for="(w, i) in preview.warnings" :key="`w-${i}`" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
<Message v-if="preview.unmatched_codes?.length && preview.format === 'partida'" severity="warn" class="mt-2" :closable="false">
Partidas no encontradas en presupuesto ({{ preview.unmatched_codes.join(", ") }}). Al importar se crearán como capítulos.
</Message>
<Message v-else-if="preview.unmatched_codes?.length" severity="warn" class="mt-2" :closable="false">
Códigos no encontrados en presupuesto: {{ preview.unmatched_codes.join(", ") }}
</Message>
<DataTable
v-if="preview.format === 'partida' && preview.partidas.length"
:value="preview.partidas"
size="small"
class="mt-3"
scrollable
scroll-height="16rem"
>
<Column field="code" header="Partida" style="width: 7rem" />
<Column field="name" header="Nombre" />
<Column header="Total">
<template #body="{ data }">{{ money(data.total) }}</template>
</Column>
</DataTable>
<DataTable
v-else-if="preview.format === 'concepto' && preview.concepts.length"
:value="preview.concepts"
size="small"
class="mt-3"
scrollable
scroll-height="16rem"
>
<Column field="code" header="Código" style="width: 9rem" />
<Column field="description" header="Concepto" />
<Column field="unit" header="Un." style="width: 4rem" />
</DataTable>
</template>
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
<template #footer>
<Button label="Cerrar" text @click="visibleModel = false" />
<Button label="Importar" icon="pi pi-check" :disabled="!preview || preview.format === 'none'" :loading="saving" @click="confirm" />
<Button label="Importar" icon="pi pi-check" :disabled="!canImport" :loading="saving" @click="confirm" />
</template>
</Dialog>
</template>
<script setup lang="ts">
type Preview = {
format: "concepto" | "partida" | "none";
format_label: string;
period_count: number;
concepts: { code: string; description: string; unit: string }[];
partidas: { code: string; name: string; total: number }[];
unmatched_codes: string[];
warnings: string[];
};
const props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
@ -56,36 +95,48 @@ const visibleModel = computed({
const fileInput = ref<HTMLInputElement | null>(null);
const fileName = ref("");
const pending = ref<File | null>(null);
const preview = ref<Record<string, unknown> | null>(null);
const preview = ref<Preview | null>(null);
const busy = ref(false);
const saving = ref(false);
const error = ref("");
const canImport = computed(() => {
if (!preview.value || preview.value.format === "none") return false;
return preview.value.format === "concepto"
? preview.value.concepts.length > 0
: preview.value.partidas.length > 0;
});
function pickFile() {
fileInput.value?.click();
}
function money(value: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(value || 0));
}
async function onFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file || !props.projectId) return;
pending.value = file;
fileName.value = file.name;
error.value = "";
preview.value = null;
busy.value = true;
try {
const fd = new FormData();
fd.append("file", file);
preview.value = await api(`/v1/projects/${props.projectId}/work-program/preview`, { method: "POST", body: fd });
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/work-program/preview`, { method: "POST", body: fd });
} catch (err) {
error.value = err instanceof Error ? err.message : "No se pudo leer el Excel";
preview.value = null;
} finally {
busy.value = false;
(e.target as HTMLInputElement).value = "";
}
}
async function confirm() {
if (!pending.value || !props.projectId) return;
if (!pending.value || !props.projectId || !canImport.value) return;
saving.value = true;
error.value = "";
try {

View file

@ -1,7 +1,7 @@
<template>
<div class="detail-section">
<Message v-if="freshnessRequired" severity="warn" :closable="false" class="mb-3">
Reingreso o pendiente de alta IMSS: documentos CURP, NSS, RFC y domicilio deben tener emisión de máximo 3 meses.
Reingreso o pendiente de alta IMSS: CURP, NSS, RFC y domicilio deben haberse subido en los últimos 3 meses. Quien recibe el expediente revisa si el documento en sí es vigente.
</Message>
<Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
Expediente listo para alta IMSS.
@ -60,12 +60,6 @@
class="w-full"
/>
</FormField>
<FormField v-if="selectedPolicy?.requires_issued_at || selectedPolicy?.validity_mode === 'freshness'" label="Fecha de emisión" class="mt-2">
<DatePicker v-model="issuedAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField>
<FormField v-if="selectedPolicy?.requires_expires_at || selectedPolicy?.validity_mode === 'expiry'" label="Vigencia / vencimiento" class="mt-2">
<DatePicker v-model="expiresAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField>
<FileUpload
mode="basic"
:auto="true"
@ -73,7 +67,7 @@
accept=".jpg,.jpeg,.png,.pdf,.webp"
custom-upload
class="mt-2"
:disabled="uploading || !canUpload"
:disabled="uploading"
@uploader="uploadDocument"
/>
<div class="muted mt-2">{{ uploadHint }}</div>
@ -173,8 +167,6 @@ const documentType = ref(
);
const uploading = ref(false);
const docsTab = ref("current");
const issuedAt = ref<Date | null>(null);
const expiresAt = ref<Date | null>(null);
const previewOpen = ref(false);
const previewPath = ref("");
const previewName = ref("");
@ -212,17 +204,11 @@ const freshnessRequired = computed(() => !!props.freshnessRequired);
const imssReady = computed(() => !!props.imssReady);
const inProjectWithoutImss = computed(() => !!props.inProjectWithoutImss);
const canUpload = computed(() => {
if (selectedPolicy.value?.requires_issued_at && !issuedAt.value) return false;
if (selectedPolicy.value?.requires_expires_at && !expiresAt.value) return false;
return true;
});
const uploadHint = computed(() => {
if (selectedPolicy.value?.requires_issued_at) return "Indica la fecha de emisión del documento (máx. 3 meses si aplica alta/reingreso).";
if (selectedPolicy.value?.requires_expires_at) return "Indica la vigencia impresa en la INE.";
if (selectedPolicy.value?.validity_mode === "expiry") return "Si el documento tiene vencimiento, indícalo. No es obligatorio para operar.";
return "Elige el tipo y el archivo se sube solo. El catálogo extra no bloquea gafete ni alta IMSS.";
if (selectedPolicy.value?.validity_mode === "freshness") {
return "Solo sube el archivo. Si hay alta o reingreso pendiente, la vigencia de 3 meses cuenta desde la fecha de carga.";
}
return "Elige el tipo y el archivo se sube solo. Quien recibe el expediente revisa si el documento es válido. El catálogo extra no bloquea gafete ni alta IMSS.";
});
function typeLabel(code: string) {
@ -243,7 +229,7 @@ function validityLabel(status?: ValidityStatus | string) {
case "stale_for_alta":
return "Desactualizado para alta";
case "missing_dates":
return "Sin fecha";
return "Por refrescar";
default:
return status || "—";
}
@ -268,29 +254,17 @@ function formatDate(value: string) {
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
}
function toIsoDate(value: Date | null) {
if (!value) return "";
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
async function uploadDocument(event: { files: File[] }) {
const file = event.files?.[0];
if (!file || uploading.value || !canUpload.value) return;
if (!file || uploading.value) return;
uploading.value = true;
try {
const body = new FormData();
body.append("type", documentType.value);
body.append("file", file);
if (issuedAt.value) body.append("issued_at", toIsoDate(issuedAt.value));
if (expiresAt.value) body.append("expires_at", toIsoDate(expiresAt.value));
await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body });
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
docsTab.value = "current";
issuedAt.value = null;
expiresAt.value = null;
emit("changed");
} catch (error: unknown) {
toast.add({
@ -312,8 +286,4 @@ function openDocument(row: DocumentRow) {
watch(() => props.initialType, (value) => {
if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;
});
watch(documentType, () => {
issuedAt.value = null;
expiresAt.value = null;
});
</script>

View file

@ -47,33 +47,65 @@ export function isApiEnvelope(value: unknown): value is {
&& "ok" in value && "message" in value && "layer" in value;
}
/** Normaliza respuestas RPC `{ ok, data, message }` al shape plano que esperan las pantallas. */
/** Normaliza respuestas `{ ok, data }` (con o sin envelope RPC) al shape plano que esperan las pantallas. */
export function unwrapApiPayload<T>(body: unknown): T {
if (!isApiEnvelope(body)) return body as T;
const env = body;
const data = env.data;
const spread = data && typeof data === "object" && !Array.isArray(data)
? data as Record<string, unknown>
: {};
if (!body || typeof body !== "object") return body as T;
const rec = body as Record<string, unknown>;
const data = rec.data;
const canSpread = "ok" in rec
&& data != null
&& typeof data === "object"
&& !Array.isArray(data);
if (!isApiEnvelope(body) && !canSpread) return body as T;
const spread = canSpread ? data as Record<string, unknown> : {};
return {
...spread,
data,
ok: env.ok,
code: env.code,
message: env.message,
status: env.status,
layer: env.layer,
ok: rec.ok,
code: rec.code,
message: rec.message,
status: rec.status,
layer: rec.layer,
} as T;
}
function firstListedError(value: object): string | null {
if (!("errors" in value) || value.errors == null) return null;
const errors = (value as { errors: unknown }).errors;
if (typeof errors === "string" && errors.trim()) return errors;
if (!Array.isArray(errors)) return null;
for (const item of errors) {
if (typeof item === "string" && item.trim()) return item;
if (item && typeof item === "object" && "messages" in item) {
const msgs = (item as { messages: unknown }).messages;
if (Array.isArray(msgs)) {
const first = msgs.find((msg) => typeof msg === "string" && msg.trim());
if (typeof first === "string") return first;
}
}
}
return null;
}
/** Muestra el mensaje detallado de la API o front sin reescribirlo. */
export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
if (isApiEnvelope(value)) return value.message;
if (value && typeof value === "object" && "message" in value) {
return String((value as { message: unknown }).message);
if (isApiEnvelope(value)) {
const listed = firstListedError(value);
const message = value.message?.trim();
if (message) return message;
if (listed) return listed;
}
if (value && typeof value === "object" && "error" in value) {
return String((value as { error: unknown }).error);
if (value && typeof value === "object") {
if ("message" in value) {
const message = String((value as { message: unknown }).message || "").trim();
if (message) return message;
}
return fallback;
if ("error" in value) {
const error = String((value as { error: unknown }).error || "").trim();
if (error) return error;
}
const listed = firstListedError(value);
if (listed) return listed;
}
return fallback || "Ocurrió un error";
}

View file

@ -0,0 +1,21 @@
export function roundMoney(value: number) {
return Math.round((Number(value) || 0) * 100) / 100;
}
export function expenseAmounts(
quantity: number,
unitPrice: number,
taxIncluded: boolean,
ivaRate = 0.16,
) {
const line = roundMoney(quantity * unitPrice);
const rate = Number(ivaRate) || 0.16;
if (taxIncluded) {
const total = line;
const subtotal = roundMoney(total / (1 + rate));
return { subtotal, ivaAmount: roundMoney(total - subtotal), total };
}
const subtotal = line;
const ivaAmount = roundMoney(subtotal * rate);
return { subtotal, ivaAmount, total: roundMoney(subtotal + ivaAmount) };
}

View file

@ -87,7 +87,7 @@ export function useDesktop() {
}) {
tabLabel.value = opts.tab;
if (opts.treeTitle != null) treeTitle.value = opts.treeTitle;
if (opts.ribbon) ribbon.value = opts.ribbon;
ribbon.value = opts.ribbon ?? [];
if (opts.tree) tree.value = opts.tree;
if (opts.treeKey != null) treeKey.value = opts.treeKey;
if (opts.showTree != null) showTree.value = opts.showTree;

View file

@ -1,17 +1,55 @@
<template>
<div class="almacen-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar material o obra">
<Chip label="Almacén global" icon="pi pi-box" />
<Chip :label="`${stockRows.length} líneas`" icon="pi pi-list" />
<DeskTableBar v-model="tableQ" :placeholder="isGlobalView ? 'Buscar material o almacén' : 'Buscar material'">
<Chip :label="currentLabel" icon="pi pi-box" />
<Chip :label="`${visibleStock.length} líneas`" icon="pi pi-list" />
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
</DeskTableBar>
<TabView>
<TabPanel header="Stock global">
<DataTable :value="filteredStock" data-key="material_id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="project_code" header="Obra / Central" />
<Column field="material_code" header="Clave" style="width: 8rem" />
<TabPanel :header="isGlobalView ? 'Consolidado' : 'Stock'">
<DataTable
:value="visibleStock"
data-key="row_key"
paginator
:rows="25"
scrollable
scroll-height="flex"
empty-message="No hay existencias en este almacén."
>
<Column v-if="isGlobalView && stockMode === 'detail'" field="warehouse_label" header="Almacén" />
<Column field="material_code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.material_code }}</span></template>
</Column>
<Column field="material_name" header="Material" />
<Column field="unit_label" header="Unidad" style="width: 8rem">
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
</Column>
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
<Column v-if="isGlobalView && stockMode === 'consolidated'" field="warehouse_count" header="Almacenes" style="width: 8rem" />
<Column field="stock_value" header="Valor" style="width: 9rem">
<template #body="{ data }">{{ money(data.stock_value) }}</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel v-if="isGlobalView" header="Por almacén">
<DataTable
:value="filteredDetail"
data-key="row_key"
paginator
:rows="25"
scrollable
scroll-height="flex"
empty-message="No hay existencias en almacenes abiertos."
>
<Column field="warehouse_label" header="Almacén" />
<Column field="material_code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.material_code }}</span></template>
</Column>
<Column field="material_name" header="Material" />
<Column field="unit_label" header="Unidad" style="width: 8rem">
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
</Column>
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
<Column field="stock_value" header="Valor" style="width: 9rem">
<template #body="{ data }">{{ money(data.stock_value) }}</template>
@ -19,9 +57,12 @@
</DataTable>
</TabPanel>
<TabPanel header="Kardex">
<DataTable :value="movements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
<DataTable :value="filteredMovements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="movement_date" header="Fecha" style="width: 7rem" />
<Column field="movement_type" header="Tipo" style="width: 8rem" />
<Column v-if="isGlobalView" field="warehouse_label" header="Almacén" />
<Column header="Tipo" style="width: 9rem">
<template #body="{ data }">{{ movementLabel(data.movement_type) }}</template>
</Column>
<Column field="material_name" header="Material" />
<Column field="quantity" header="Cant." style="width: 7rem" />
<Column field="total_cost" header="Importe" style="width: 9rem">
@ -32,23 +73,82 @@
</TabPanel>
</TabView>
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '640px' }">
<Dialog v-model:visible="showOpen" header="Abrir almacén de obra" modal :style="{ width: '520px' }">
<p class="muted mb-3">
No todas las obras tienen almacén. Elija el proyecto para abrirlo. El almacén global permanece disponible.
</p>
<FormField label="Proyecto" required>
<Select
v-model="openProjectId"
:options="projectsWithoutWarehouse"
option-label="label"
option-value="id"
filter
placeholder="Seleccione una obra"
class="w-full"
/>
</FormField>
<Message v-if="!projectsWithoutWarehouse.length" severity="info" class="mt-3" :closable="false">
Todas las obras activas ya tienen almacén abierto.
</Message>
<template #footer>
<Button label="Cancelar" text @click="showOpen = false" />
<Button label="Abrir almacén" icon="pi pi-folder-open" :disabled="!openProjectId" @click="openWarehouse" />
</template>
</Dialog>
<Dialog v-model:visible="showClose" header="Cerrar almacén de obra" modal :style="{ width: '560px' }">
<p class="muted mb-3">
El almacén global no se cierra. Al terminar la obra puede cerrar este almacén y devolver el stock restante.
</p>
<Message :closable="false" class="mb-3">{{ selectedWarehouse?.label }}</Message>
<FormField label="Notas de cierre">
<Textarea v-model="closeNotes" class="w-full" rows="2" auto-resize />
</FormField>
<div class="flex align-items-center gap-2 mt-3">
<Checkbox v-model="transferRemaining" binary input-id="xfer-remaining" />
<label for="xfer-remaining">Traspasar stock restante al almacén global</label>
</div>
<template #footer>
<Button label="Cancelar" text @click="showClose = false" />
<Button label="Cerrar almacén" icon="pi pi-lock" severity="danger" @click="closeWarehouse" />
</template>
</Dialog>
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '680px' }">
<div class="form-grid form-grid-compact">
<FormField label="Almacén">
<Select v-model="entryForm.warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
<FormField label="Almacén destino" required>
<Select v-model="entryForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Código material">
<InputText v-model="entryForm.material_code" class="w-full identifier-input" />
<FormField label="Fecha">
<DatePicker v-model="entryDate" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Nombre material">
<InputText v-model="entryForm.material_name" class="w-full" />
<FormField label="Material" required class="span-2">
<div class="flex gap-2 align-items-center">
<Select
v-model="entryForm.material_id"
:options="materialSelectOptions"
option-label="label"
option-value="id"
filter
placeholder="Seleccione del catálogo"
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Catálogo" title="Catálogo de materiales" @click="showCatalog = true" />
</div>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="entryForm.quantity" :min="0" class="w-full" />
<FormField label="Unidad">
<InputText :model-value="selectedEntryMaterial?.unit_label || selectedEntryMaterial?.unit || ''" class="w-full" readonly />
</FormField>
<FormField label="Cantidad" required>
<InputNumber v-model="entryForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Costo unitario">
<InputNumber v-model="entryForm.unit_cost" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<FormField v-if="selectedEntrySuppliers" label="Proveedores del material" class="span-2">
<small class="muted">{{ selectedEntrySuppliers }}</small>
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showEntry = false" />
@ -56,19 +156,27 @@
</template>
</Dialog>
<Dialog v-model:visible="showExit" header="Salida a obra / concepto" modal :style="{ width: '720px' }">
<Dialog v-model:visible="showExit" header="Salida de material" modal :style="{ width: '720px' }">
<div class="form-grid form-grid-compact">
<FormField label="Almacén obra">
<Select v-model="exitForm.warehouse_id" :options="projectWarehouses" option-label="label" option-value="id" class="w-full" />
<FormField label="Almacén" required>
<Select v-model="exitForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Material">
<Select v-model="exitForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
<FormField label="Material" required>
<Select
v-model="exitForm.material_id"
:options="stockMaterialOptions(exitForm.warehouse_id)"
option-label="label"
option-value="id"
filter
class="w-full"
/>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="exitForm.quantity" :min="0" class="w-full" />
<FormField label="Cantidad" required>
<InputNumber v-model="exitForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
<InputText v-else disabled placeholder="Solo aplica en almacén de obra" class="w-full" />
</FormField>
<FormField label="Destino físico" class="span-2">
<InputText v-model="exitForm.destination_note" class="w-full" />
@ -81,19 +189,29 @@
</template>
</Dialog>
<Dialog v-model:visible="showTransfer" header="Transferencia central ↔ obra" modal :style="{ width: '640px' }">
<Dialog v-model:visible="showTransfer" header="Traspaso entre almacenes" modal :style="{ width: '680px' }">
<div class="form-grid form-grid-compact">
<FormField label="Desde">
<Select v-model="transferForm.from_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
<FormField label="Desde" required>
<Select v-model="transferForm.from_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Hacia">
<Select v-model="transferForm.to_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
<FormField label="Hacia" required>
<Select v-model="transferForm.to_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Material">
<Select v-model="transferForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
<FormField label="Material" required>
<Select
v-model="transferForm.material_id"
:options="stockMaterialOptions(transferForm.from_warehouse_id)"
option-label="label"
option-value="id"
filter
class="w-full"
/>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="transferForm.quantity" :min="0" class="w-full" />
<FormField label="Cantidad" required>
<InputNumber v-model="transferForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Notas" class="span-2">
<InputText v-model="transferForm.notes" class="w-full" />
</FormField>
</div>
<template #footer>
@ -101,89 +219,347 @@
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
</template>
</Dialog>
<MaterialCatalogDialog v-model="showCatalog" @saved="onMaterialSaved" />
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
import MaterialCatalogDialog, { type Material } from "~/components/MaterialCatalogDialog.vue";
import { displayErrorMessage } from "~/composables/api-response";
type WarehouseRow = { id: number; kind: string; project_id?: number; project_code?: string; label: string };
type StockRow = Record<string, unknown>;
type WarehouseRow = {
id: number;
kind: string;
status?: string;
project_id?: number;
project_code?: string;
project_name?: string;
label: string;
};
type StockRow = Record<string, unknown> & {
row_key?: string;
material_id?: number;
material_code?: string;
material_name?: string;
warehouse_id?: number;
warehouse_label?: string;
qty_on_hand?: number;
stock_value?: number;
unit?: string;
unit_label?: string;
warehouse_count?: number;
};
type MovementRow = Record<string, unknown>;
type ProjectOption = { id: number; name: string; code?: string; status?: string; label: string };
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const perms = usePermissions();
const tableQ = ref("");
const warehouses = ref<WarehouseRow[]>([]);
const projects = ref<ProjectOption[]>([]);
const materials = ref<Material[]>([]);
const stockRows = ref<StockRow[]>([]);
const consolidated = ref<StockRow[]>([]);
const movements = ref<MovementRow[]>([]);
const materialOptions = ref<{ id: number; label: string }[]>([]);
const showEntry = ref(false);
const showExit = ref(false);
const showTransfer = ref(false);
const showOpen = ref(false);
const showClose = ref(false);
const showCatalog = ref(false);
const exitWarning = ref("");
const openProjectId = ref<number | null>(null);
const closeNotes = ref("");
const transferRemaining = ref(true);
const entryForm = reactive({ warehouse_id: 0, material_code: "", material_name: "", quantity: 0, unit_cost: 0 });
const exitForm = reactive({ warehouse_id: 0, material_id: 0, quantity: 0, budget_item_id: null as number | null, destination_note: "" });
const transferForm = reactive({ from_warehouse_id: 0, to_warehouse_id: 0, material_id: 0, quantity: 0 });
const entryForm = reactive({
warehouse_id: 0,
material_id: 0,
quantity: 0,
unit_cost: 0,
movement_date: new Date().toISOString().slice(0, 10),
});
const exitForm = reactive({
warehouse_id: 0,
material_id: 0,
quantity: 0,
budget_item_id: null as number | null,
destination_note: "",
});
const transferForm = reactive({
from_warehouse_id: 0,
to_warehouse_id: 0,
material_id: 0,
quantity: 0,
notes: "",
});
const projectWarehouses = computed(() => warehouses.value.filter((w) => w.kind === "project"));
const exitProjectId = computed(() => projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0);
const stockValue = computed(() => stockRows.value.reduce((s, r) => s + Number(r.stock_value || 0), 0));
const filteredStock = computed(() => {
const canUpdate = computed(() => perms.can("warehouse.update"));
const canCreate = computed(() => perms.can("warehouse.create"));
const openWarehouses = computed(() => warehouses.value.filter((w) => w.status !== "closed"));
const projectWarehouses = computed(() => openWarehouses.value.filter((w) => w.kind === "project"));
const centralWarehouse = computed(() => warehouses.value.find((w) => w.kind === "central") ?? null);
const isGlobalView = computed(() => !desk.treeKey.value || desk.treeKey.value === "global");
const selectedWarehouse = computed(() => {
if (isGlobalView.value) return centralWarehouse.value;
if (desk.treeKey.value.startsWith("w:")) {
const id = Number(desk.treeKey.value.slice(2));
return warehouses.value.find((w) => w.id === id) ?? null;
}
return null;
});
const currentLabel = computed(() =>
isGlobalView.value ? "Almacén global" : (selectedWarehouse.value?.label || "Almacén"),
);
const stockMode = computed(() => (isGlobalView.value ? "consolidated" : "detail"));
const exitProjectId = computed(() =>
projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0,
);
const projectsWithoutWarehouse = computed(() => {
const openIds = new Set(projectWarehouses.value.map((w) => w.project_id));
return projects.value.filter((p) => !openIds.has(p.id));
});
const materialSelectOptions = computed(() =>
materials.value.map((m) => ({
id: m.id,
label: `${m.code} — ${m.name}`,
})),
);
const selectedEntryMaterial = computed(() =>
materials.value.find((m) => m.id === entryForm.material_id) ?? null,
);
const selectedEntrySuppliers = computed(() =>
(selectedEntryMaterial.value?.suppliers ?? []).map((s) => s.name).join(", "),
);
const visibleStock = computed(() => {
const rows = isGlobalView.value ? consolidated.value : stockRows.value;
return filterStock(rows);
});
const filteredDetail = computed(() => filterStock(stockRows.value));
const stockValue = computed(() =>
visibleStock.value.reduce((s, r) => s + Number(r.stock_value || 0), 0),
);
const filteredMovements = computed(() => {
const q = tableQ.value.toLowerCase();
return stockRows.value.filter((r) =>
!q || [r.material_name, r.material_code, r.project_code].join(" ").toLowerCase().includes(q),
return movements.value.filter((r) =>
!q || [r.material_name, r.material_code, r.warehouse_label, r.destination_note].join(" ").toLowerCase().includes(q),
);
});
function filterStock(rows: StockRow[]) {
const q = tableQ.value.toLowerCase();
return rows.filter((r) =>
!q || [r.material_name, r.material_code, r.warehouse_label, r.project_code].join(" ").toLowerCase().includes(q),
);
}
function stockMaterialOptions(warehouseId: number) {
const seen = new Map<number, string>();
for (const row of stockRows.value) {
if (warehouseId && Number(row.warehouse_id) !== warehouseId && row.warehouse_id != null) continue;
const id = Number(row.material_id);
if (!id || seen.has(id)) continue;
seen.set(id, `${row.material_code} — ${row.material_name}`);
}
if (!seen.size) {
for (const m of materials.value) seen.set(m.id, `${m.code} — ${m.name}`);
}
return [...seen.entries()].map(([id, label]) => ({ id, label }));
}
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
async function loadWarehouses() {
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} });
const r = await api<{ data?: { warehouses?: Array<Record<string, unknown>> } }>("/v1/warehouses");
const rows = (r.data?.warehouses ?? []) as Array<Record<string, unknown>>;
warehouses.value = rows.map((w) => ({
id: Number(w.id),
kind: String(w.kind),
project_id: w.project_id ? Number(w.project_id) : undefined,
project_code: String(w.project_code ?? (w.kind === "central" ? "CENTRAL" : "")),
label: w.kind === "central" ? "Almacén central" : `${w.project_code} — ${w.project_name ?? "Obra"}`,
function movementLabel(type: unknown) {
switch (String(type || "")) {
case "entry": return "Entrada";
case "exit": return "Salida";
case "transfer_out": return "Traspaso salida";
case "transfer_in": return "Traspaso entrada";
case "adjustment": return "Ajuste";
default: return String(type || "—");
}
}
function warehouseLabel(w: Record<string, unknown>) {
if (w.kind === "central") return "Almacén global";
const code = String(w.project_code || "");
const name = String(w.project_name || "Obra");
return code ? `${code} — ${name}` : name;
}
function withRowKeys(rows: StockRow[], prefix: string) {
return rows.map((r, i) => ({
...r,
row_key: `${prefix}-${r.warehouse_id ?? "g"}-${r.material_id ?? i}`,
}));
}
async function loadStock() {
const r = await api<{ data?: { stock?: StockRow[] } }>("/v1/warehouses/stock/global");
stockRows.value = r.data?.stock ?? [];
const mats = new Map<number, string>();
for (const row of stockRows.value) {
mats.set(Number(row.material_id), `${row.material_code} — ${row.material_name}`);
const entryDate = computed({
get: () => {
const [y, m, d] = entryForm.movement_date.split("-").map(Number);
return y ? new Date(y, (m || 1) - 1, d || 1) : new Date();
},
set: (value: Date | null) => {
if (!value) return;
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0");
entryForm.movement_date = `${y}-${m}-${d}`;
},
});
async function loadProjects() {
const r = await api<{ projects?: Array<{ id: number; name: string; code?: string; status?: string }> }>(
"/v1/projects?status=activo,pausado",
);
projects.value = (r.projects ?? []).map((p) => ({
...p,
label: p.code ? `${p.code} — ${p.name}` : p.name,
}));
}
async function loadMaterials() {
try {
const r = await api<{ materials?: Material[] }>("/v1/warehouse-materials");
materials.value = r.materials ?? [];
} catch {
materials.value = [];
}
materialOptions.value = [...mats.entries()].map(([id, label]) => ({ id, label }));
}
async function loadWarehouses() {
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} }).catch(() => undefined);
const r = await api<{ warehouses?: Array<Record<string, unknown>> }>("/v1/warehouses");
const rows = r.warehouses ?? [];
warehouses.value = rows.map((w) => ({
id: Number(w.id),
kind: String(w.kind),
status: String(w.status ?? "open"),
project_id: w.project_id ? Number(w.project_id) : undefined,
project_code: String(w.project_code ?? ""),
project_name: String(w.project_name ?? ""),
label: warehouseLabel(w),
}));
}
function aggregateClient(rows: StockRow[]): StockRow[] {
const map = new Map<number, StockRow>();
for (const row of rows) {
const id = Number(row.material_id);
if (!id) continue;
const cur = map.get(id);
if (!cur) {
map.set(id, { ...row, warehouse_id: undefined, warehouse_count: 1 });
continue;
}
cur.qty_on_hand = Number(cur.qty_on_hand || 0) + Number(row.qty_on_hand || 0);
cur.stock_value = Number(cur.stock_value || 0) + Number(row.stock_value || 0);
cur.warehouse_count = Number(cur.warehouse_count || 1) + 1;
}
return [...map.values()];
}
async function loadStock() {
if (isGlobalView.value) {
const r = await api<{ stock?: StockRow[]; consolidated?: StockRow[] }>("/v1/warehouses/stock/global");
const detail = r.stock ?? [];
stockRows.value = withRowKeys(detail, "d");
const cons = r.consolidated ?? [];
consolidated.value = withRowKeys(cons.length ? cons : aggregateClient(detail), "c");
return;
}
const id = selectedWarehouse.value?.id;
if (!id) {
stockRows.value = [];
consolidated.value = [];
return;
}
const r = await api<{ stock?: StockRow[] }>(`/v1/warehouses/${id}/stock`);
stockRows.value = withRowKeys(r.stock ?? [], "w");
consolidated.value = [];
}
async function loadMovements() {
const wh = warehouses.value[0]?.id;
if (!wh) return;
const r = await api<{ data?: { movements?: MovementRow[] } }>(`/v1/warehouses/${wh}/movements`);
movements.value = r.data?.movements ?? [];
if (isGlobalView.value) {
const r = await api<{ movements?: MovementRow[] }>("/v1/warehouses/movements/global");
movements.value = r.movements ?? [];
return;
}
const id = selectedWarehouse.value?.id;
if (!id) {
movements.value = [];
return;
}
const r = await api<{ movements?: MovementRow[] }>(`/v1/warehouses/${id}/movements`);
movements.value = r.movements ?? [];
}
function defaultWarehouseId() {
return selectedWarehouse.value?.id || centralWarehouse.value?.id || 0;
}
function openEntry() {
entryForm.warehouse_id = defaultWarehouseId();
entryForm.material_id = materials.value[0]?.id ?? 0;
entryForm.quantity = 0;
entryForm.unit_cost = 0;
showEntry.value = true;
}
function openExitDlg() {
exitForm.warehouse_id = defaultWarehouseId();
exitForm.material_id = 0;
exitForm.quantity = 0;
exitForm.budget_item_id = null;
exitForm.destination_note = "";
exitWarning.value = "";
showExit.value = true;
}
function openTransferDlg() {
transferForm.from_warehouse_id = defaultWarehouseId();
transferForm.to_warehouse_id = isGlobalView.value
? (projectWarehouses.value[0]?.id ?? 0)
: (centralWarehouse.value?.id ?? 0);
transferForm.material_id = 0;
transferForm.quantity = 0;
transferForm.notes = "";
showTransfer.value = true;
}
async function saveEntry() {
if (!entryForm.warehouse_id || !entryForm.material_id || entryForm.quantity <= 0) {
toast.add({ severity: "warn", summary: "Seleccione almacén, material y una cantidad mayor a cero", life: 3000 });
return;
}
try {
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
method: "POST",
json: entryForm,
json: {
warehouse_id: entryForm.warehouse_id,
material_id: entryForm.material_id,
quantity: entryForm.quantity,
unit_cost: entryForm.unit_cost,
movement_date: entryForm.movement_date,
},
});
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
showEntry.value = false;
await loadStock();
await refresh();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
@ -191,14 +567,14 @@ async function saveEntry() {
async function saveExit() {
try {
const r = await api<{ data?: { budget_warning?: { message: string } }; message?: string }>(
const r = await api<{ budget_warning?: { message: string }; message?: string }>(
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
{ method: "POST", json: exitForm },
);
exitWarning.value = r.data?.budget_warning?.message ?? "";
exitWarning.value = r.budget_warning?.message ?? "";
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
if (!exitWarning.value) showExit.value = false;
await loadStock();
await refresh();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
@ -207,29 +583,138 @@ async function saveExit() {
async function saveTransfer() {
try {
const r = await api<{ message?: string }>("/v1/warehouse-transfers", { method: "POST", json: transferForm });
toast.add({ severity: "success", summary: r.message || "Transferencia completada", life: 2000 });
toast.add({ severity: "success", summary: r.message || "Traspaso completado", life: 2000 });
showTransfer.value = false;
await loadStock();
await refresh();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
onMounted(async () => {
async function openWarehouse() {
if (!openProjectId.value) return;
try {
const r = await api<{ message?: string }>(`/v1/projects/${openProjectId.value}/warehouse/open`, {
method: "POST",
json: {},
});
toast.add({ severity: "success", summary: r.message || "Almacén abierto", life: 2200 });
showOpen.value = false;
const projectId = openProjectId.value;
openProjectId.value = null;
await loadWarehouses();
const opened = warehouses.value.find((w) => w.project_id === projectId);
desk.treeKey.value = opened ? `w:${opened.id}` : "global";
await refresh();
syncChrome();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function closeWarehouse() {
const projectId = selectedWarehouse.value?.project_id;
if (!projectId) return;
try {
const r = await api<{ message?: string }>(`/v1/projects/${projectId}/warehouse/close`, {
method: "POST",
json: { close_notes: closeNotes.value, transfer_remaining: transferRemaining.value },
});
toast.add({ severity: "success", summary: r.message || "Almacén cerrado", life: 2200 });
showClose.value = false;
closeNotes.value = "";
transferRemaining.value = true;
desk.treeKey.value = "global";
await loadWarehouses();
await refresh();
syncChrome();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function onMaterialSaved(material: Material) {
await loadMaterials();
if (material?.id) entryForm.material_id = material.id;
}
async function refresh() {
await Promise.all([loadStock(), loadMovements(), loadMaterials()]);
syncChrome();
}
function syncChrome() {
const projectNodes = projectWarehouses.value.map((w) => ({
key: `w:${w.id}`,
label: w.project_name || w.label,
icon: "pi pi-building",
}));
desk.configure({
tab: "Almacén",
ribbon: [{
treeTitle: "Almacenes",
showTree: true,
showDetail: false,
treeKey: desk.treeKey.value || "global",
countLabel: `${visibleStock.value.length} líneas`,
tree: [
{ key: "global", label: "Almacén global", icon: "pi pi-box", data: { count: consolidated.value.length || stockRows.value.length } },
...projectNodes,
],
onTreeSelect: () => {
void refresh();
},
ribbon: [
{
key: "almacenes",
label: "Almacenes",
items: [
{
key: "open",
label: "Abrir almacén",
icon: "pi pi-folder-open",
disabled: !canUpdate.value,
command: () => {
openProjectId.value = projectsWithoutWarehouse.value[0]?.id ?? null;
showOpen.value = true;
},
},
{
key: "close",
label: "Cerrar almacén",
icon: "pi pi-lock",
disabled: !canUpdate.value || isGlobalView.value || selectedWarehouse.value?.kind !== "project",
title: isGlobalView.value
? "El almacén global no se cierra"
: "Cerrar el almacén de la obra seleccionada",
command: () => { showClose.value = true; },
},
],
},
{
key: "wh",
label: "Movimientos",
items: [
{ key: "entry", label: "Entrada", icon: "pi pi-download", command: () => { showEntry.value = true; } },
{ key: "exit", label: "Salida", icon: "pi pi-upload", command: () => { showExit.value = true; } },
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", command: () => { showTransfer.value = true; } },
{ key: "entry", label: "Entrada", icon: "pi pi-download", disabled: !canCreate.value, command: openEntry },
{ key: "exit", label: "Salida", icon: "pi pi-upload", disabled: !canUpdate.value, command: openExitDlg },
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", disabled: !canUpdate.value, command: openTransferDlg },
],
},
{
key: "cat",
label: "Catálogo",
items: [
{ key: "materials", label: "Materiales", icon: "pi pi-book", command: () => { showCatalog.value = true; } },
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: () => { void refresh(); } },
],
},
],
}],
});
await loadWarehouses();
await loadStock();
await loadMovements();
}
onMounted(async () => {
desk.treeKey.value = "global";
syncChrome();
await Promise.all([loadProjects(), loadWarehouses(), loadMaterials()]);
await refresh();
});
</script>

View file

@ -4,12 +4,16 @@
<Chip v-if="tab === 'empresas'" :label="`${listedCompanies.length} empresas`" icon="pi pi-briefcase" />
<Chip v-if="tab === 'empresas'" :label="`${subCompanies} subempresas`" icon="pi pi-th-large" />
<Chip v-if="tab === 'nomina'" :label="`${units.length} unidades`" icon="pi pi-percentage" />
<Chip v-if="tab === 'proveedores'" :label="`${listedSuppliers.length} proveedores`" icon="pi pi-truck" />
<Chip v-if="tab === 'unidades'" :label="`${listedMeasureUnits.length} unidades`" icon="pi pi-box" />
<Chip v-if="tab === 'riesgos'" :label="`${listedRisks.length} riesgos`" icon="pi pi-exclamation-triangle" />
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
</DeskTableBar>
<Tabs v-model:value="tab">
<TabList>
<Tab value="empresas">Empresas</Tab>
<Tab value="proveedores">Proveedores</Tab>
<Tab value="unidades">Unidades</Tab>
<Tab value="nomina">Nómina</Tab>
<Tab value="riesgos">Riesgos</Tab>
<Tab value="temas">Temas de gafete</Tab>
@ -74,6 +78,7 @@
</div>
</div>
<h3 class="mt-4">Unidades de destajo</h3>
<p class="muted">Solo para destajo de nómina. Las unidades de gastos y presupuesto están en <strong>Unidades</strong>.</p>
<DataTable :value="units" class="mt-2" empty-message="Sin unidades">
<Column field="code" header="Código" />
<Column field="label" header="Nombre" />
@ -90,6 +95,51 @@
</div>
</div>
</TabPanel>
<TabPanel value="proveedores">
<Message severity="info" :closable="false">
Catálogo de proveedores para capturar gastos. En Gastos solo se selecciona; aquí se guardan nombre, RFC y datos generales.
</Message>
<DataTable :value="listedSuppliers" class="mt-3" data-key="id" paginator :rows="15" empty-message="No hay proveedores. Use Nuevo proveedor.">
<Column field="name" header="Nombre" />
<Column field="legal_name" header="Razón social">
<template #body="{ data }">{{ data.legal_name || "—" }}</template>
</Column>
<Column field="rfc" header="RFC">
<template #body="{ data }"><span class="identifier">{{ data.rfc || "—" }}</span></template>
</Column>
<Column field="phone" header="Teléfono">
<template #body="{ data }">{{ data.phone || "—" }}</template>
</Column>
<Column field="email" header="Correo">
<template #body="{ data }">{{ data.email || "—" }}</template>
</Column>
<Column header="" header-style="width: 5rem">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded size="small" aria-label="Editar" @click="openSupplier(data)" />
</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel value="unidades">
<Message severity="info" :closable="false">
Unidades de medida para gastos (pieza, m², bulto…). Distintas de las unidades de destajo de nómina.
</Message>
<DataTable :value="listedMeasureUnits" class="mt-3" data-key="code" paginator :rows="15" empty-message="Sin unidades">
<Column field="code" header="Código" />
<Column field="label" header="Nombre" />
</DataTable>
<div class="company-form-grid mt-3" style="max-width: 36rem">
<FormField label="Código">
<InputText v-model="measureUnitForm.code" class="w-full" placeholder="bulto" />
</FormField>
<FormField label="Nombre">
<InputText v-model="measureUnitForm.label" class="w-full" placeholder="Bulto" />
</FormField>
<div>
<Button label="Agregar unidad" icon="pi pi-plus" outlined @click="addMeasureUnit" />
</div>
</div>
</TabPanel>
<TabPanel value="riesgos">
<DataTable :value="listedRisks" paginator :rows="15">
<Column field="code" header="Barra gafete" />
@ -215,12 +265,15 @@
<Button v-if="formTab === 'datos'" :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<SupplierFormDialog v-model="showSupplierForm" :supplier="editingSupplier" @saved="onSupplierSaved" />
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import CompanyDocuments from "~/components/CompanyDocuments.vue";
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
import type { CompanyOption } from "~/types/padron";
const emptyForm = () => ({
@ -258,6 +311,11 @@ const payroll = reactive({
});
const units = ref<{ code: string; label: string }[]>([]);
const unitForm = reactive({ code: "", label: "" });
const suppliers = ref<Supplier[]>([]);
const measureUnits = ref<{ code: string; label: string }[]>([]);
const measureUnitForm = reactive({ code: "", label: "" });
const showSupplierForm = ref(false);
const editingSupplier = ref<Supplier | null>(null);
const savingPayroll = ref(false);
const showForm = ref(false);
const saving = ref(false);
@ -293,7 +351,17 @@ const claseRiesgoOpts = [
const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
const { tableQ, matches } = useTableSearch();
const searchPlaceholder = computed(() =>
tab.value === "riesgos" ? "Buscar riesgo o color" : tab.value === "temas" ? "Buscar tema" : tab.value === "nomina" ? "Nómina y destajo" : "Buscar empresa, RFC o código",
tab.value === "riesgos"
? "Buscar riesgo o color"
: tab.value === "temas"
? "Buscar tema"
: tab.value === "nomina"
? "Nómina y destajo"
: tab.value === "proveedores"
? "Buscar proveedor o RFC"
: tab.value === "unidades"
? "Buscar unidad"
: "Buscar empresa, RFC o código",
);
const listedCompanies = computed(() =>
companies.value.filter((company) =>
@ -315,6 +383,12 @@ const listedCompanies = computed(() =>
const subCompanies = computed(() => listedCompanies.value.filter((company) => company.kind !== "principal").length);
const listedRisks = computed(() => risks.value.filter((risk) => matches(risk.code, risk.label, risk.color)));
const listedThemes = computed(() => themes.value.filter((theme) => matches(theme.id, theme.name, theme.layout)));
const listedSuppliers = computed(() =>
suppliers.value.filter((s) => matches(s.name, s.legal_name, s.rfc, s.email, s.phone, s.city)),
);
const listedMeasureUnits = computed(() =>
measureUnits.value.filter((u) => matches(u.code, u.label)),
);
function regimenLabel(code?: string | null) {
if (!code) return "—";
@ -344,6 +418,18 @@ async function load() {
payroll.loan_commission_pct = Number(pay.settings.loan_commission_pct);
payroll.loan_small_max = Number(pay.settings.loan_small_max);
units.value = pay.units || [];
try {
const sup = await api<{ suppliers?: Supplier[]; data?: { suppliers?: Supplier[] } }>("/v1/suppliers");
suppliers.value = sup.suppliers ?? sup.data?.suppliers ?? [];
} catch {
suppliers.value = [];
}
try {
const mu = await api<{ units?: { code: string; label: string }[]; data?: { units?: { code: string; label: string }[] } }>("/v1/measure-units");
measureUnits.value = mu.units ?? mu.data?.units ?? [];
} catch {
measureUnits.value = [];
}
syncChrome();
}
@ -367,6 +453,30 @@ async function addUnit() {
await load();
}
async function addMeasureUnit() {
if (!measureUnitForm.code.trim()) return;
await api("/v1/measure-units", { method: "POST", json: { code: measureUnitForm.code, label: measureUnitForm.label } });
measureUnitForm.code = "";
measureUnitForm.label = "";
toast.add({ severity: "success", summary: "Unidad de medida agregada", life: 1600 });
await load();
}
function openNewSupplier() {
editingSupplier.value = null;
showSupplierForm.value = true;
}
function openSupplier(row: Supplier) {
editingSupplier.value = row;
showSupplierForm.value = true;
}
async function onSupplierSaved() {
toast.add({ severity: "success", summary: "Proveedor guardado", life: 1600 });
await load();
}
function openNew() {
editing.value = null;
formTab.value = "datos";
@ -474,10 +584,12 @@ function syncChrome() {
showDetail: false,
treeKey: tab.value,
onTreeSelect: (key) => {
if (key === "empresas" || key === "riesgos" || key === "temas" || key === "nomina") tab.value = key;
if (["empresas", "proveedores", "unidades", "riesgos", "temas", "nomina"].includes(key)) tab.value = key;
},
tree: [
{ key: "empresas", label: "Empresas", icon: "pi pi-briefcase", data: { count: companies.value.length } },
{ key: "proveedores", label: "Proveedores", icon: "pi pi-truck", data: { count: suppliers.value.length } },
{ key: "unidades", label: "Unidades", icon: "pi pi-box", data: { count: measureUnits.value.length } },
{ key: "nomina", label: "Nómina", icon: "pi pi-wallet" },
{ key: "riesgos", label: "Riesgos", icon: "pi pi-exclamation-triangle", data: { count: risks.value.length } },
{ key: "temas", label: "Temas de gafete", icon: "pi pi-palette", data: { count: themes.value.length } },
@ -488,6 +600,7 @@ function syncChrome() {
label: "Acciones",
items: [
{ key: "alta", label: "Nueva subempresa", icon: "pi pi-plus", disabled: tab.value !== "empresas", command: openNew },
{ key: "proveedor", label: "Nuevo proveedor", icon: "pi pi-plus", disabled: tab.value !== "proveedores", command: openNewSupplier },
],
},
{
@ -502,7 +615,7 @@ onMounted(load);
watch(
() => String(route.query.tab || ""),
(key) => {
if (key === "empresas" || key === "riesgos" || key === "temas" || key === "nomina") tab.value = key;
if (["empresas", "proveedores", "unidades", "riesgos", "temas", "nomina"].includes(key)) tab.value = key;
},
{ immediate: true },
);

View file

@ -6,6 +6,16 @@
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
</DeskTableBar>
<Message
v-if="summary && items.length && Number(summary.executed_subtotal) === 0"
severity="info"
:closable="false"
class="mb-3"
>
Esta pantalla compara presupuesto contra lo ejecutado. Los gastos se capturan en
<strong>Gastos</strong>, ligados a un concepto. Use «Registrar gasto» o haga clic en un renglón.
</Message>
<div v-if="summary" class="kpi-row mb-3">
<div class="kpi-card">
<span class="kpi-label">Presupuesto</span>
@ -38,6 +48,7 @@
scroll-height="flex"
table-style="min-width: 72rem"
empty-message="Seleccione una obra con presupuesto cargado."
@row-click="onRowClick"
>
<Column field="code" header="Clave" style="width: 7rem">
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
@ -91,6 +102,7 @@ type Summary = {
const desk = useDesktop();
const { api } = useApi();
const route = useRoute();
const perms = usePermissions();
const tableQ = ref("");
const projects = ref<{ id: number; name: string }[]>([]);
@ -99,6 +111,7 @@ const summary = ref<Summary | null>(null);
const projectId = computed(() => Number(route.query.project_id || 0) || projects.value[0]?.id || 0);
const projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra");
const canCreateExpense = computed(() => perms.can("expenses.create"));
const filteredItems = computed(() => {
const q = tableQ.value.toLowerCase();
return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q));
@ -108,13 +121,33 @@ function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
function goGastos(opts?: { nuevo?: boolean; budgetItemId?: number }) {
if (!projectId.value) return;
const qs = new URLSearchParams({ project_id: String(projectId.value) });
if (opts?.nuevo) qs.set("nuevo", "1");
if (opts?.budgetItemId) qs.set("budget_item_id", String(opts.budgetItemId));
navigateTo(`/gastos?${qs}`);
}
function onRowClick(e: { data: ControlItem }) {
if (!canCreateExpense.value) {
goGastos();
return;
}
goGastos({ nuevo: true, budgetItemId: e.data.id });
}
async function loadProjects() {
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
projects.value = r.projects ?? [];
}
async function loadControl() {
if (!projectId.value) return;
if (!projectId.value) {
summary.value = null;
items.value = [];
return;
}
const [s, i] = await Promise.all([
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
@ -123,20 +156,67 @@ async function loadControl() {
items.value = i.data?.items ?? [];
}
onMounted(async () => {
await loadProjects();
function syncChrome() {
desk.configure({
tab: "Control presupuesto",
treeTitle: "Obras",
showTree: true,
showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "",
countLabel: projectId.value ? `${items.value.length} conceptos` : "",
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
onTreeSelect: (key) => {
if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`);
},
ribbon: [
{
key: "control",
label: "Control",
items: [
{
key: "gasto",
label: "Registrar gasto",
icon: "pi pi-plus",
disabled: !projectId.value || !canCreateExpense.value,
title: canCreateExpense.value
? "Captura un gasto ligado a un concepto del presupuesto"
: "Sin permiso para capturar gastos",
command: () => goGastos({ nuevo: true }),
},
{
key: "gastos",
label: "Ver gastos",
icon: "pi pi-list",
disabled: !projectId.value,
command: () => goGastos(),
},
],
},
{
key: "vista",
label: "Vista",
items: [
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !projectId.value, command: loadControl },
],
},
],
});
}
onMounted(async () => {
await loadProjects();
if (!route.query.project_id && projectId.value) {
await navigateTo(`/control-presupuesto?project_id=${projectId.value}`, { replace: true });
}
syncChrome();
await loadControl();
syncChrome();
});
watch(projectId, loadControl);
watch(projectId, async () => {
await loadControl();
syncChrome();
});
</script>
<style scoped>
@ -150,4 +230,5 @@ watch(projectId, loadControl);
}
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
.cost-critical-text { color: #b42318; font-weight: 600; }
.control-page :deep(.p-datatable-tbody > tr) { cursor: pointer; }
</style>

View file

@ -149,7 +149,8 @@ const SAMPLE: Worker = {
const { api, download } = useApi();
const desk = useDesktop();
const toast = useToast();
const projects = ref<{ id: number; name: string; code?: string }[]>([]);
const { user } = useAuth();
const projects = ref<{ id: number; name: string; code?: string; company_name?: string }[]>([]);
const projectId = ref<number | null>(null);
const workers = ref<Worker[]>([]);
const workerId = ref(0);
@ -203,8 +204,9 @@ const cardProps = computed(() => {
blood: (w.blood_type || "").toUpperCase(),
riskColor: r?.color || w.risk_color || "#A20000",
riskText: r?.text_color || w.risk_text || "#FFFFFF",
projectName: (project.value?.name || "Zendala Cancún").toUpperCase(),
projectCode: (project.value?.code || "PRY-0001").toUpperCase(),
companyName: (project.value?.company_name || user.value?.company_name || "").toUpperCase(),
projectName: (project.value?.name || "Obra").toUpperCase(),
projectCode: (project.value?.code || "").toUpperCase(),
photoSrc: w.id ? `/v1/workers/${w.id}/photo` : "",
qrSrc: `/v1/badge-qr?curp=${encodeURIComponent(w.curp)}`,
};
@ -236,7 +238,7 @@ async function loadWorkers() {
async function load() {
const [p, j, c] = await Promise.all([
api<{ projects: { id: number; name: string; code?: string }[] }>("/v1/projects?status=activo"),
api<{ projects: { id: number; name: string; code?: string; company_name?: string }[] }>("/v1/projects?status=activo"),
api<{ jobs: Record<string, unknown>[] }>("/v1/badge-jobs"),
api<{ risks: { code: string; label: string; color: string; text_color: string }[] }>("/v1/catalogs"),
]);

View file

@ -3,7 +3,9 @@
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
<Chip :label="projectLabel" icon="pi pi-building" />
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" />
<Chip :label="`${unclassifiedCount} sin clasificar`" icon="pi pi-question-circle" />
<Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
<SelectButton v-model="listFilter" :options="listFilterOpts" option-label="label" option-value="value" :allow-empty="false" />
</DeskTableBar>
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
@ -11,20 +13,28 @@
</Message>
<DataTable
v-model:selection="selected"
:value="listed"
data-key="id"
paginator
:rows="25"
empty-message="No hay gastos en el periodo seleccionado."
selection-mode="single"
meta-key-selection="false"
scrollable
scroll-height="flex"
table-style="min-width: 56rem"
@row-click="onRow"
@row-dblclick="(e: { data: Expense }) => openEdit(e.data)"
>
<Column field="expense_date" header="Fecha" sortable style="width: 7rem" />
<Column field="description" header="Descripción" sortable />
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
<Column field="budget_item_code" header="Concepto" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.budget_item_code || "—" }}</span></template>
<Column field="budget_item_code" header="Concepto" style="width: 10rem">
<template #body="{ data }">
<Tag v-if="!data.budget_item_id" value="Sin clasificar" severity="warn" />
<span v-else class="identifier">{{ data.budget_item_code || "—" }}</span>
</template>
</Column>
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
<template #body="{ data }">{{ money(data.subtotal) }}</template>
@ -38,7 +48,7 @@
<Column field="source" header="Origen" style="width: 7rem" />
</DataTable>
<Dialog v-model:visible="showForm" header="Nuevo gasto" modal :style="{ width: '720px' }">
<Dialog v-model:visible="showForm" :header="editingId ? 'Editar gasto' : 'Nuevo gasto'" modal :style="{ width: '720px' }">
<div class="form-grid form-grid-compact">
<FormField label="Empresa" required>
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
@ -47,47 +57,106 @@
<Select v-model="form.project_id" :options="projects" option-label="name" option-value="id" show-clear placeholder="Gasto de empresa" class="w-full" />
</FormField>
<FormField label="Fecha" required>
<InputText v-model="form.expense_date" type="date" class="w-full" />
<DatePicker v-model="expenseDate" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker v-if="form.project_id" v-model="form.budget_item_id" :project-id="form.project_id" />
<BudgetItemPicker
v-if="form.project_id"
v-model="form.budget_item_id"
:project-id="form.project_id"
@select="onBudgetSelect"
/>
<InputText v-else disabled placeholder="Seleccione una obra para ligar concepto" class="w-full" />
</FormField>
<FormField label="Descripción" class="span-2" required>
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
</FormField>
<FormField label="Proveedor">
<InputText v-model="form.supplier_name" class="w-full" />
</FormField>
<FormField label="RFC proveedor">
<InputText v-model="form.supplier_rfc" class="w-full identifier-input" />
<FormField label="Proveedor" class="span-2">
<div class="flex gap-2 align-items-center">
<Select
v-model="form.supplier_id"
:options="supplierOptions"
option-label="label"
option-value="id"
placeholder="Seleccione proveedor"
filter
show-clear
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nuevo proveedor" title="Nuevo proveedor" @click="showSupplierForm = true" />
</div>
<small v-if="selectedSupplierRfc" class="muted">RFC {{ selectedSupplierRfc }}</small>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Unidad">
<InputText v-model="form.unit" class="w-full" />
<div class="flex gap-2 align-items-center">
<Select
v-model="form.unit"
:options="units"
option-label="label"
option-value="code"
placeholder="Seleccione unidad"
filter
show-clear
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nueva unidad" title="Nueva unidad" @click="showUnitForm = true" />
</div>
</FormField>
<FormField label="Precio unitario">
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<FormField label="Subtotal">
<InputNumber v-model="form.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<InputNumber :model-value="computedAmounts.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="IVA incluido en total">
<Checkbox v-model="form.tax_included" binary />
<FormField label="IVA incluido en el precio">
<div class="flex align-items-center gap-2">
<Checkbox v-model="form.tax_included" binary input-id="tax-included" />
<label for="tax-included" class="muted">El precio ya trae IVA</label>
</div>
</FormField>
<FormField label="IVA">
<InputNumber :model-value="computedAmounts.ivaAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="Total con IVA">
<InputNumber v-model="form.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<InputNumber :model-value="computedAmounts.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="Comprobante PDF" class="span-2">
<input type="file" accept="application/pdf" @change="onFile" />
<FileUpload
mode="basic"
:auto="false"
choose-label="Seleccionar PDF"
accept="application/pdf,.pdf"
custom-upload
@select="onPickReceipt"
/>
<div class="muted mt-1">{{ receiptFile ? receiptFile.name : "Ningún archivo seleccionado" }}</div>
</FormField>
</div>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showForm = false" />
<Button label="Guardar" icon="pi pi-check" @click="save" />
<Button :label="editingId ? 'Guardar cambios' : 'Guardar'" icon="pi pi-check" @click="save" />
</template>
</Dialog>
<SupplierFormDialog v-model="showSupplierForm" @saved="onSupplierSaved" />
<Dialog v-model:visible="showUnitForm" header="Nueva unidad" modal :style="{ width: '420px' }">
<div class="form-grid form-grid-compact">
<FormField label="Código" required>
<InputText v-model="unitForm.code" class="w-full identifier-input" placeholder="bulto" />
</FormField>
<FormField label="Nombre" required>
<InputText v-model="unitForm.label" class="w-full" placeholder="Bulto" />
</FormField>
</div>
<Message v-if="unitError" severity="error" class="mt-3" :closable="false">{{ unitError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showUnitForm = false" />
<Button label="Guardar" icon="pi pi-check" @click="saveUnit" />
</template>
</Dialog>
</div>
@ -96,14 +165,24 @@
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
import { displayErrorMessage } from "~/composables/api-response";
import { expenseAmounts } from "~/composables/expenseAmounts";
type Expense = {
id: number;
expense_date: string;
description: string;
supplier_name: string;
supplier_id?: number | null;
budget_item_id?: number | null;
budget_item_code?: string;
company_id?: number | null;
quantity?: number;
unit?: string;
unit_price?: number;
tax_included?: boolean;
is_locked?: boolean;
subtotal: number;
iva_amount: number;
total: number;
@ -115,15 +194,31 @@ const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const route = useRoute();
const perms = usePermissions();
const tableQ = ref("");
const expenses = ref<Expense[]>([]);
const companies = ref<{ id: number; name: string }[]>([]);
const projects = ref<{ id: number; name: string }[]>([]);
const suppliers = ref<Supplier[]>([]);
const units = ref<{ code: string; label: string }[]>([]);
const showForm = ref(false);
const showSupplierForm = ref(false);
const showUnitForm = ref(false);
const formError = ref("");
const unitError = ref("");
const budgetWarning = ref<{ level: string; message: string } | null>(null);
const receiptFile = ref<File | null>(null);
const ivaRate = ref(0.16);
const editingId = ref<number | null>(null);
const selected = ref<Expense | null>(null);
const listFilter = ref<"all" | "unclassified">("all");
const listFilterOpts = [
{ label: "Todos", value: "all" },
{ label: "Sin clasificar", value: "unclassified" },
];
const canCreateExpense = computed(() => perms.can("expenses.create"));
const canUpdateExpense = computed(() => perms.can("expenses.update"));
const form = reactive({
company_id: null as number | null,
@ -131,16 +226,37 @@ const form = reactive({
expense_date: new Date().toISOString().slice(0, 10),
budget_item_id: null as number | null,
description: "",
supplier_name: "",
supplier_rfc: "",
supplier_id: null as number | null,
quantity: 0,
unit: "",
unit_price: 0,
subtotal: 0,
total: 0,
tax_included: false,
});
const unitForm = reactive({ code: "", label: "" });
const expenseDate = computed({
get: () => parseDate(form.expense_date),
set: (value: Date | null) => {
form.expense_date = isoDate(value) || new Date().toISOString().slice(0, 10);
},
});
const computedAmounts = computed(() =>
expenseAmounts(form.quantity, form.unit_price, form.tax_included, ivaRate.value),
);
const supplierOptions = computed(() =>
suppliers.value.map((s) => ({
...s,
label: s.rfc ? `${s.name} · ${s.rfc}` : s.name,
})),
);
const selectedSupplierRfc = computed(() =>
suppliers.value.find((s) => s.id === form.supplier_id)?.rfc || "",
);
const projectId = computed(() => Number(route.query.project_id || 0) || null);
const projectLabel = computed(() => {
const p = projects.value.find((x) => x.id === projectId.value);
@ -148,19 +264,169 @@ const projectLabel = computed(() => {
});
const listed = computed(() => {
const q = tableQ.value.toLowerCase();
return expenses.value.filter((e) =>
!q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q),
);
return expenses.value.filter((e) => {
if (listFilter.value === "unclassified" && e.budget_item_id) return false;
return !q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q);
});
});
const unclassifiedCount = computed(() => expenses.value.filter((e) => !e.budget_item_id).length);
const totalSubtotal = computed(() => listed.value.reduce((s, e) => s + Number(e.subtotal || 0), 0));
const canEditSelected = computed(() =>
Boolean(selected.value && canUpdateExpense.value && isEditable(selected.value)),
);
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
function onFile(e: Event) {
const input = e.target as HTMLInputElement;
receiptFile.value = input.files?.[0] ?? null;
function parseDate(value: unknown): Date | null {
const raw = String(value || "").slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const [year, month, day] = raw.split("-").map(Number);
return new Date(year, month - 1, day);
}
function isoDate(value: Date | null) {
if (!value) return "";
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function onPickReceipt(event: { files: File[] }) {
receiptFile.value = event.files?.[0] || null;
}
function onBudgetSelect(item: { unit?: string } | null) {
const code = String(item?.unit || "").trim().toLowerCase();
if (code && units.value.some((u) => u.code === code)) form.unit = code;
}
function onSupplierSaved(supplier: Supplier) {
suppliers.value = [...suppliers.value.filter((s) => s.id !== supplier.id), supplier]
.sort((a, b) => a.name.localeCompare(b.name, "es"));
form.supplier_id = supplier.id;
}
function isEditable(row: Expense) {
return row.source === "manual" && !row.is_locked;
}
function resetForm() {
form.company_id = companies.value[0]?.id ?? null;
form.project_id = projectId.value;
form.expense_date = isoDate(new Date());
form.budget_item_id = Number(route.query.budget_item_id || 0) || null;
form.description = "";
form.supplier_id = null;
form.quantity = 0;
form.unit = "";
form.unit_price = 0;
form.tax_included = false;
receiptFile.value = null;
formError.value = "";
}
function openNew() {
editingId.value = null;
resetForm();
showForm.value = true;
}
function onRow(e: { data: Expense }) {
selected.value = e.data;
}
async function openEdit(row: Expense) {
selected.value = row;
if (!canUpdateExpense.value) return;
if (!isEditable(row)) {
toast.add({
severity: "info",
summary: "Este gasto no se edita aquí",
detail: row.source === "manual" ? "Está bloqueado" : "Viene de nómina o destajo",
life: 3000,
});
return;
}
formError.value = "";
try {
const r = await api<{ expense?: Expense; data?: { expense?: Expense } }>(`/v1/expenses/${row.id}`);
const e = r.expense ?? r.data?.expense ?? row;
editingId.value = e.id;
form.company_id = e.company_id ?? row.company_id ?? companies.value[0]?.id ?? null;
form.project_id = e.project_id ?? null;
form.expense_date = String(e.expense_date || "").slice(0, 10);
form.budget_item_id = e.budget_item_id ?? null;
form.description = e.description || "";
form.supplier_id = e.supplier_id ?? null;
form.quantity = Number(e.quantity || 0);
form.unit = e.unit || "";
form.unit_price = Number(e.unit_price || 0);
form.tax_included = Boolean(e.tax_included);
receiptFile.value = null;
showForm.value = true;
} catch (err) {
toast.add({
severity: "error",
summary: displayErrorMessage((err as { data?: unknown }).data ?? err),
life: 4000,
});
}
}
function syncChrome() {
desk.configure({
tab: "Gastos",
treeTitle: "Obras",
showTree: true,
showDetail: false,
treeKey: projectId.value ? `p:${projectId.value}` : "",
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
onTreeSelect: (key) => {
if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`);
},
ribbon: [
{
key: "gastos",
label: "Captura",
items: [
{
key: "new",
label: "Nuevo gasto",
icon: "pi pi-plus",
disabled: !canCreateExpense.value,
command: openNew,
},
{
key: "edit",
label: "Reclasificar",
icon: "pi pi-pencil",
disabled: !canEditSelected.value,
title: canEditSelected.value
? "Cambiar concepto u otros datos del gasto"
: "Seleccione un gasto manual sin clasificar o para cambiar de partida",
command: () => selected.value && openEdit(selected.value),
},
],
},
{
key: "control",
label: "Control",
items: [
{
key: "control",
label: "Control presupuesto",
icon: "pi pi-chart-bar",
disabled: !projectId.value,
command: () => navigateTo(`/control-presupuesto?project_id=${projectId.value}`),
},
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: loadExpenses },
],
},
],
});
}
async function loadCatalogs() {
@ -171,13 +437,70 @@ async function loadCatalogs() {
companies.value = cat.companies ?? [];
projects.value = pr.projects ?? [];
if (!form.company_id && companies.value[0]) form.company_id = companies.value[0].id;
try {
const sup = await api<{ suppliers?: Supplier[]; data?: { suppliers?: Supplier[] } }>("/v1/suppliers?status=activo");
suppliers.value = sup.suppliers ?? sup.data?.suppliers ?? [];
} catch {
suppliers.value = [];
}
try {
const un = await api<{ units?: { code: string; label: string }[]; data?: { units?: { code: string; label: string }[] } }>("/v1/measure-units");
units.value = un.units ?? un.data?.units ?? [];
} catch {
units.value = [];
}
try {
const settings = await api<{ default_iva_rate?: number; data?: { default_iva_rate?: number } }>("/v1/cost-settings");
ivaRate.value = Number(settings.default_iva_rate ?? settings.data?.default_iva_rate ?? 0.16) || 0.16;
} catch {
ivaRate.value = 0.16;
}
}
async function saveUnit() {
unitError.value = "";
if (!unitForm.code.trim()) {
unitError.value = "El código de unidad es obligatorio";
return;
}
try {
const r = await api<{ unit?: { code: string; label: string }; data?: { unit?: { code: string; label: string } } }>(
"/v1/measure-units",
{ method: "POST", json: { code: unitForm.code, label: unitForm.label } },
);
const unit = r.unit ?? r.data?.unit;
if (unit) {
units.value = [...units.value.filter((u) => u.code !== unit.code), unit]
.sort((a, b) => a.label.localeCompare(b.label, "es"));
form.unit = unit.code;
}
unitForm.code = "";
unitForm.label = "";
showUnitForm.value = false;
} catch (e) {
unitError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
}
async function loadExpenses() {
const qs = new URLSearchParams();
if (projectId.value) qs.set("project_id", String(projectId.value));
const r = await api<{ data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
expenses.value = (r.data?.expenses ?? []) as Expense[];
const r = await api<{ expenses?: Expense[]; data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
const keep = selected.value?.id;
expenses.value = (r.expenses ?? r.data?.expenses ?? []) as Expense[];
selected.value = keep ? expenses.value.find((e) => e.id === keep) ?? null : null;
}
function payload() {
return {
...form,
project_id: form.project_id || null,
budget_item_id: form.budget_item_id || null,
supplier_id: form.supplier_id || null,
subtotal: computedAmounts.value.subtotal,
total: computedAmounts.value.total,
iva_rate: ivaRate.value,
};
}
async function save() {
@ -187,23 +510,35 @@ async function save() {
return;
}
try {
const r = await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
const r = editingId.value
? await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
`/v1/expenses/${editingId.value}`,
{ method: "PATCH", json: payload() },
)
: await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
"/v1/expenses",
{
method: "POST",
json: { ...form, project_id: form.project_id || null },
},
{ method: "POST", json: payload() },
);
const expenseId = r.data?.expense?.id;
const expenseId = r.data?.expense?.id ?? editingId.value;
if (expenseId && receiptFile.value) {
const fd = new FormData();
fd.append("file", receiptFile.value);
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
}
budgetWarning.value = r.data?.budget_warning ?? null;
toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 });
toast.add({
severity: "success",
summary: r.message || (editingId.value ? "Gasto actualizado" : "Gasto registrado"),
life: 2500,
});
showForm.value = false;
editingId.value = null;
await loadExpenses();
if (route.query.nuevo || route.query.budget_item_id) {
const qs = new URLSearchParams();
if (projectId.value) qs.set("project_id", String(projectId.value));
await navigateTo(`/gastos${qs.toString() ? `?${qs}` : ""}`, { replace: true });
}
} catch (e) {
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
@ -211,25 +546,22 @@ async function save() {
onMounted(async () => {
await loadCatalogs();
desk.configure({
tab: "Gastos",
treeTitle: "Obras",
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
onTreeSelect: (key) => {
if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`);
},
ribbon: [
{
key: "gastos",
label: "Captura",
items: [
{ key: "new", label: "Nuevo gasto", icon: "pi pi-plus", command: () => { showForm.value = true; } },
],
},
],
});
syncChrome();
await loadExpenses();
if (route.query.nuevo) openNew();
});
watch(projectId, loadExpenses);
watch(projectId, () => {
loadExpenses();
syncChrome();
});
watch(selected, syncChrome);
watch(
() => String(route.query.nuevo || ""),
(nuevo) => {
if (nuevo) openNew();
},
);
</script>

View file

@ -406,7 +406,7 @@ function buildHomeAlerts(input: {
key: "home_frescos",
severity: "warn",
title: `${freshness} con documentos por refrescar`,
detail: "Reingreso o alta pendiente: constancias con más de 3 meses.",
detail: "Reingreso o alta pendiente: constancias subidas hace más de 3 meses.",
route: "/padron",
filter: "freshness",
});

View file

@ -46,9 +46,15 @@
<TabPanels>
<TabPanel value="obra">
<Message severity="secondary" :closable="false">
Lun–sáb. Clic en la persona abre la ficha (consulta). El préstamo se registra con <strong>Préstamo</strong> en la barra o desde la ficha.
Marque L–S para asistencia y desmarque para falta. Clic en la persona abre la ficha (consulta). El préstamo se registra con <strong>Préstamo</strong> en la barra o desde la ficha.
</Message>
<DataTable :value="obraLines" scrollable scroll-height="flex" empty-message="Sin personal de jornal en esta obra">
<DataTable
:value="obraLines"
scrollable
scroll-height="flex"
table-style="min-width: 52rem"
empty-message="Sin personal de jornal en esta obra"
>
<Column header="Persona" frozen>
<template #body="{ data }">
<button type="button" class="person-link" @click="openPerson(data.worker_id)">
@ -60,7 +66,7 @@
v-for="(day, idx) in days"
:key="day"
:header="dayHeaders[idx]"
style="width: 5.5rem; text-align: center"
style="width: 4.2rem; min-width: 3.6rem; text-align: center"
>
<template #body="{ data }">
<Tag
@ -377,7 +383,8 @@ async function load() {
destajoPeriod.value = bundle.destajo_period;
sheets.value = bundle.sheets;
destajoCuts.value = bundle.destajo_cuts || [];
days.value = bundle.days || [];
const start = String(bundle.week?.week_start || weekStart.value);
days.value = bundle.days?.length ? bundle.days : [0, 1, 2, 3, 4, 5].map((n) => addDaysIso(start, n));
board.value = {
required_net: 0,
payable_net: 0,

View file

@ -265,7 +265,7 @@ function baja(worker: Worker) {
function reactivate(worker: Worker) {
confirm.require({
header: "Reactivar persona",
message: `¿Reactivar a ${worker.first_name} ${worker.last_name_p}? Se pedirán documentos frescos (máx. 3 meses) para una nueva alta IMSS.`,
message: `¿Reactivar a ${worker.first_name} ${worker.last_name_p}? Se pedirán documentos subidos en los últimos 3 meses para una nueva alta IMSS.`,
icon: "pi pi-user-plus",
acceptLabel: "Reactivar",
rejectLabel: "Cancelar",
@ -274,7 +274,7 @@ function reactivate(worker: Worker) {
toast.add({
severity: "info",
summary: "Reingreso",
detail: "Actualiza documentos IMSS (máx. 3 meses) antes del alta.",
detail: "Vuelve a subir CURP, NSS, RFC y domicilio (máx. 3 meses desde la carga) antes del alta.",
life: 4000,
});
await loadWorkers(worker.id);
@ -310,7 +310,7 @@ function syncStatusMessages() {
key: "docs_frescos",
severity: "warn" as const,
title: `${freshnessCount.value} con documentos por refrescar`,
detail: "Reingreso o pendiente de alta: CURP, NSS, RFC o domicilio con más de 3 meses.",
detail: "Reingreso o pendiente de alta: CURP, NSS, RFC o domicilio subidos hace más de 3 meses.",
route: "/padron",
filter: "freshness",
});

View file

@ -124,7 +124,7 @@
/>
<div v-if="readingBudget" class="muted mt-1">Leyendo Excel…</div>
<div v-else-if="presupuestoFile" class="muted mt-1">{{ presupuestoFile.name }}</div>
<div v-else class="muted mt-1">PDF o Excel. Si es Excel, el monto de contrato se calcula solo.</div>
<div v-else class="muted mt-1">PDF para el expediente, o Excel (.xlsx / .xls) para cargar partidas y el monto.</div>
</div>
</FormField>
</div>
@ -142,6 +142,8 @@
import FormField from "~/components/padron/FormField.vue";
import type { CompanyOption } from "~/types/padron";
import { companyLabel } from "~/types/padron";
import { displayErrorMessage } from "~/composables/api-response";
import { isExcelSpreadsheet } from "~/utils/excelFile";
type Project = Record<string, unknown> & {
id: number;
@ -289,9 +291,7 @@ function onPickContrato(event: { files: File[] }) {
}
function isExcelFile(file: File) {
const name = file.name.toLowerCase();
return name.endsWith(".xlsx") || name.endsWith(".xls") ||
file.type.includes("spreadsheet") || file.type.includes("excel");
return isExcelSpreadsheet(file);
}
function moneyMx(value: number) {
@ -302,7 +302,8 @@ async function onPickPresupuesto(event: { files: File[] }) {
const file = event.files?.[0] || null;
presupuestoFile.value = file;
budgetHint.value = "";
if (!file || !isExcelFile(file)) return;
if (!file) return;
if (!isExcelFile(file)) return;
readingBudget.value = true;
try {
const body = new FormData();
@ -310,6 +311,7 @@ async function onPickPresupuesto(event: { files: File[] }) {
const preview = await api<{
totals: { subtotal: number; iva: number; total: number; item_count: number };
format_label?: string;
errors?: { messages: string[] }[];
}>("/v1/budget/preview", { method: "POST", body });
const subtotal = Number(preview.totals?.subtotal || 0);
if (subtotal > 0) {
@ -321,14 +323,23 @@ async function onPickPresupuesto(event: { files: File[] }) {
detail: `Subtotal del presupuesto: ${moneyMx(subtotal)}`,
life: 2800,
});
}
} catch (error) {
const detail = error as { data?: { error?: string } };
} else {
const message = preview.errors?.[0]?.messages?.[0]
|| "El Excel no tiene el formato de presupuesto esperado. Use la plantilla del panel u Opus/Neodata.";
toast.add({
severity: "warn",
summary: "Excel sin partidas",
detail: message,
life: 4500,
});
}
} catch (error) {
const detail = error as { data?: unknown };
toast.add({
severity: "error",
summary: "No se pudo leer el Excel",
detail: detail.data?.error || (error instanceof Error ? error.message : "Revise el formato"),
life: 3500,
detail: displayErrorMessage(detail.data ?? error, error instanceof Error ? error.message : "Revise que sea .xlsx o .xls con el formato de presupuesto"),
life: 4500,
});
} finally {
readingBudget.value = false;
@ -367,12 +378,12 @@ async function uploadDocs(projectId: number) {
});
}
} catch (error) {
const detail = error as { data?: { error?: string } };
const detail = error as { data?: unknown };
toast.add({
severity: "warn",
summary: "Documento guardado, pero no se importaron partidas",
detail: detail.data?.error || (error instanceof Error ? error.message : "Revise el Excel"),
life: 4000,
detail: displayErrorMessage(detail.data ?? error, error instanceof Error ? error.message : "Revise el formato del Excel"),
life: 4500,
});
}
}

View file

@ -0,0 +1,13 @@
export const EXCEL_ACCEPT = ".xlsx,.xls,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
export function isExcelSpreadsheet(file: File): boolean {
const name = file.name.toLowerCase();
return name.endsWith(".xlsx") || name.endsWith(".xls");
}
export function excelOnlyMessage(fileName?: string): string {
const name = fileName?.trim();
return name
? `Solo se aceptan archivos Excel (.xlsx o .xls). «${name}» no es un Excel.`
: "Solo se aceptan archivos Excel (.xlsx o .xls).";
}