mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 16:33:18 +00:00
Compare commits
23 commits
9ea4706954
...
4750dab95f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4750dab95f | ||
|
|
ad152d46f4 | ||
|
|
5e4d9a6710 | ||
|
|
15ffb3ca29 | ||
|
|
9c198a3be7 | ||
|
|
2f2d23004e | ||
|
|
202eca337b | ||
|
|
016bc0bcee | ||
|
|
14985ae829 | ||
|
|
9c8b6ea19c | ||
|
|
31a7c1cbf5 | ||
|
|
e89b51bf43 | ||
|
|
af5bbd7566 | ||
|
|
e528d217d3 | ||
|
|
61e36696b9 | ||
|
|
4b5d9572ee | ||
|
|
7a31453dfd | ||
|
|
1e5bc22936 | ||
|
|
6fd673f581 | ||
|
|
b7bd167e08 | ||
|
|
b0a1a3147c | ||
|
|
fa3656d142 | ||
|
|
de07975d4a |
44 changed files with 3940 additions and 391 deletions
|
|
@ -801,10 +801,49 @@ const FORMAT_LABEL: Record<ParsedBudget["format"], string> = {
|
||||||
none: "No reconocido",
|
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 } {
|
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];
|
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[][] };
|
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 iva = Math.round(subtotal * BUDGET_IVA * 100) / 100;
|
||||||
const errors = parsed.items.length
|
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 {
|
return {
|
||||||
format: parsed.format,
|
format: parsed.format,
|
||||||
format_label: FORMAT_LABEL[parsed.format],
|
format_label: FORMAT_LABEL[parsed.format],
|
||||||
|
|
@ -902,7 +941,7 @@ export async function importBudgetExcel(database: Db, projectId: number, bytes:
|
||||||
chapters: 0,
|
chapters: 0,
|
||||||
inserted: 0,
|
inserted: 0,
|
||||||
skipped: parsed.skipped,
|
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);
|
await replaceBudgetFromParsed(database, projectId, parsed);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
import { assertEquals, assertStringIncludes } from "jsr:@std/assert@1";
|
||||||
import * as XLSX from "xlsx";
|
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", () => {
|
Deno.test("parsea partidas estilo CLAVE DESCRIPCION UNIDAD", () => {
|
||||||
const parsed = parseBudgetRows([
|
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.tree[0]?.children.length, 2);
|
||||||
assertEquals(preview.totals.item_count, 4);
|
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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,59 @@ Deno.test("RPC almacén: central, obra, entrada, transferencia, salida", async (
|
||||||
global: true,
|
global: true,
|
||||||
});
|
});
|
||||||
assertEquals(globalStock.ok, 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 () => {
|
Deno.test("RPC IAM v2: roles y permisos por role_id", async () => {
|
||||||
const url = Deno.env.get("DATABASE_URL_IAM");
|
const url = Deno.env.get("DATABASE_URL_IAM");
|
||||||
if (!url) throw new Error("DATABASE_URL_IAM requerido");
|
if (!url) throw new Error("DATABASE_URL_IAM requerido");
|
||||||
|
|
|
||||||
10
api/document_rpc.ts
Normal file
10
api/document_rpc.ts
Normal 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
13
api/document_rpc_test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
@ -82,18 +82,10 @@ export function evaluateDocumentValidity(
|
||||||
|
|
||||||
const issuedAt = doc.issued_at;
|
const issuedAt = doc.issued_at;
|
||||||
const expiresAt = doc.expires_at;
|
const expiresAt = doc.expires_at;
|
||||||
|
const uploadedAt = doc.uploaded_at;
|
||||||
const needFresh = freshnessRequired(ctx);
|
const needFresh = freshnessRequired(ctx);
|
||||||
|
|
||||||
if (policy.validity_mode === "expiry") {
|
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 exp = parseDay(expiresAt);
|
||||||
const todayDate = parseDay(today);
|
const todayDate = parseDay(today);
|
||||||
if (exp && todayDate && exp < todayDate) {
|
if (exp && todayDate && exp < todayDate) {
|
||||||
|
|
@ -115,20 +107,11 @@ export function evaluateDocumentValidity(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (policy.validity_mode === "freshness") {
|
if (policy.validity_mode === "freshness") {
|
||||||
if (policy.requires_issued_at && !issuedAt) {
|
if (needFresh) {
|
||||||
return {
|
const uploaded = parseDay(uploadedAt);
|
||||||
present: true,
|
|
||||||
valid: false,
|
|
||||||
validity_status: "missing_dates",
|
|
||||||
issued_at: issuedAt,
|
|
||||||
expires_at: expiresAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (needFresh && issuedAt) {
|
|
||||||
const issued = parseDay(issuedAt);
|
|
||||||
const days = policy.freshness_days ?? 90;
|
const days = policy.freshness_days ?? 90;
|
||||||
const todayForFreshness = parseDay(today) ?? new Date();
|
const todayForFreshness = parseDay(today) ?? new Date();
|
||||||
if (issued && daysBetween(issued, todayForFreshness) > days) {
|
if (uploaded && daysBetween(uploaded, todayForFreshness) > days) {
|
||||||
return {
|
return {
|
||||||
present: true,
|
present: true,
|
||||||
valid: false,
|
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. */
|
/** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */
|
||||||
export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [
|
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: "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: "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: 1, 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: 1, 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: 1, 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: 1, 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: "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: "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 },
|
{ code: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const curpPolicy: DocTypePolicy = {
|
||||||
required: true,
|
required: true,
|
||||||
validity_mode: "freshness",
|
validity_mode: "freshness",
|
||||||
freshness_days: 90,
|
freshness_days: 90,
|
||||||
requires_issued_at: true,
|
requires_issued_at: false,
|
||||||
requires_expires_at: false,
|
requires_expires_at: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -26,7 +26,7 @@ const inePolicy: DocTypePolicy = {
|
||||||
validity_mode: "expiry",
|
validity_mode: "expiry",
|
||||||
freshness_days: null,
|
freshness_days: null,
|
||||||
requires_issued_at: false,
|
requires_issued_at: false,
|
||||||
requires_expires_at: true,
|
requires_expires_at: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Deno.test("freshnessRequired: sin alta exige frescor", () => {
|
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 = {
|
const ctx: WorkerImssContext = {
|
||||||
imss_status: "sin_alta",
|
imss_status: "sin_alta",
|
||||||
last_rehire_at: null,
|
last_rehire_at: null,
|
||||||
|
|
@ -85,6 +85,22 @@ Deno.test("CURP stale when freshness required", () => {
|
||||||
assertEquals(result.valid, false);
|
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", () => {
|
Deno.test("CURP old but ok when already alta", () => {
|
||||||
const ctx: WorkerImssContext = {
|
const ctx: WorkerImssContext = {
|
||||||
imss_status: "alta",
|
imss_status: "alta",
|
||||||
|
|
@ -117,13 +133,26 @@ Deno.test("INE expired always invalid", () => {
|
||||||
assertEquals(result.valid, false);
|
assertEquals(result.valid, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("missing issued_at for freshness", () => {
|
Deno.test("freshness without issued_at uses uploaded_at", () => {
|
||||||
const result = evaluateDocumentValidity(
|
const result = evaluateDocumentValidity(
|
||||||
curpPolicy,
|
curpPolicy,
|
||||||
{ type_code: "curp", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" },
|
{ 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 },
|
{ 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", () => {
|
Deno.test("núcleo de persona no se agranda", () => {
|
||||||
|
|
|
||||||
67
api/expense_catalogs_http.ts
Normal file
67
api/expense_catalogs_http.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
102
api/main.ts
102
api/main.ts
|
|
@ -16,6 +16,7 @@ import {
|
||||||
type Db,
|
type Db,
|
||||||
} from "./db.ts";
|
} from "./db.ts";
|
||||||
import { callCoreFn } from "./rpc.ts";
|
import { callCoreFn } from "./rpc.ts";
|
||||||
|
import { normalizeDocumentGetPayload } from "./document_rpc.ts";
|
||||||
import {
|
import {
|
||||||
respondRpc,
|
respondRpc,
|
||||||
respondApiError,
|
respondApiError,
|
||||||
|
|
@ -65,6 +66,7 @@ import {
|
||||||
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
|
import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts";
|
||||||
import { registerPayrollRoutes } from "./payroll_http.ts";
|
import { registerPayrollRoutes } from "./payroll_http.ts";
|
||||||
import { registerExpenseRoutes } from "./expenses_http.ts";
|
import { registerExpenseRoutes } from "./expenses_http.ts";
|
||||||
|
import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts";
|
||||||
import { registerWarehouseRoutes } from "./warehouse_http.ts";
|
import { registerWarehouseRoutes } from "./warehouse_http.ts";
|
||||||
import { registerCostControlRoutes } from "./cost_control_http.ts";
|
import { registerCostControlRoutes } from "./cost_control_http.ts";
|
||||||
import { registerWorkProgramRoutes } from "./work_program_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 { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts";
|
||||||
import {
|
import {
|
||||||
buildBudgetTemplate,
|
buildBudgetTemplate,
|
||||||
|
budgetReadErrorMessage,
|
||||||
|
excelFileTypeError,
|
||||||
exportBudgetWorkbook,
|
exportBudgetWorkbook,
|
||||||
previewBudgetExcel,
|
previewBudgetExcel,
|
||||||
importBudgetExcel,
|
importBudgetExcel,
|
||||||
|
|
@ -100,7 +104,12 @@ async function rpcDocumentForDownload(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
route: string,
|
route: string,
|
||||||
): Promise<{ doc?: StorageDoc; envelope: RpcEnvelope }> {
|
): 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 };
|
if (!envelope.ok) return { envelope };
|
||||||
const doc = envelope.data?.document;
|
const doc = envelope.data?.document;
|
||||||
if (!doc?.storage_name) {
|
if (!doc?.storage_name) {
|
||||||
|
|
@ -119,6 +128,17 @@ async function rpcDocumentForDownload(
|
||||||
return { doc, envelope };
|
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(
|
async function scopedCompany(
|
||||||
db: Db,
|
db: Db,
|
||||||
id: number,
|
id: number,
|
||||||
|
|
@ -447,7 +467,7 @@ app.get("/v1/companies/:id/documents/:docId", ...requireCoreAuth, requirePermiss
|
||||||
const { doc, envelope } = await rpcDocumentForDownload(
|
const { doc, envelope } = await rpcDocumentForDownload(
|
||||||
db,
|
db,
|
||||||
"core.fn_company_document_get",
|
"core.fn_company_document_get",
|
||||||
{ company_id: companyId, id: docId },
|
{ company_id: companyId, doc_id: docId },
|
||||||
route,
|
route,
|
||||||
);
|
);
|
||||||
if (!doc) return respondRpc(c, envelope);
|
if (!doc) return respondRpc(c, envelope);
|
||||||
|
|
@ -689,7 +709,7 @@ app.get("/v1/projects/:id/documents/:docId", ...requireCoreAuth, requirePermissi
|
||||||
const { doc, envelope } = await rpcDocumentForDownload(
|
const { doc, envelope } = await rpcDocumentForDownload(
|
||||||
db,
|
db,
|
||||||
"core.fn_project_document_get",
|
"core.fn_project_document_get",
|
||||||
{ project_id: projectId, id: docId },
|
{ project_id: projectId, doc_id: docId },
|
||||||
route,
|
route,
|
||||||
);
|
);
|
||||||
if (!doc) return respondRpc(c, envelope);
|
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) => {
|
app.post("/v1/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
|
||||||
const form = await c.req.formData();
|
const upload = await readExcelUpload(c);
|
||||||
const file = form.get("file");
|
if ("error" in upload) {
|
||||||
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c) });
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
}
|
||||||
const preview = previewBudgetExcel(bytes);
|
const preview = previewBudgetExcel(upload.bytes);
|
||||||
if (preview.errors.length && !preview.items.length) return c.json(preview, 400);
|
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);
|
return c.json(preview);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
|
app.post("/v1/projects/:id/budget/preview", ...requireCoreAuth, requirePermission("budget.create"), async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
if (!await denyUnlessProjectScope(c, id)) return;
|
if (!await denyUnlessProjectScope(c, id)) return;
|
||||||
const form = await c.req.formData();
|
const upload = await readExcelUpload(c);
|
||||||
const file = form.get("file");
|
if ("error" in upload) {
|
||||||
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c), project_id: id });
|
||||||
|
}
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
||||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
const preview = previewBudgetExcel(upload.bytes);
|
||||||
const preview = previewBudgetExcel(bytes);
|
if (preview.errors.length && !preview.items.length) {
|
||||||
if (preview.errors.length && !preview.items.length) return c.json(preview, 400);
|
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);
|
return c.json(preview);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
|
app.post("/v1/projects/:id/budget/import", ...requireCoreAuth, requirePermission("budget.update"), async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
if (!await denyUnlessProjectScope(c, id)) return;
|
if (!await denyUnlessProjectScope(c, id)) return;
|
||||||
const form = await c.req.formData();
|
const upload = await readExcelUpload(c);
|
||||||
const file = form.get("file");
|
if ("error" in upload) {
|
||||||
if (!(file instanceof File)) return c.json({ error: "Archivo Excel requerido" }, 400);
|
return respondApiError(c, "VALIDATION", upload.error, { route: routeLabel(c), project_id: id });
|
||||||
|
}
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
if (!await projectById(db, id)) return c.json({ error: "Proyecto no encontrado" }, 404);
|
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, upload.bytes);
|
||||||
const report = await importBudgetExcel(db, id, bytes);
|
if (report.errors.length && !report.inserted) {
|
||||||
if (report.errors.length && !report.inserted) return c.json(report, 400);
|
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);
|
return c.json(report);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1174,11 +1213,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc
|
||||||
{},
|
{},
|
||||||
{ route },
|
{ route },
|
||||||
);
|
);
|
||||||
const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as {
|
const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type);
|
||||||
validity_mode: string;
|
|
||||||
requires_issued_at: boolean;
|
|
||||||
requires_expires_at: boolean;
|
|
||||||
} | undefined;
|
|
||||||
if (!policy) {
|
if (!policy) {
|
||||||
return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, {
|
return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, {
|
||||||
route,
|
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 imssCompanyId = Number(form.get("imss_company_id") || 0) || null;
|
||||||
const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null;
|
const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null;
|
||||||
const imssBajaAt = String(form.get("imss_baja_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) {
|
if (type === "alta_imss" && !imssCompanyId) {
|
||||||
return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id });
|
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(
|
const { doc, envelope } = await rpcDocumentForDownload(
|
||||||
db,
|
db,
|
||||||
"core.fn_worker_document_get",
|
"core.fn_worker_document_get",
|
||||||
{ worker_id: workerId, id: docId },
|
{ worker_id: workerId, doc_id: docId },
|
||||||
route,
|
route,
|
||||||
);
|
);
|
||||||
if (!doc) return respondRpc(c, envelope);
|
if (!doc) return respondRpc(c, envelope);
|
||||||
|
|
@ -1355,8 +1383,9 @@ app.get("/v1/badge-jobs/:id/pdf", ...requireCoreAuth, requirePermission("documen
|
||||||
{ job_id: id },
|
{ job_id: id },
|
||||||
{ route },
|
{ route },
|
||||||
);
|
);
|
||||||
if (!jobEnv.ok) return respondRpc(c, jobEnv);
|
const pdfPath = jobEnv.ok
|
||||||
const pdfPath = jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id);
|
? (jobEnv.data?.job?.pdf_path || badgeJobPdfKey(id))
|
||||||
|
: badgeJobPdfKey(id);
|
||||||
const bytes = await getObject(pdfPath);
|
const bytes = await getObject(pdfPath);
|
||||||
c.header("Content-Type", "application/pdf");
|
c.header("Content-Type", "application/pdf");
|
||||||
c.header("Content-Disposition", `attachment; filename="gafetes-${id}.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);
|
registerPayrollRoutes(app);
|
||||||
registerExpenseRoutes(app);
|
registerExpenseRoutes(app);
|
||||||
|
registerExpenseCatalogRoutes(app);
|
||||||
registerWarehouseRoutes(app);
|
registerWarehouseRoutes(app);
|
||||||
registerCostControlRoutes(app);
|
registerCostControlRoutes(app);
|
||||||
registerWorkProgramRoutes(app);
|
registerWorkProgramRoutes(app);
|
||||||
|
|
|
||||||
|
|
@ -217,14 +217,109 @@ export async function patchDestajoCut(db: Db, weekId: number, cutId: number, pat
|
||||||
if (!env.ok) throwFromRpc(env);
|
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) {
|
export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) {
|
||||||
await ensureWeek(db, tenantId, weekStart);
|
await ensureWeek(db, tenantId, weekStart);
|
||||||
const env = await callCoreFn<Record<string, unknown>>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: 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);
|
if (!env.ok) throwFromRpc(env);
|
||||||
const data = env.data ?? {};
|
const data = env.data ?? {};
|
||||||
const today = await resolveToday(db, tenantId);
|
const today = await resolveToday(db, tenantId);
|
||||||
const start = String((data.week as Record<string, unknown>)?.week_start ?? weekStart);
|
const week = (data.week ?? null) as Record<string, unknown> | null;
|
||||||
return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) };
|
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> {
|
export async function weekCsv(db: Db, weekId: number): Promise<string> {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
thisSaturday,
|
thisSaturday,
|
||||||
weekAssembleDate,
|
weekAssembleDate,
|
||||||
weekContaining,
|
weekContaining,
|
||||||
|
weekDays,
|
||||||
} from "./payroll.ts";
|
} from "./payroll.ts";
|
||||||
|
|
||||||
// tenant_id fijo y solo-de-tests: cada test corre en su propia transacción
|
// 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");
|
const w = weekContaining("2026-08-26");
|
||||||
assertEquals(w.weekStart, "2026-08-24");
|
assertEquals(w.weekStart, "2026-08-24");
|
||||||
assertEquals(w.weekEnd, "2026-08-29");
|
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");
|
const d = destajoPeriodBounds("2026-08-24");
|
||||||
assertEquals(d.periodEnd, "2026-08-27");
|
assertEquals(d.periodEnd, "2026-08-27");
|
||||||
assertEquals(d.periodStart, "2026-08-20");
|
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 () => {
|
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", async () => {
|
||||||
await withTestDb(async (db) => {
|
await withTestDb(async (db) => {
|
||||||
const { companyId, projectId, workerId } = await seedFixture(db);
|
const { companyId, projectId, workerId } = await seedFixture(db);
|
||||||
|
|
|
||||||
62
api/pdf.ts
62
api/pdf.ts
|
|
@ -101,16 +101,34 @@ export async function generateBadgePdf(
|
||||||
workerIds: number[],
|
workerIds: number[],
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const project = await db.prepare(
|
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 {
|
).get(projectId) as {
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
theme_id: string;
|
theme_id: string;
|
||||||
logo_left_path: string | null;
|
logo_left_path: string | null;
|
||||||
logo_right_path: string | null;
|
logo_right_path: string | null;
|
||||||
|
tenant_id?: number | null;
|
||||||
|
company_name?: string | null;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
if (!project) throw new Error("Proyecto no encontrado");
|
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(
|
const workers = await db.prepare(
|
||||||
`SELECT w.id, w.first_name, w.middle_name, w.last_name_p, w.last_name_m,
|
`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,
|
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 photo = await embedMaybe(pdf, await loadCurrentPhoto(db, w.id));
|
||||||
const qrPng = await badgeQrPng(w.curp);
|
const qrPng = await badgeQrPng(w.curp);
|
||||||
const qrImg = await pdf.embedPng(qrPng);
|
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);
|
drawBack(page, font, fontBold, x, botY, w, qrImg, project.name, project.code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -164,6 +182,22 @@ export async function generateBadgePdf(
|
||||||
return await pdf.save();
|
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(
|
function drawFront(
|
||||||
page: PDFPage,
|
page: PDFPage,
|
||||||
font: PDFFont,
|
font: PDFFont,
|
||||||
|
|
@ -174,6 +208,7 @@ function drawFront(
|
||||||
photo: PDFImage | null,
|
photo: PDFImage | null,
|
||||||
logoL: PDFImage | null,
|
logoL: PDFImage | null,
|
||||||
logoR: PDFImage | null,
|
logoR: PDFImage | null,
|
||||||
|
companyName: string,
|
||||||
) {
|
) {
|
||||||
page.drawRectangle({
|
page.drawRectangle({
|
||||||
x,
|
x,
|
||||||
|
|
@ -193,12 +228,15 @@ function drawFront(
|
||||||
height: logoL.height * scale,
|
height: logoL.height * scale,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
page.drawText("Construcciones Arctec", {
|
drawFittedLeft(
|
||||||
x: x + 6,
|
page,
|
||||||
y: y + CARD_H - 22,
|
fontBold,
|
||||||
size: 7,
|
companyName.toUpperCase(),
|
||||||
font: fontBold,
|
x + 6,
|
||||||
});
|
y + CARD_H - 22,
|
||||||
|
CARD_W / 2 - 10,
|
||||||
|
7,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (logoR) {
|
if (logoR) {
|
||||||
const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height);
|
const scale = Math.min((CARD_W / 2 - 8) / logoR.width, (logoH - 4) / logoR.height);
|
||||||
|
|
@ -208,14 +246,6 @@ function drawFront(
|
||||||
width: logoR.width * scale,
|
width: logoR.width * scale,
|
||||||
height: logoR.height * 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;
|
const photoW = 3.5 * CM;
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,15 @@ export function registerWarehouseRoutes(app: App) {
|
||||||
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
|
app.post("/v1/projects/:id/warehouse/close", ...requireCoreAuth, requirePermission("warehouse.update"), async (c) => {
|
||||||
const projectId = Number(c.req.param("id"));
|
const projectId = Number(c.req.param("id"));
|
||||||
if (!await denyUnlessProjectScope(c, projectId)) return;
|
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 db = c.get("db");
|
||||||
const a = actor(c);
|
const a = actor(c);
|
||||||
const env = await callCoreFn(db, "core.fn_warehouse_close_project", {
|
const env = await callCoreFn(db, "core.fn_warehouse_close_project", {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
force: body.force,
|
force: body.force,
|
||||||
|
transfer_remaining: body.transfer_remaining,
|
||||||
closed_by_name: a.name,
|
closed_by_name: a.name,
|
||||||
close_notes: body.close_notes,
|
close_notes: body.close_notes,
|
||||||
}, { route: routeLabel(c) });
|
}, { route: routeLabel(c) });
|
||||||
|
|
@ -119,12 +122,32 @@ export function registerWarehouseRoutes(app: App) {
|
||||||
if (!await denyUnlessWarehouseCentral(c)) return;
|
if (!await denyUnlessWarehouseCentral(c)) return;
|
||||||
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
|
} else if (!await denyUnlessProjectScope(c, wh.project_id)) return;
|
||||||
const env = await callCoreFn(db, "core.fn_warehouse_movement_list", {
|
const env = await callCoreFn(db, "core.fn_warehouse_movement_list", {
|
||||||
|
tenant_id: tid(c),
|
||||||
warehouse_id: whId,
|
warehouse_id: whId,
|
||||||
material_id: c.req.query("material_id"),
|
material_id: c.req.query("material_id"),
|
||||||
}, { route: routeLabel(c) });
|
}, { route: routeLabel(c) });
|
||||||
return respondRpc(c, env);
|
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) => {
|
app.post("/v1/warehouse-materials", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
|
||||||
const body = await c.req.json<Record<string, unknown>>();
|
const body = await c.req.json<Record<string, unknown>>();
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
|
|
@ -132,6 +155,17 @@ export function registerWarehouseRoutes(app: App) {
|
||||||
return respondRpc(c, env);
|
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) => {
|
app.post("/v1/warehouses/:id/entries", ...requireCoreAuth, requirePermission("warehouse.create"), async (c) => {
|
||||||
const whId = Number(c.req.param("id"));
|
const whId = Number(c.req.param("id"));
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { assertEquals, assertGreater } from "jsr:@std/assert@1";
|
import { assertEquals, assertGreater } from "jsr:@std/assert@1";
|
||||||
import { readFileSync } from "node:fs";
|
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", () => {
|
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");
|
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);
|
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";
|
Deno.test("detecta programa por partida con columna vacía antes de Sem (Neodata)", () => {
|
||||||
const partidaPath = "/home/ubuntu/.cursor/projects/workspace/uploads/Programa_de_obra_por_Partida_a_Est_ndar__E__4-9-2026_Hr18Mn43_18ff.xlsx";
|
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", () => {
|
Deno.test("lee Excel real por concepto del usuario", () => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import type { AuthUser } from "./auth.ts";
|
||||||
import { requireCoreAuth } from "./scope.ts";
|
import { requireCoreAuth } from "./scope.ts";
|
||||||
import type { Db } from "./db.ts";
|
import type { Db } from "./db.ts";
|
||||||
import { projectById } 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 { denyUnlessProjectScope } from "./scope_enforcement.ts";
|
||||||
|
import { respondApiError, respondRpc, routeLabel } from "./http_errors.ts";
|
||||||
import {
|
import {
|
||||||
createEmptyWorkProgram,
|
createEmptyWorkProgram,
|
||||||
exportWorkProgram,
|
exportWorkProgram,
|
||||||
|
|
@ -29,11 +30,38 @@ async function ensureProject(c: Context, id: number): Promise<boolean> {
|
||||||
return await denyUnlessProjectScope(c, id);
|
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 form = await c.req.formData();
|
||||||
const file = form.get("file");
|
const file = form.get("file");
|
||||||
if (!(file instanceof File)) throw new Error("Archivo Excel requerido (campo file)");
|
if (!(file instanceof File)) {
|
||||||
return new Uint8Array(await file.arrayBuffer());
|
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) {
|
export function registerWorkProgramRoutes(app: App) {
|
||||||
|
|
@ -42,7 +70,7 @@ export function registerWorkProgramRoutes(app: App) {
|
||||||
if (!await ensureProject(c, id)) return;
|
if (!await ensureProject(c, id)) return;
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const data = await getWorkProgram(db, id);
|
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) => {
|
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;
|
if (!await ensureProject(c, id)) return;
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const data = await listConcepts(db, id);
|
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) => {
|
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;
|
if (!await ensureProject(c, id)) return;
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const data = await listPartidas(db, id);
|
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) => {
|
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;
|
if (!await ensureProject(c, id)) return;
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const data = await getVsCost(db, id);
|
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) => {
|
app.post("/v1/projects/:id/work-program/preview", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
if (!await ensureProject(c, id)) return;
|
if (!await ensureProject(c, id)) return;
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const bytes = await readUpload(c);
|
const upload = await readUpload(c);
|
||||||
const preview = await previewWorkProgramExcel(bytes, id, db);
|
if ("response" in upload) return upload.response;
|
||||||
return c.json({ ok: true, data: preview });
|
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) => {
|
app.post("/v1/projects/:id/work-program/import", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
|
||||||
const id = Number(c.req.param("id"));
|
const id = Number(c.req.param("id"));
|
||||||
if (!await ensureProject(c, id)) return;
|
if (!await ensureProject(c, id)) return;
|
||||||
const db = c.get("db");
|
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 createChapters = c.req.query("create_chapters") !== "false";
|
||||||
const data = await importWorkProgramExcel(db, id, bytes, { createChapters });
|
const data = await importWorkProgramExcel(db, id, upload.bytes, { createChapters });
|
||||||
return c.json({ ok: true, data });
|
return respondOk(c, data, "Programa de obra importado");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/v1/projects/:id/work-program", ...requireCoreAuth, requirePermission("work_program.create"), async (c) => {
|
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;
|
if (!await ensureProject(c, id)) return;
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const data = await createEmptyWorkProgram(db, id);
|
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) => {
|
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 db = c.get("db");
|
||||||
const body = await c.req.json().catch(() => ({})) as { strategy?: string };
|
const body = await c.req.json().catch(() => ({})) as { strategy?: string };
|
||||||
const data = await generateWorkProgram(db, id, body.strategy || "keyword");
|
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) => {
|
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;
|
if (!await ensureProject(c, id)) return;
|
||||||
const db = c.get("db");
|
const db = c.get("db");
|
||||||
const env = await recalculatePartidas(db, id);
|
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) => {
|
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.start_pct ?? 0),
|
||||||
Number(body.end_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) => {
|
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(c.req.param("periodId")),
|
||||||
Number(body.amount ?? 0),
|
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) => {
|
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(c.req.param("periodId")),
|
||||||
Number(body.pct_complete ?? 0),
|
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) => {
|
app.get("/v1/projects/:id/work-program/export", ...viewAuth, async (c) => {
|
||||||
|
|
|
||||||
|
|
@ -38,5 +38,12 @@
|
||||||
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
|
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
|
||||||
<include file="changesets/030-rpc-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/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>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
98
db/core/changesets/032-document-validity-from-upload.sql
Normal file
98
db/core/changesets/032-document-validity-from-upload.sql
Normal 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;
|
||||||
123
db/core/changesets/032-fix-work-program-jsonb-object-length.sql
Normal file
123
db/core/changesets/032-fix-work-program-jsonb-object-length.sql
Normal 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;
|
||||||
|
$$;
|
||||||
80
db/core/changesets/032-fn-badge-job-get.sql
Normal file
80
db/core/changesets/032-fn-badge-job-get.sql
Normal 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;
|
||||||
99
db/core/changesets/032-rpc-document-get-id.sql
Normal file
99
db/core/changesets/032-rpc-document-get-id.sql
Normal 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;
|
||||||
|
$$;
|
||||||
397
db/core/changesets/033-catalog-suppliers-units.sql
Normal file
397
db/core/changesets/033-catalog-suppliers-units.sql
Normal 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;
|
||||||
111
db/core/changesets/034-fn-expense-update-reclassify.sql
Normal file
111
db/core/changesets/034-fn-expense-update-reclassify.sql
Normal 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;
|
||||||
464
db/core/changesets/035-warehouse-materials-catalog.sql
Normal file
464
db/core/changesets/035-warehouse-materials-catalog.sql
Normal 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;
|
||||||
|
|
@ -1844,7 +1844,8 @@ html.dark .p-tieredmenu,
|
||||||
html.dark .p-popover,
|
html.dark .p-popover,
|
||||||
html.dark .p-datepicker,
|
html.dark .p-datepicker,
|
||||||
html.dark .p-select-overlay,
|
html.dark .p-select-overlay,
|
||||||
html.dark .p-multiselect-overlay {
|
html.dark .p-multiselect-overlay,
|
||||||
|
html.dark .p-autocomplete-overlay {
|
||||||
--p-content-background: #111b2e;
|
--p-content-background: #111b2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2165,7 +2166,8 @@ html.dark .p-select-label.p-placeholder {
|
||||||
|
|
||||||
html.dark .p-select-overlay,
|
html.dark .p-select-overlay,
|
||||||
html.dark .p-datepicker-panel,
|
html.dark .p-datepicker-panel,
|
||||||
html.dark .p-multiselect-overlay {
|
html.dark .p-multiselect-overlay,
|
||||||
|
html.dark .p-autocomplete-overlay {
|
||||||
background: #111b2e !important;
|
background: #111b2e !important;
|
||||||
border-color: #243044 !important;
|
border-color: #243044 !important;
|
||||||
color: #e2e8f0 !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 { background: #ffebee !important; color: #b42318 !important; }
|
||||||
.cost-critical-text { color: #b42318; font-weight: 600; }
|
.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. */
|
/* Grouped menubar: single row, icons, card-style submenus. */
|
||||||
.menubar.p-menubar {
|
.menubar.p-menubar {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<article class="badge" :class="{ 'badge-back-rot': side === 'back' && rotated, fluid }">
|
<article class="badge" :class="{ 'badge-back-rot': side === 'back' && rotated, fluid }">
|
||||||
<template v-if="side === 'front'">
|
<template v-if="side === 'front'">
|
||||||
<div class="badge-logos">
|
<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 class="logo-r">{{ projectName }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge-photo">
|
<div class="badge-photo">
|
||||||
|
|
@ -47,6 +47,7 @@ const props = defineProps<{
|
||||||
blood: string;
|
blood: string;
|
||||||
riskColor: string;
|
riskColor: string;
|
||||||
riskText: string;
|
riskText: string;
|
||||||
|
companyName?: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
projectCode: string;
|
projectCode: string;
|
||||||
photoSrc?: string;
|
photoSrc?: string;
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,17 @@
|
||||||
:content-style="{ overflow: 'auto', maxHeight: 'calc(100vh - 9rem)' }"
|
:content-style="{ overflow: 'auto', maxHeight: 'calc(100vh - 9rem)' }"
|
||||||
>
|
>
|
||||||
<Message severity="info" :closable="false">
|
<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>.
|
nada se guarda hasta que confirme. Reemplazará el presupuesto de <strong>{{ projectName }}</strong>.
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<div class="flex flex-wrap align-items-center gap-2 mt-3">
|
<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" />
|
<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" />
|
<Button label="Seleccionar Excel" icon="pi pi-plus" :disabled="busy" @click="pickFile" />
|
||||||
<span v-if="pending" class="muted">{{ pending.name }}</span>
|
<span v-if="pending" class="muted">{{ pending.name }}</span>
|
||||||
</div>
|
</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" />
|
<ProgressBar v-if="busy" mode="indeterminate" class="mt-3" style="height: 4px" />
|
||||||
|
|
||||||
<template v-if="preview">
|
<template v-if="preview">
|
||||||
|
|
@ -82,6 +83,8 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { TreeNode } from "primevue/treenode";
|
import type { TreeNode } from "primevue/treenode";
|
||||||
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
import { EXCEL_ACCEPT, excelOnlyMessage, isExcelSpreadsheet } from "~/utils/excelFile";
|
||||||
|
|
||||||
type PreviewNode = {
|
type PreviewNode = {
|
||||||
wbs: string;
|
wbs: string;
|
||||||
|
|
@ -172,10 +175,21 @@ function onNativeFile(event: Event) {
|
||||||
if (file) readFile(file);
|
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) {
|
async function readFile(file: File) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
if (!props.projectId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
reading.value = true;
|
reading.value = true;
|
||||||
|
|
@ -187,13 +201,18 @@ async function readFile(file: File) {
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/budget/preview`, { method: "POST", body });
|
preview.value = await api<Preview>(`/v1/projects/${props.projectId}/budget/preview`, { method: "POST", body });
|
||||||
if (preview.value.errors?.length && !preview.value.totals.item_count) {
|
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) {
|
} catch (err) {
|
||||||
pending.value = null;
|
pending.value = null;
|
||||||
const detail = err as { data?: { errors?: { messages: string[] }[] } };
|
const detail = err as { data?: unknown };
|
||||||
error.value = detail.data?.errors?.[0]?.messages?.[0] || (err instanceof Error ? err.message : "No se pudo leer el Excel");
|
showReadError(
|
||||||
toast.add({ severity: "error", summary: error.value, life: 3500 });
|
displayErrorMessage(detail.data ?? err, err instanceof Error ? err.message : "No se pudo leer el Excel"),
|
||||||
|
"Formato de Excel no válido",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
reading.value = false;
|
reading.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -217,8 +236,9 @@ async function confirmSave() {
|
||||||
emit("imported");
|
emit("imported");
|
||||||
visibleModel.value = false;
|
visibleModel.value = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "No se pudo guardar";
|
const detail = err as { data?: unknown };
|
||||||
toast.add({ severity: "error", summary: error.value, life: 3500 });
|
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 {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<AutoComplete
|
<Select
|
||||||
v-model="selected"
|
:model-value="modelValue ?? null"
|
||||||
:suggestions="filtered"
|
:options="items"
|
||||||
option-label="label"
|
option-label="label"
|
||||||
|
option-value="id"
|
||||||
placeholder="Buscar concepto del presupuesto..."
|
placeholder="Buscar concepto del presupuesto..."
|
||||||
class="w-full"
|
filter
|
||||||
force-selection
|
filter-placeholder="Clave o descripción"
|
||||||
@complete="onSearch"
|
show-clear
|
||||||
@item-select="onSelect"
|
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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -29,8 +50,8 @@ type BudgetOption = {
|
||||||
|
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const items = ref<BudgetOption[]>([]);
|
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() {
|
async function load() {
|
||||||
if (!props.projectId) {
|
if (!props.projectId) {
|
||||||
|
|
@ -49,25 +70,13 @@ async function load() {
|
||||||
amount: Number(row.amount ?? 0),
|
amount: Number(row.amount ?? 0),
|
||||||
label: [row.code, row.description].filter(Boolean).join(" — "),
|
label: [row.code, row.description].filter(Boolean).join(" — "),
|
||||||
}));
|
}));
|
||||||
filtered.value = items.value.slice(0, 30);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSearch(event: { query: string }) {
|
function onUpdate(id: number | null) {
|
||||||
const q = event.query.toLowerCase();
|
const option = id == null ? null : items.value.find((i) => i.id === id) ?? null;
|
||||||
filtered.value = items.value.filter((i) => i.label.toLowerCase().includes(q)).slice(0, 30);
|
emit("update:modelValue", option?.id ?? null);
|
||||||
}
|
emit("select", option);
|
||||||
|
|
||||||
function onSelect(event: { value: BudgetOption }) {
|
|
||||||
emit("update:modelValue", event.value?.id ?? null);
|
|
||||||
emit("select", event.value ?? null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.projectId, load, { immediate: true });
|
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>
|
</script>
|
||||||
|
|
|
||||||
269
web-panel/components/MaterialCatalogDialog.vue
Normal file
269
web-panel/components/MaterialCatalogDialog.vue
Normal 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>
|
||||||
129
web-panel/components/SupplierFormDialog.vue
Normal file
129
web-panel/components/SupplierFormDialog.vue
Normal 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>
|
||||||
|
|
@ -20,28 +20,67 @@
|
||||||
|
|
||||||
<template v-if="preview">
|
<template v-if="preview">
|
||||||
<div class="import-summary mt-3">
|
<div class="import-summary mt-3">
|
||||||
<div><strong>{{ preview.format_label }}</strong><span>Formato</span></div>
|
<div><strong>{{ preview.format_label || "—" }}</strong><span>Formato</span></div>
|
||||||
<div><strong>{{ preview.period_count }}</strong><span>Semanas</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 === '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 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><strong>{{ preview.unmatched_codes?.length || 0 }}</strong><span>Sin match</span></div>
|
||||||
</div>
|
</div>
|
||||||
<Message v-for="(w, i) in preview.warnings" :key="i" severity="warn" class="mt-2" :closable="false">{{ w }}</Message>
|
<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" severity="warn" class="mt-2" :closable="false">
|
<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(", ") }}
|
Códigos no encontrados en presupuesto: {{ preview.unmatched_codes.join(", ") }}
|
||||||
</Message>
|
</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>
|
</template>
|
||||||
|
|
||||||
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
|
<Message v-if="error" severity="error" class="mt-3" :closable="false">{{ error }}</Message>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cerrar" text @click="visibleModel = false" />
|
<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>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 props = defineProps<{ visible: boolean; projectId: number; projectName: string }>();
|
||||||
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
|
const emit = defineEmits<{ "update:visible": [boolean]; imported: [] }>();
|
||||||
|
|
||||||
|
|
@ -56,36 +95,48 @@ const visibleModel = computed({
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const fileName = ref("");
|
const fileName = ref("");
|
||||||
const pending = ref<File | null>(null);
|
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 busy = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const error = ref("");
|
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() {
|
function pickFile() {
|
||||||
fileInput.value?.click();
|
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) {
|
async function onFile(e: Event) {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (!file || !props.projectId) return;
|
if (!file || !props.projectId) return;
|
||||||
pending.value = file;
|
pending.value = file;
|
||||||
fileName.value = file.name;
|
fileName.value = file.name;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
|
preview.value = null;
|
||||||
busy.value = true;
|
busy.value = true;
|
||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", file);
|
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) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "No se pudo leer el Excel";
|
error.value = err instanceof Error ? err.message : "No se pudo leer el Excel";
|
||||||
preview.value = null;
|
preview.value = null;
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false;
|
busy.value = false;
|
||||||
|
(e.target as HTMLInputElement).value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (!pending.value || !props.projectId) return;
|
if (!pending.value || !props.projectId || !canImport.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<Message v-if="freshnessRequired" severity="warn" :closable="false" class="mb-3">
|
<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>
|
||||||
<Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
|
<Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
|
||||||
Expediente listo para alta IMSS.
|
Expediente listo para alta IMSS.
|
||||||
|
|
@ -60,12 +60,6 @@
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</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
|
<FileUpload
|
||||||
mode="basic"
|
mode="basic"
|
||||||
:auto="true"
|
:auto="true"
|
||||||
|
|
@ -73,7 +67,7 @@
|
||||||
accept=".jpg,.jpeg,.png,.pdf,.webp"
|
accept=".jpg,.jpeg,.png,.pdf,.webp"
|
||||||
custom-upload
|
custom-upload
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
:disabled="uploading || !canUpload"
|
:disabled="uploading"
|
||||||
@uploader="uploadDocument"
|
@uploader="uploadDocument"
|
||||||
/>
|
/>
|
||||||
<div class="muted mt-2">{{ uploadHint }}</div>
|
<div class="muted mt-2">{{ uploadHint }}</div>
|
||||||
|
|
@ -173,8 +167,6 @@ const documentType = ref(
|
||||||
);
|
);
|
||||||
const uploading = ref(false);
|
const uploading = ref(false);
|
||||||
const docsTab = ref("current");
|
const docsTab = ref("current");
|
||||||
const issuedAt = ref<Date | null>(null);
|
|
||||||
const expiresAt = ref<Date | null>(null);
|
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
const previewPath = ref("");
|
const previewPath = ref("");
|
||||||
const previewName = ref("");
|
const previewName = ref("");
|
||||||
|
|
@ -212,17 +204,11 @@ const freshnessRequired = computed(() => !!props.freshnessRequired);
|
||||||
const imssReady = computed(() => !!props.imssReady);
|
const imssReady = computed(() => !!props.imssReady);
|
||||||
const inProjectWithoutImss = computed(() => !!props.inProjectWithoutImss);
|
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(() => {
|
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?.validity_mode === "freshness") {
|
||||||
if (selectedPolicy.value?.requires_expires_at) return "Indica la vigencia impresa en la INE.";
|
return "Solo sube el archivo. Si hay alta o reingreso pendiente, la vigencia de 3 meses cuenta desde la fecha de carga.";
|
||||||
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.";
|
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) {
|
function typeLabel(code: string) {
|
||||||
|
|
@ -243,7 +229,7 @@ function validityLabel(status?: ValidityStatus | string) {
|
||||||
case "stale_for_alta":
|
case "stale_for_alta":
|
||||||
return "Desactualizado para alta";
|
return "Desactualizado para alta";
|
||||||
case "missing_dates":
|
case "missing_dates":
|
||||||
return "Sin fecha";
|
return "Por refrescar";
|
||||||
default:
|
default:
|
||||||
return status || "—";
|
return status || "—";
|
||||||
}
|
}
|
||||||
|
|
@ -268,29 +254,17 @@ function formatDate(value: string) {
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
|
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[] }) {
|
async function uploadDocument(event: { files: File[] }) {
|
||||||
const file = event.files?.[0];
|
const file = event.files?.[0];
|
||||||
if (!file || uploading.value || !canUpload.value) return;
|
if (!file || uploading.value) return;
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
try {
|
try {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("type", documentType.value);
|
body.append("type", documentType.value);
|
||||||
body.append("file", file);
|
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 });
|
await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body });
|
||||||
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
|
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
|
||||||
docsTab.value = "current";
|
docsTab.value = "current";
|
||||||
issuedAt.value = null;
|
|
||||||
expiresAt.value = null;
|
|
||||||
emit("changed");
|
emit("changed");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.add({
|
toast.add({
|
||||||
|
|
@ -312,8 +286,4 @@ function openDocument(row: DocumentRow) {
|
||||||
watch(() => props.initialType, (value) => {
|
watch(() => props.initialType, (value) => {
|
||||||
if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;
|
if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;
|
||||||
});
|
});
|
||||||
watch(documentType, () => {
|
|
||||||
issuedAt.value = null;
|
|
||||||
expiresAt.value = null;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -47,33 +47,65 @@ export function isApiEnvelope(value: unknown): value is {
|
||||||
&& "ok" in value && "message" in value && "layer" in value;
|
&& "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 {
|
export function unwrapApiPayload<T>(body: unknown): T {
|
||||||
if (!isApiEnvelope(body)) return body as T;
|
if (!body || typeof body !== "object") return body as T;
|
||||||
const env = body;
|
const rec = body as Record<string, unknown>;
|
||||||
const data = env.data;
|
const data = rec.data;
|
||||||
const spread = data && typeof data === "object" && !Array.isArray(data)
|
const canSpread = "ok" in rec
|
||||||
? data as Record<string, unknown>
|
&& 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 {
|
return {
|
||||||
...spread,
|
...spread,
|
||||||
data,
|
data,
|
||||||
ok: env.ok,
|
ok: rec.ok,
|
||||||
code: env.code,
|
code: rec.code,
|
||||||
message: env.message,
|
message: rec.message,
|
||||||
status: env.status,
|
status: rec.status,
|
||||||
layer: env.layer,
|
layer: rec.layer,
|
||||||
} as T;
|
} 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. */
|
/** Muestra el mensaje detallado de la API o front sin reescribirlo. */
|
||||||
export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
|
export function displayErrorMessage(value: unknown, fallback = "Ocurrió un error"): string {
|
||||||
if (isApiEnvelope(value)) return value.message;
|
if (isApiEnvelope(value)) {
|
||||||
if (value && typeof value === "object" && "message" in value) {
|
const listed = firstListedError(value);
|
||||||
return String((value as { message: unknown }).message);
|
const message = value.message?.trim();
|
||||||
|
if (message) return message;
|
||||||
|
if (listed) return listed;
|
||||||
}
|
}
|
||||||
if (value && typeof value === "object" && "error" in value) {
|
if (value && typeof value === "object") {
|
||||||
return String((value as { error: unknown }).error);
|
if ("message" in value) {
|
||||||
|
const message = String((value as { message: unknown }).message || "").trim();
|
||||||
|
if (message) return message;
|
||||||
|
}
|
||||||
|
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;
|
return fallback || "Ocurrió un error";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
web-panel/composables/expenseAmounts.ts
Normal file
21
web-panel/composables/expenseAmounts.ts
Normal 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) };
|
||||||
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ export function useDesktop() {
|
||||||
}) {
|
}) {
|
||||||
tabLabel.value = opts.tab;
|
tabLabel.value = opts.tab;
|
||||||
if (opts.treeTitle != null) treeTitle.value = opts.treeTitle;
|
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.tree) tree.value = opts.tree;
|
||||||
if (opts.treeKey != null) treeKey.value = opts.treeKey;
|
if (opts.treeKey != null) treeKey.value = opts.treeKey;
|
||||||
if (opts.showTree != null) showTree.value = opts.showTree;
|
if (opts.showTree != null) showTree.value = opts.showTree;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,55 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="almacen-page">
|
<div class="almacen-page">
|
||||||
<DeskTableBar v-model="tableQ" placeholder="Buscar material o obra">
|
<DeskTableBar v-model="tableQ" :placeholder="isGlobalView ? 'Buscar material o almacén' : 'Buscar material'">
|
||||||
<Chip label="Almacén global" icon="pi pi-box" />
|
<Chip :label="currentLabel" icon="pi pi-box" />
|
||||||
<Chip :label="`${stockRows.length} líneas`" icon="pi pi-list" />
|
<Chip :label="`${visibleStock.length} líneas`" icon="pi pi-list" />
|
||||||
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
|
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
|
||||||
</DeskTableBar>
|
</DeskTableBar>
|
||||||
|
|
||||||
<TabView>
|
<TabView>
|
||||||
<TabPanel header="Stock global">
|
<TabPanel :header="isGlobalView ? 'Consolidado' : 'Stock'">
|
||||||
<DataTable :value="filteredStock" data-key="material_id" paginator :rows="25" scrollable scroll-height="flex">
|
<DataTable
|
||||||
<Column field="project_code" header="Obra / Central" />
|
:value="visibleStock"
|
||||||
<Column field="material_code" header="Clave" style="width: 8rem" />
|
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="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="qty_on_hand" header="Cantidad" style="width: 8rem" />
|
||||||
<Column field="stock_value" header="Valor" style="width: 9rem">
|
<Column field="stock_value" header="Valor" style="width: 9rem">
|
||||||
<template #body="{ data }">{{ money(data.stock_value) }}</template>
|
<template #body="{ data }">{{ money(data.stock_value) }}</template>
|
||||||
|
|
@ -19,9 +57,12 @@
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel header="Kardex">
|
<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_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="material_name" header="Material" />
|
||||||
<Column field="quantity" header="Cant." style="width: 7rem" />
|
<Column field="quantity" header="Cant." style="width: 7rem" />
|
||||||
<Column field="total_cost" header="Importe" style="width: 9rem">
|
<Column field="total_cost" header="Importe" style="width: 9rem">
|
||||||
|
|
@ -32,23 +73,82 @@
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabView>
|
</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">
|
<div class="form-grid form-grid-compact">
|
||||||
<FormField label="Almacén">
|
<FormField label="Almacén destino" required>
|
||||||
<Select v-model="entryForm.warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
<Select v-model="entryForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Código material">
|
<FormField label="Fecha">
|
||||||
<InputText v-model="entryForm.material_code" class="w-full identifier-input" />
|
<DatePicker v-model="entryDate" date-format="dd/mm/yy" show-icon class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Nombre material">
|
<FormField label="Material" required class="span-2">
|
||||||
<InputText v-model="entryForm.material_name" class="w-full" />
|
<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>
|
||||||
<FormField label="Cantidad">
|
<FormField label="Unidad">
|
||||||
<InputNumber v-model="entryForm.quantity" :min="0" class="w-full" />
|
<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>
|
||||||
<FormField label="Costo unitario">
|
<FormField label="Costo unitario">
|
||||||
<InputNumber v-model="entryForm.unit_cost" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
<InputNumber v-model="entryForm.unit_cost" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField v-if="selectedEntrySuppliers" label="Proveedores del material" class="span-2">
|
||||||
|
<small class="muted">{{ selectedEntrySuppliers }}</small>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancelar" text @click="showEntry = false" />
|
<Button label="Cancelar" text @click="showEntry = false" />
|
||||||
|
|
@ -56,19 +156,27 @@
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</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">
|
<div class="form-grid form-grid-compact">
|
||||||
<FormField label="Almacén obra">
|
<FormField label="Almacén" required>
|
||||||
<Select v-model="exitForm.warehouse_id" :options="projectWarehouses" option-label="label" option-value="id" class="w-full" />
|
<Select v-model="exitForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Material">
|
<FormField label="Material" required>
|
||||||
<Select v-model="exitForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
|
<Select
|
||||||
|
v-model="exitForm.material_id"
|
||||||
|
:options="stockMaterialOptions(exitForm.warehouse_id)"
|
||||||
|
option-label="label"
|
||||||
|
option-value="id"
|
||||||
|
filter
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Cantidad">
|
<FormField label="Cantidad" required>
|
||||||
<InputNumber v-model="exitForm.quantity" :min="0" class="w-full" />
|
<InputNumber v-model="exitForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Concepto presupuesto" class="span-2">
|
<FormField label="Concepto presupuesto" class="span-2">
|
||||||
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
|
<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>
|
||||||
<FormField label="Destino físico" class="span-2">
|
<FormField label="Destino físico" class="span-2">
|
||||||
<InputText v-model="exitForm.destination_note" class="w-full" />
|
<InputText v-model="exitForm.destination_note" class="w-full" />
|
||||||
|
|
@ -81,19 +189,29 @@
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</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">
|
<div class="form-grid form-grid-compact">
|
||||||
<FormField label="Desde">
|
<FormField label="Desde" required>
|
||||||
<Select v-model="transferForm.from_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
<Select v-model="transferForm.from_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Hacia">
|
<FormField label="Hacia" required>
|
||||||
<Select v-model="transferForm.to_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
|
<Select v-model="transferForm.to_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Material">
|
<FormField label="Material" required>
|
||||||
<Select v-model="transferForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
|
<Select
|
||||||
|
v-model="transferForm.material_id"
|
||||||
|
:options="stockMaterialOptions(transferForm.from_warehouse_id)"
|
||||||
|
option-label="label"
|
||||||
|
option-value="id"
|
||||||
|
filter
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Cantidad">
|
<FormField label="Cantidad" required>
|
||||||
<InputNumber v-model="transferForm.quantity" :min="0" class="w-full" />
|
<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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
@ -101,89 +219,347 @@
|
||||||
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
|
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<MaterialCatalogDialog v-model="showCatalog" @saved="onMaterialSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormField from "~/components/padron/FormField.vue";
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
||||||
|
import MaterialCatalogDialog, { type Material } from "~/components/MaterialCatalogDialog.vue";
|
||||||
import { displayErrorMessage } from "~/composables/api-response";
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
|
||||||
type WarehouseRow = { id: number; kind: string; project_id?: number; project_code?: string; label: string };
|
type WarehouseRow = {
|
||||||
type StockRow = Record<string, unknown>;
|
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 MovementRow = Record<string, unknown>;
|
||||||
|
type ProjectOption = { id: number; name: string; code?: string; status?: string; label: string };
|
||||||
|
|
||||||
const desk = useDesktop();
|
const desk = useDesktop();
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const perms = usePermissions();
|
||||||
|
|
||||||
const tableQ = ref("");
|
const tableQ = ref("");
|
||||||
const warehouses = ref<WarehouseRow[]>([]);
|
const warehouses = ref<WarehouseRow[]>([]);
|
||||||
|
const projects = ref<ProjectOption[]>([]);
|
||||||
|
const materials = ref<Material[]>([]);
|
||||||
const stockRows = ref<StockRow[]>([]);
|
const stockRows = ref<StockRow[]>([]);
|
||||||
|
const consolidated = ref<StockRow[]>([]);
|
||||||
const movements = ref<MovementRow[]>([]);
|
const movements = ref<MovementRow[]>([]);
|
||||||
const materialOptions = ref<{ id: number; label: string }[]>([]);
|
|
||||||
const showEntry = ref(false);
|
const showEntry = ref(false);
|
||||||
const showExit = ref(false);
|
const showExit = ref(false);
|
||||||
const showTransfer = ref(false);
|
const showTransfer = ref(false);
|
||||||
|
const showOpen = ref(false);
|
||||||
|
const showClose = ref(false);
|
||||||
|
const showCatalog = ref(false);
|
||||||
const exitWarning = ref("");
|
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 entryForm = reactive({
|
||||||
const exitForm = reactive({ warehouse_id: 0, material_id: 0, quantity: 0, budget_item_id: null as number | null, destination_note: "" });
|
warehouse_id: 0,
|
||||||
const transferForm = reactive({ from_warehouse_id: 0, to_warehouse_id: 0, material_id: 0, quantity: 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 canUpdate = computed(() => perms.can("warehouse.update"));
|
||||||
const exitProjectId = computed(() => projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0);
|
const canCreate = computed(() => perms.can("warehouse.create"));
|
||||||
const stockValue = computed(() => stockRows.value.reduce((s, r) => s + Number(r.stock_value || 0), 0));
|
const openWarehouses = computed(() => warehouses.value.filter((w) => w.status !== "closed"));
|
||||||
const filteredStock = computed(() => {
|
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();
|
const q = tableQ.value.toLowerCase();
|
||||||
return stockRows.value.filter((r) =>
|
return movements.value.filter((r) =>
|
||||||
!q || [r.material_name, r.material_code, r.project_code].join(" ").toLowerCase().includes(q),
|
!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) {
|
function money(v: number) {
|
||||||
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWarehouses() {
|
function movementLabel(type: unknown) {
|
||||||
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} });
|
switch (String(type || "")) {
|
||||||
const r = await api<{ data?: { warehouses?: Array<Record<string, unknown>> } }>("/v1/warehouses");
|
case "entry": return "Entrada";
|
||||||
const rows = (r.data?.warehouses ?? []) as Array<Record<string, unknown>>;
|
case "exit": return "Salida";
|
||||||
warehouses.value = rows.map((w) => ({
|
case "transfer_out": return "Traspaso salida";
|
||||||
id: Number(w.id),
|
case "transfer_in": return "Traspaso entrada";
|
||||||
kind: String(w.kind),
|
case "adjustment": return "Ajuste";
|
||||||
project_id: w.project_id ? Number(w.project_id) : undefined,
|
default: return String(type || "—");
|
||||||
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 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 entryDate = computed({
|
||||||
const r = await api<{ data?: { stock?: StockRow[] } }>("/v1/warehouses/stock/global");
|
get: () => {
|
||||||
stockRows.value = r.data?.stock ?? [];
|
const [y, m, d] = entryForm.movement_date.split("-").map(Number);
|
||||||
const mats = new Map<number, string>();
|
return y ? new Date(y, (m || 1) - 1, d || 1) : new Date();
|
||||||
for (const row of stockRows.value) {
|
},
|
||||||
mats.set(Number(row.material_id), `${row.material_code} — ${row.material_name}`);
|
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() {
|
async function loadMovements() {
|
||||||
const wh = warehouses.value[0]?.id;
|
if (isGlobalView.value) {
|
||||||
if (!wh) return;
|
const r = await api<{ movements?: MovementRow[] }>("/v1/warehouses/movements/global");
|
||||||
const r = await api<{ data?: { movements?: MovementRow[] } }>(`/v1/warehouses/${wh}/movements`);
|
movements.value = r.movements ?? [];
|
||||||
movements.value = r.data?.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() {
|
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 {
|
try {
|
||||||
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
|
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
|
||||||
method: "POST",
|
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 });
|
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
|
||||||
showEntry.value = false;
|
showEntry.value = false;
|
||||||
await loadStock();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||||
}
|
}
|
||||||
|
|
@ -191,14 +567,14 @@ async function saveEntry() {
|
||||||
|
|
||||||
async function saveExit() {
|
async function saveExit() {
|
||||||
try {
|
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`,
|
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
|
||||||
{ method: "POST", json: exitForm },
|
{ 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 });
|
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
|
||||||
if (!exitWarning.value) showExit.value = false;
|
if (!exitWarning.value) showExit.value = false;
|
||||||
await loadStock();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
||||||
}
|
}
|
||||||
|
|
@ -207,29 +583,138 @@ async function saveExit() {
|
||||||
async function saveTransfer() {
|
async function saveTransfer() {
|
||||||
try {
|
try {
|
||||||
const r = await api<{ message?: string }>("/v1/warehouse-transfers", { method: "POST", json: transferForm });
|
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;
|
showTransfer.value = false;
|
||||||
await loadStock();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
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({
|
desk.configure({
|
||||||
tab: "Almacén",
|
tab: "Almacén",
|
||||||
ribbon: [{
|
treeTitle: "Almacenes",
|
||||||
key: "wh",
|
showTree: true,
|
||||||
label: "Movimientos",
|
showDetail: false,
|
||||||
items: [
|
treeKey: desk.treeKey.value || "global",
|
||||||
{ key: "entry", label: "Entrada", icon: "pi pi-download", command: () => { showEntry.value = true; } },
|
countLabel: `${visibleStock.value.length} líneas`,
|
||||||
{ key: "exit", label: "Salida", icon: "pi pi-upload", command: () => { showExit.value = true; } },
|
tree: [
|
||||||
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", command: () => { showTransfer.value = true; } },
|
{ 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", 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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,16 @@
|
||||||
<Chip v-if="tab === 'empresas'" :label="`${listedCompanies.length} empresas`" icon="pi pi-briefcase" />
|
<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 === '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 === '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 === 'riesgos'" :label="`${listedRisks.length} riesgos`" icon="pi pi-exclamation-triangle" />
|
||||||
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
|
<Chip v-if="tab === 'temas'" :label="`${listedThemes.length} temas`" icon="pi pi-palette" />
|
||||||
</DeskTableBar>
|
</DeskTableBar>
|
||||||
<Tabs v-model:value="tab">
|
<Tabs v-model:value="tab">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="empresas">Empresas</Tab>
|
<Tab value="empresas">Empresas</Tab>
|
||||||
|
<Tab value="proveedores">Proveedores</Tab>
|
||||||
|
<Tab value="unidades">Unidades</Tab>
|
||||||
<Tab value="nomina">Nómina</Tab>
|
<Tab value="nomina">Nómina</Tab>
|
||||||
<Tab value="riesgos">Riesgos</Tab>
|
<Tab value="riesgos">Riesgos</Tab>
|
||||||
<Tab value="temas">Temas de gafete</Tab>
|
<Tab value="temas">Temas de gafete</Tab>
|
||||||
|
|
@ -74,6 +78,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mt-4">Unidades de destajo</h3>
|
<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">
|
<DataTable :value="units" class="mt-2" empty-message="Sin unidades">
|
||||||
<Column field="code" header="Código" />
|
<Column field="code" header="Código" />
|
||||||
<Column field="label" header="Nombre" />
|
<Column field="label" header="Nombre" />
|
||||||
|
|
@ -90,6 +95,51 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</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">
|
<TabPanel value="riesgos">
|
||||||
<DataTable :value="listedRisks" paginator :rows="15">
|
<DataTable :value="listedRisks" paginator :rows="15">
|
||||||
<Column field="code" header="Barra gafete" />
|
<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" />
|
<Button v-if="formTab === 'datos'" :label="editing ? 'Guardar' : 'Crear'" icon="pi pi-check" :loading="saving" @click="save" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<SupplierFormDialog v-model="showSupplierForm" :supplier="editingSupplier" @saved="onSupplierSaved" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormField from "~/components/padron/FormField.vue";
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
import CompanyDocuments from "~/components/CompanyDocuments.vue";
|
import CompanyDocuments from "~/components/CompanyDocuments.vue";
|
||||||
|
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
|
||||||
import type { CompanyOption } from "~/types/padron";
|
import type { CompanyOption } from "~/types/padron";
|
||||||
|
|
||||||
const emptyForm = () => ({
|
const emptyForm = () => ({
|
||||||
|
|
@ -258,6 +311,11 @@ const payroll = reactive({
|
||||||
});
|
});
|
||||||
const units = ref<{ code: string; label: string }[]>([]);
|
const units = ref<{ code: string; label: string }[]>([]);
|
||||||
const unitForm = reactive({ code: "", label: "" });
|
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 savingPayroll = ref(false);
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
@ -293,7 +351,17 @@ const claseRiesgoOpts = [
|
||||||
const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
|
const principal = computed(() => companies.value.find((company) => company.kind === "principal"));
|
||||||
const { tableQ, matches } = useTableSearch();
|
const { tableQ, matches } = useTableSearch();
|
||||||
const searchPlaceholder = computed(() =>
|
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(() =>
|
const listedCompanies = computed(() =>
|
||||||
companies.value.filter((company) =>
|
companies.value.filter((company) =>
|
||||||
|
|
@ -315,6 +383,12 @@ const listedCompanies = computed(() =>
|
||||||
const subCompanies = computed(() => listedCompanies.value.filter((company) => company.kind !== "principal").length);
|
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 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 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) {
|
function regimenLabel(code?: string | null) {
|
||||||
if (!code) return "—";
|
if (!code) return "—";
|
||||||
|
|
@ -344,6 +418,18 @@ async function load() {
|
||||||
payroll.loan_commission_pct = Number(pay.settings.loan_commission_pct);
|
payroll.loan_commission_pct = Number(pay.settings.loan_commission_pct);
|
||||||
payroll.loan_small_max = Number(pay.settings.loan_small_max);
|
payroll.loan_small_max = Number(pay.settings.loan_small_max);
|
||||||
units.value = pay.units || [];
|
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();
|
syncChrome();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,6 +453,30 @@ async function addUnit() {
|
||||||
await load();
|
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() {
|
function openNew() {
|
||||||
editing.value = null;
|
editing.value = null;
|
||||||
formTab.value = "datos";
|
formTab.value = "datos";
|
||||||
|
|
@ -474,10 +584,12 @@ function syncChrome() {
|
||||||
showDetail: false,
|
showDetail: false,
|
||||||
treeKey: tab.value,
|
treeKey: tab.value,
|
||||||
onTreeSelect: (key) => {
|
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: [
|
tree: [
|
||||||
{ key: "empresas", label: "Empresas", icon: "pi pi-briefcase", data: { count: companies.value.length } },
|
{ 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: "nomina", label: "Nómina", icon: "pi pi-wallet" },
|
||||||
{ key: "riesgos", label: "Riesgos", icon: "pi pi-exclamation-triangle", data: { count: risks.value.length } },
|
{ 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 } },
|
{ key: "temas", label: "Temas de gafete", icon: "pi pi-palette", data: { count: themes.value.length } },
|
||||||
|
|
@ -488,6 +600,7 @@ function syncChrome() {
|
||||||
label: "Acciones",
|
label: "Acciones",
|
||||||
items: [
|
items: [
|
||||||
{ key: "alta", label: "Nueva subempresa", icon: "pi pi-plus", disabled: tab.value !== "empresas", command: openNew },
|
{ 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(
|
watch(
|
||||||
() => String(route.query.tab || ""),
|
() => String(route.query.tab || ""),
|
||||||
(key) => {
|
(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 },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,16 @@
|
||||||
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
|
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
|
||||||
</DeskTableBar>
|
</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 v-if="summary" class="kpi-row mb-3">
|
||||||
<div class="kpi-card">
|
<div class="kpi-card">
|
||||||
<span class="kpi-label">Presupuesto</span>
|
<span class="kpi-label">Presupuesto</span>
|
||||||
|
|
@ -38,6 +48,7 @@
|
||||||
scroll-height="flex"
|
scroll-height="flex"
|
||||||
table-style="min-width: 72rem"
|
table-style="min-width: 72rem"
|
||||||
empty-message="Seleccione una obra con presupuesto cargado."
|
empty-message="Seleccione una obra con presupuesto cargado."
|
||||||
|
@row-click="onRowClick"
|
||||||
>
|
>
|
||||||
<Column field="code" header="Clave" style="width: 7rem">
|
<Column field="code" header="Clave" style="width: 7rem">
|
||||||
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
|
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
|
||||||
|
|
@ -91,6 +102,7 @@ type Summary = {
|
||||||
const desk = useDesktop();
|
const desk = useDesktop();
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const perms = usePermissions();
|
||||||
|
|
||||||
const tableQ = ref("");
|
const tableQ = ref("");
|
||||||
const projects = ref<{ id: number; name: string }[]>([]);
|
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 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 projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra");
|
||||||
|
const canCreateExpense = computed(() => perms.can("expenses.create"));
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const q = tableQ.value.toLowerCase();
|
const q = tableQ.value.toLowerCase();
|
||||||
return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q));
|
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));
|
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() {
|
async function loadProjects() {
|
||||||
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
|
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
|
||||||
projects.value = r.projects ?? [];
|
projects.value = r.projects ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadControl() {
|
async function loadControl() {
|
||||||
if (!projectId.value) return;
|
if (!projectId.value) {
|
||||||
|
summary.value = null;
|
||||||
|
items.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [s, i] = await Promise.all([
|
const [s, i] = await Promise.all([
|
||||||
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
|
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
|
||||||
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
|
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
|
||||||
|
|
@ -123,20 +156,67 @@ async function loadControl() {
|
||||||
items.value = i.data?.items ?? [];
|
items.value = i.data?.items ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function syncChrome() {
|
||||||
await loadProjects();
|
|
||||||
desk.configure({
|
desk.configure({
|
||||||
tab: "Control presupuesto",
|
tab: "Control presupuesto",
|
||||||
treeTitle: "Obras",
|
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" })),
|
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
|
||||||
onTreeSelect: (key) => {
|
onTreeSelect: (key) => {
|
||||||
if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`);
|
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();
|
await loadControl();
|
||||||
|
syncChrome();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(projectId, loadControl);
|
watch(projectId, async () => {
|
||||||
|
await loadControl();
|
||||||
|
syncChrome();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -150,4 +230,5 @@ watch(projectId, loadControl);
|
||||||
}
|
}
|
||||||
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
|
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
|
||||||
.cost-critical-text { color: #b42318; font-weight: 600; }
|
.cost-critical-text { color: #b42318; font-weight: 600; }
|
||||||
|
.control-page :deep(.p-datatable-tbody > tr) { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,8 @@ const SAMPLE: Worker = {
|
||||||
const { api, download } = useApi();
|
const { api, download } = useApi();
|
||||||
const desk = useDesktop();
|
const desk = useDesktop();
|
||||||
const toast = useToast();
|
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 projectId = ref<number | null>(null);
|
||||||
const workers = ref<Worker[]>([]);
|
const workers = ref<Worker[]>([]);
|
||||||
const workerId = ref(0);
|
const workerId = ref(0);
|
||||||
|
|
@ -203,8 +204,9 @@ const cardProps = computed(() => {
|
||||||
blood: (w.blood_type || "").toUpperCase(),
|
blood: (w.blood_type || "").toUpperCase(),
|
||||||
riskColor: r?.color || w.risk_color || "#A20000",
|
riskColor: r?.color || w.risk_color || "#A20000",
|
||||||
riskText: r?.text_color || w.risk_text || "#FFFFFF",
|
riskText: r?.text_color || w.risk_text || "#FFFFFF",
|
||||||
projectName: (project.value?.name || "Zendala Cancún").toUpperCase(),
|
companyName: (project.value?.company_name || user.value?.company_name || "").toUpperCase(),
|
||||||
projectCode: (project.value?.code || "PRY-0001").toUpperCase(),
|
projectName: (project.value?.name || "Obra").toUpperCase(),
|
||||||
|
projectCode: (project.value?.code || "").toUpperCase(),
|
||||||
photoSrc: w.id ? `/v1/workers/${w.id}/photo` : "",
|
photoSrc: w.id ? `/v1/workers/${w.id}/photo` : "",
|
||||||
qrSrc: `/v1/badge-qr?curp=${encodeURIComponent(w.curp)}`,
|
qrSrc: `/v1/badge-qr?curp=${encodeURIComponent(w.curp)}`,
|
||||||
};
|
};
|
||||||
|
|
@ -236,7 +238,7 @@ async function loadWorkers() {
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const [p, j, c] = await Promise.all([
|
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<{ jobs: Record<string, unknown>[] }>("/v1/badge-jobs"),
|
||||||
api<{ risks: { code: string; label: string; color: string; text_color: string }[] }>("/v1/catalogs"),
|
api<{ risks: { code: string; label: string; color: string; text_color: string }[] }>("/v1/catalogs"),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
|
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
|
||||||
<Chip :label="projectLabel" icon="pi pi-building" />
|
<Chip :label="projectLabel" icon="pi pi-building" />
|
||||||
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" />
|
<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" />
|
<Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
|
||||||
|
<SelectButton v-model="listFilter" :options="listFilterOpts" option-label="label" option-value="value" :allow-empty="false" />
|
||||||
</DeskTableBar>
|
</DeskTableBar>
|
||||||
|
|
||||||
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
|
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
|
||||||
|
|
@ -11,20 +13,28 @@
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
v-model:selection="selected"
|
||||||
:value="listed"
|
:value="listed"
|
||||||
data-key="id"
|
data-key="id"
|
||||||
paginator
|
paginator
|
||||||
:rows="25"
|
:rows="25"
|
||||||
empty-message="No hay gastos en el periodo seleccionado."
|
empty-message="No hay gastos en el periodo seleccionado."
|
||||||
|
selection-mode="single"
|
||||||
|
meta-key-selection="false"
|
||||||
scrollable
|
scrollable
|
||||||
scroll-height="flex"
|
scroll-height="flex"
|
||||||
table-style="min-width: 56rem"
|
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="expense_date" header="Fecha" sortable style="width: 7rem" />
|
||||||
<Column field="description" header="Descripción" sortable />
|
<Column field="description" header="Descripción" sortable />
|
||||||
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
|
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
|
||||||
<Column field="budget_item_code" header="Concepto" style="width: 8rem">
|
<Column field="budget_item_code" header="Concepto" style="width: 10rem">
|
||||||
<template #body="{ data }"><span class="identifier">{{ data.budget_item_code || "—" }}</span></template>
|
<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>
|
||||||
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
|
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
|
||||||
<template #body="{ data }">{{ money(data.subtotal) }}</template>
|
<template #body="{ data }">{{ money(data.subtotal) }}</template>
|
||||||
|
|
@ -38,7 +48,7 @@
|
||||||
<Column field="source" header="Origen" style="width: 7rem" />
|
<Column field="source" header="Origen" style="width: 7rem" />
|
||||||
</DataTable>
|
</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">
|
<div class="form-grid form-grid-compact">
|
||||||
<FormField label="Empresa" required>
|
<FormField label="Empresa" required>
|
||||||
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
|
<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" />
|
<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>
|
||||||
<FormField label="Fecha" required>
|
<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>
|
||||||
<FormField label="Concepto presupuesto" class="span-2">
|
<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" />
|
<InputText v-else disabled placeholder="Seleccione una obra para ligar concepto" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Descripción" class="span-2" required>
|
<FormField label="Descripción" class="span-2" required>
|
||||||
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
|
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Proveedor">
|
<FormField label="Proveedor" class="span-2">
|
||||||
<InputText v-model="form.supplier_name" class="w-full" />
|
<div class="flex gap-2 align-items-center">
|
||||||
</FormField>
|
<Select
|
||||||
<FormField label="RFC proveedor">
|
v-model="form.supplier_id"
|
||||||
<InputText v-model="form.supplier_rfc" class="w-full identifier-input" />
|
: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>
|
||||||
<FormField label="Cantidad">
|
<FormField label="Cantidad">
|
||||||
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Unidad">
|
<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>
|
||||||
<FormField label="Precio unitario">
|
<FormField label="Precio unitario">
|
||||||
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Subtotal">
|
<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>
|
||||||
<FormField label="IVA incluido en total">
|
<FormField label="IVA incluido en el precio">
|
||||||
<Checkbox v-model="form.tax_included" binary />
|
<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>
|
||||||
<FormField label="Total con IVA">
|
<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>
|
||||||
<FormField label="Comprobante PDF" class="span-2">
|
<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>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
|
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancelar" text @click="showForm = false" />
|
<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>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -96,14 +165,24 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FormField from "~/components/padron/FormField.vue";
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
||||||
|
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
|
||||||
import { displayErrorMessage } from "~/composables/api-response";
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
import { expenseAmounts } from "~/composables/expenseAmounts";
|
||||||
|
|
||||||
type Expense = {
|
type Expense = {
|
||||||
id: number;
|
id: number;
|
||||||
expense_date: string;
|
expense_date: string;
|
||||||
description: string;
|
description: string;
|
||||||
supplier_name: string;
|
supplier_name: string;
|
||||||
|
supplier_id?: number | null;
|
||||||
|
budget_item_id?: number | null;
|
||||||
budget_item_code?: string;
|
budget_item_code?: string;
|
||||||
|
company_id?: number | null;
|
||||||
|
quantity?: number;
|
||||||
|
unit?: string;
|
||||||
|
unit_price?: number;
|
||||||
|
tax_included?: boolean;
|
||||||
|
is_locked?: boolean;
|
||||||
subtotal: number;
|
subtotal: number;
|
||||||
iva_amount: number;
|
iva_amount: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
|
@ -115,15 +194,31 @@ const desk = useDesktop();
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const perms = usePermissions();
|
||||||
|
|
||||||
const tableQ = ref("");
|
const tableQ = ref("");
|
||||||
const expenses = ref<Expense[]>([]);
|
const expenses = ref<Expense[]>([]);
|
||||||
const companies = ref<{ id: number; name: string }[]>([]);
|
const companies = ref<{ id: number; name: string }[]>([]);
|
||||||
const projects = 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 showForm = ref(false);
|
||||||
|
const showSupplierForm = ref(false);
|
||||||
|
const showUnitForm = ref(false);
|
||||||
const formError = ref("");
|
const formError = ref("");
|
||||||
|
const unitError = ref("");
|
||||||
const budgetWarning = ref<{ level: string; message: string } | null>(null);
|
const budgetWarning = ref<{ level: string; message: string } | null>(null);
|
||||||
const receiptFile = ref<File | 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({
|
const form = reactive({
|
||||||
company_id: null as number | null,
|
company_id: null as number | null,
|
||||||
|
|
@ -131,16 +226,37 @@ const form = reactive({
|
||||||
expense_date: new Date().toISOString().slice(0, 10),
|
expense_date: new Date().toISOString().slice(0, 10),
|
||||||
budget_item_id: null as number | null,
|
budget_item_id: null as number | null,
|
||||||
description: "",
|
description: "",
|
||||||
supplier_name: "",
|
supplier_id: null as number | null,
|
||||||
supplier_rfc: "",
|
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
unit: "",
|
unit: "",
|
||||||
unit_price: 0,
|
unit_price: 0,
|
||||||
subtotal: 0,
|
|
||||||
total: 0,
|
|
||||||
tax_included: false,
|
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 projectId = computed(() => Number(route.query.project_id || 0) || null);
|
||||||
const projectLabel = computed(() => {
|
const projectLabel = computed(() => {
|
||||||
const p = projects.value.find((x) => x.id === projectId.value);
|
const p = projects.value.find((x) => x.id === projectId.value);
|
||||||
|
|
@ -148,19 +264,169 @@ const projectLabel = computed(() => {
|
||||||
});
|
});
|
||||||
const listed = computed(() => {
|
const listed = computed(() => {
|
||||||
const q = tableQ.value.toLowerCase();
|
const q = tableQ.value.toLowerCase();
|
||||||
return expenses.value.filter((e) =>
|
return expenses.value.filter((e) => {
|
||||||
!q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q),
|
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 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) {
|
function money(v: number) {
|
||||||
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFile(e: Event) {
|
function parseDate(value: unknown): Date | null {
|
||||||
const input = e.target as HTMLInputElement;
|
const raw = String(value || "").slice(0, 10);
|
||||||
receiptFile.value = input.files?.[0] ?? null;
|
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() {
|
async function loadCatalogs() {
|
||||||
|
|
@ -171,13 +437,70 @@ async function loadCatalogs() {
|
||||||
companies.value = cat.companies ?? [];
|
companies.value = cat.companies ?? [];
|
||||||
projects.value = pr.projects ?? [];
|
projects.value = pr.projects ?? [];
|
||||||
if (!form.company_id && companies.value[0]) form.company_id = companies.value[0].id;
|
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() {
|
async function loadExpenses() {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (projectId.value) qs.set("project_id", String(projectId.value));
|
if (projectId.value) qs.set("project_id", String(projectId.value));
|
||||||
const r = await api<{ data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
|
const r = await api<{ expenses?: Expense[]; data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
|
||||||
expenses.value = (r.data?.expenses ?? []) as Expense[];
|
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() {
|
async function save() {
|
||||||
|
|
@ -187,23 +510,35 @@ async function save() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const r = await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
|
const r = editingId.value
|
||||||
"/v1/expenses",
|
? await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
|
||||||
{
|
`/v1/expenses/${editingId.value}`,
|
||||||
method: "POST",
|
{ method: "PATCH", json: payload() },
|
||||||
json: { ...form, project_id: form.project_id || null },
|
)
|
||||||
},
|
: await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
|
||||||
);
|
"/v1/expenses",
|
||||||
const expenseId = r.data?.expense?.id;
|
{ method: "POST", json: payload() },
|
||||||
|
);
|
||||||
|
const expenseId = r.data?.expense?.id ?? editingId.value;
|
||||||
if (expenseId && receiptFile.value) {
|
if (expenseId && receiptFile.value) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append("file", receiptFile.value);
|
fd.append("file", receiptFile.value);
|
||||||
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
|
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
|
||||||
}
|
}
|
||||||
budgetWarning.value = r.data?.budget_warning ?? null;
|
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;
|
showForm.value = false;
|
||||||
|
editingId.value = null;
|
||||||
await loadExpenses();
|
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) {
|
} catch (e) {
|
||||||
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
||||||
}
|
}
|
||||||
|
|
@ -211,25 +546,22 @@ async function save() {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadCatalogs();
|
await loadCatalogs();
|
||||||
desk.configure({
|
syncChrome();
|
||||||
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; } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await loadExpenses();
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,7 @@ function buildHomeAlerts(input: {
|
||||||
key: "home_frescos",
|
key: "home_frescos",
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
title: `${freshness} con documentos por refrescar`,
|
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",
|
route: "/padron",
|
||||||
filter: "freshness",
|
filter: "freshness",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,15 @@
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<TabPanel value="obra">
|
<TabPanel value="obra">
|
||||||
<Message severity="secondary" :closable="false">
|
<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>
|
</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>
|
<Column header="Persona" frozen>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<button type="button" class="person-link" @click="openPerson(data.worker_id)">
|
<button type="button" class="person-link" @click="openPerson(data.worker_id)">
|
||||||
|
|
@ -60,7 +66,7 @@
|
||||||
v-for="(day, idx) in days"
|
v-for="(day, idx) in days"
|
||||||
:key="day"
|
:key="day"
|
||||||
:header="dayHeaders[idx]"
|
:header="dayHeaders[idx]"
|
||||||
style="width: 5.5rem; text-align: center"
|
style="width: 4.2rem; min-width: 3.6rem; text-align: center"
|
||||||
>
|
>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag
|
<Tag
|
||||||
|
|
@ -377,7 +383,8 @@ async function load() {
|
||||||
destajoPeriod.value = bundle.destajo_period;
|
destajoPeriod.value = bundle.destajo_period;
|
||||||
sheets.value = bundle.sheets;
|
sheets.value = bundle.sheets;
|
||||||
destajoCuts.value = bundle.destajo_cuts || [];
|
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 = {
|
board.value = {
|
||||||
required_net: 0,
|
required_net: 0,
|
||||||
payable_net: 0,
|
payable_net: 0,
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ function baja(worker: Worker) {
|
||||||
function reactivate(worker: Worker) {
|
function reactivate(worker: Worker) {
|
||||||
confirm.require({
|
confirm.require({
|
||||||
header: "Reactivar persona",
|
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",
|
icon: "pi pi-user-plus",
|
||||||
acceptLabel: "Reactivar",
|
acceptLabel: "Reactivar",
|
||||||
rejectLabel: "Cancelar",
|
rejectLabel: "Cancelar",
|
||||||
|
|
@ -274,7 +274,7 @@ function reactivate(worker: Worker) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: "info",
|
severity: "info",
|
||||||
summary: "Reingreso",
|
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,
|
life: 4000,
|
||||||
});
|
});
|
||||||
await loadWorkers(worker.id);
|
await loadWorkers(worker.id);
|
||||||
|
|
@ -310,7 +310,7 @@ function syncStatusMessages() {
|
||||||
key: "docs_frescos",
|
key: "docs_frescos",
|
||||||
severity: "warn" as const,
|
severity: "warn" as const,
|
||||||
title: `${freshnessCount.value} con documentos por refrescar`,
|
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",
|
route: "/padron",
|
||||||
filter: "freshness",
|
filter: "freshness",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -124,7 +124,7 @@
|
||||||
/>
|
/>
|
||||||
<div v-if="readingBudget" class="muted mt-1">Leyendo Excel…</div>
|
<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-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>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -142,6 +142,8 @@
|
||||||
import FormField from "~/components/padron/FormField.vue";
|
import FormField from "~/components/padron/FormField.vue";
|
||||||
import type { CompanyOption } from "~/types/padron";
|
import type { CompanyOption } from "~/types/padron";
|
||||||
import { companyLabel } from "~/types/padron";
|
import { companyLabel } from "~/types/padron";
|
||||||
|
import { displayErrorMessage } from "~/composables/api-response";
|
||||||
|
import { isExcelSpreadsheet } from "~/utils/excelFile";
|
||||||
|
|
||||||
type Project = Record<string, unknown> & {
|
type Project = Record<string, unknown> & {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -289,9 +291,7 @@ function onPickContrato(event: { files: File[] }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isExcelFile(file: File) {
|
function isExcelFile(file: File) {
|
||||||
const name = file.name.toLowerCase();
|
return isExcelSpreadsheet(file);
|
||||||
return name.endsWith(".xlsx") || name.endsWith(".xls") ||
|
|
||||||
file.type.includes("spreadsheet") || file.type.includes("excel");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function moneyMx(value: number) {
|
function moneyMx(value: number) {
|
||||||
|
|
@ -302,7 +302,8 @@ async function onPickPresupuesto(event: { files: File[] }) {
|
||||||
const file = event.files?.[0] || null;
|
const file = event.files?.[0] || null;
|
||||||
presupuestoFile.value = file;
|
presupuestoFile.value = file;
|
||||||
budgetHint.value = "";
|
budgetHint.value = "";
|
||||||
if (!file || !isExcelFile(file)) return;
|
if (!file) return;
|
||||||
|
if (!isExcelFile(file)) return;
|
||||||
readingBudget.value = true;
|
readingBudget.value = true;
|
||||||
try {
|
try {
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
|
|
@ -310,6 +311,7 @@ async function onPickPresupuesto(event: { files: File[] }) {
|
||||||
const preview = await api<{
|
const preview = await api<{
|
||||||
totals: { subtotal: number; iva: number; total: number; item_count: number };
|
totals: { subtotal: number; iva: number; total: number; item_count: number };
|
||||||
format_label?: string;
|
format_label?: string;
|
||||||
|
errors?: { messages: string[] }[];
|
||||||
}>("/v1/budget/preview", { method: "POST", body });
|
}>("/v1/budget/preview", { method: "POST", body });
|
||||||
const subtotal = Number(preview.totals?.subtotal || 0);
|
const subtotal = Number(preview.totals?.subtotal || 0);
|
||||||
if (subtotal > 0) {
|
if (subtotal > 0) {
|
||||||
|
|
@ -321,14 +323,23 @@ async function onPickPresupuesto(event: { files: File[] }) {
|
||||||
detail: `Subtotal del presupuesto: ${moneyMx(subtotal)}`,
|
detail: `Subtotal del presupuesto: ${moneyMx(subtotal)}`,
|
||||||
life: 2800,
|
life: 2800,
|
||||||
});
|
});
|
||||||
|
} 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) {
|
} catch (error) {
|
||||||
const detail = error as { data?: { error?: string } };
|
const detail = error as { data?: unknown };
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: "warn",
|
severity: "error",
|
||||||
summary: "No se pudo leer el Excel",
|
summary: "No se pudo leer el Excel",
|
||||||
detail: detail.data?.error || (error instanceof Error ? error.message : "Revise el formato"),
|
detail: displayErrorMessage(detail.data ?? error, error instanceof Error ? error.message : "Revise que sea .xlsx o .xls con el formato de presupuesto"),
|
||||||
life: 3500,
|
life: 4500,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
readingBudget.value = false;
|
readingBudget.value = false;
|
||||||
|
|
@ -367,12 +378,12 @@ async function uploadDocs(projectId: number) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const detail = error as { data?: { error?: string } };
|
const detail = error as { data?: unknown };
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: "warn",
|
severity: "warn",
|
||||||
summary: "Documento guardado, pero no se importaron partidas",
|
summary: "Documento guardado, pero no se importaron partidas",
|
||||||
detail: detail.data?.error || (error instanceof Error ? error.message : "Revise el Excel"),
|
detail: displayErrorMessage(detail.data ?? error, error instanceof Error ? error.message : "Revise el formato del Excel"),
|
||||||
life: 4000,
|
life: 4500,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
web-panel/utils/excelFile.ts
Normal file
13
web-panel/utils/excelFile.ts
Normal 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).";
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue