From 6fd673f581ae4bcdc2ca0a87e0696b551a9dffa3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 19:55:37 +0000 Subject: [PATCH 1/4] fix: ribbon de Control presupuesto y captura de gastos por concepto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La pantalla de control era solo lectura, pero heredaba los botones de Programa de obra (no-op). Ahora tiene acciones propias, explica que el ejecutado viene de Gastos/almacén, y abre la captura ligada al concepto. Co-authored-by: alberto.martinez --- web-panel/components/BudgetItemPicker.vue | 3 + web-panel/composables/useDesktop.ts | 2 +- web-panel/pages/control-presupuesto.vue | 89 +++++++++++++++++- web-panel/pages/gastos.vue | 104 ++++++++++++++++++---- 4 files changed, 175 insertions(+), 23 deletions(-) diff --git a/web-panel/components/BudgetItemPicker.vue b/web-panel/components/BudgetItemPicker.vue index 2c0f777..c7e5665 100644 --- a/web-panel/components/BudgetItemPicker.vue +++ b/web-panel/components/BudgetItemPicker.vue @@ -50,6 +50,9 @@ async function load() { label: [row.code, row.description].filter(Boolean).join(" — "), })); filtered.value = items.value.slice(0, 30); + if (props.modelValue) { + selected.value = items.value.find((i) => i.id === props.modelValue) ?? null; + } } function onSearch(event: { query: string }) { diff --git a/web-panel/composables/useDesktop.ts b/web-panel/composables/useDesktop.ts index d7db6ce..5b83a8e 100644 --- a/web-panel/composables/useDesktop.ts +++ b/web-panel/composables/useDesktop.ts @@ -87,7 +87,7 @@ export function useDesktop() { }) { tabLabel.value = opts.tab; if (opts.treeTitle != null) treeTitle.value = opts.treeTitle; - if (opts.ribbon) ribbon.value = opts.ribbon; + ribbon.value = opts.ribbon ?? []; if (opts.tree) tree.value = opts.tree; if (opts.treeKey != null) treeKey.value = opts.treeKey; if (opts.showTree != null) showTree.value = opts.showTree; diff --git a/web-panel/pages/control-presupuesto.vue b/web-panel/pages/control-presupuesto.vue index 99f4a67..8054868 100644 --- a/web-panel/pages/control-presupuesto.vue +++ b/web-panel/pages/control-presupuesto.vue @@ -6,6 +6,16 @@ + + Esta pantalla compara presupuesto contra lo ejecutado. Los gastos se capturan en + Gastos, ligados a un concepto. Use «Registrar gasto» o haga clic en un renglón. + +
Presupuesto @@ -38,6 +48,7 @@ scroll-height="flex" table-style="min-width: 72rem" empty-message="Seleccione una obra con presupuesto cargado." + @row-click="onRowClick" > @@ -91,6 +102,7 @@ type Summary = { const desk = useDesktop(); const { api } = useApi(); const route = useRoute(); +const perms = usePermissions(); const tableQ = ref(""); const projects = ref<{ id: number; name: string }[]>([]); @@ -99,6 +111,7 @@ const summary = ref(null); const projectId = computed(() => Number(route.query.project_id || 0) || projects.value[0]?.id || 0); const projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra"); +const canCreateExpense = computed(() => perms.can("expenses.create")); const filteredItems = computed(() => { const q = tableQ.value.toLowerCase(); return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q)); @@ -108,13 +121,33 @@ function money(v: number) { return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0)); } +function goGastos(opts?: { nuevo?: boolean; budgetItemId?: number }) { + if (!projectId.value) return; + const qs = new URLSearchParams({ project_id: String(projectId.value) }); + if (opts?.nuevo) qs.set("nuevo", "1"); + if (opts?.budgetItemId) qs.set("budget_item_id", String(opts.budgetItemId)); + navigateTo(`/gastos?${qs}`); +} + +function onRowClick(e: { data: ControlItem }) { + if (!canCreateExpense.value) { + goGastos(); + return; + } + goGastos({ nuevo: true, budgetItemId: e.data.id }); +} + async function loadProjects() { const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects"); projects.value = r.projects ?? []; } async function loadControl() { - if (!projectId.value) return; + if (!projectId.value) { + summary.value = null; + items.value = []; + return; + } const [s, i] = await Promise.all([ api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`), api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`), @@ -123,20 +156,67 @@ async function loadControl() { items.value = i.data?.items ?? []; } -onMounted(async () => { - await loadProjects(); +function syncChrome() { desk.configure({ tab: "Control presupuesto", treeTitle: "Obras", + showTree: true, + showDetail: false, + treeKey: projectId.value ? `p:${projectId.value}` : "", + countLabel: projectId.value ? `${items.value.length} conceptos` : "", tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })), onTreeSelect: (key) => { if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`); }, + ribbon: [ + { + key: "control", + label: "Control", + items: [ + { + key: "gasto", + label: "Registrar gasto", + icon: "pi pi-plus", + disabled: !projectId.value || !canCreateExpense.value, + title: canCreateExpense.value + ? "Captura un gasto ligado a un concepto del presupuesto" + : "Sin permiso para capturar gastos", + command: () => goGastos({ nuevo: true }), + }, + { + key: "gastos", + label: "Ver gastos", + icon: "pi pi-list", + disabled: !projectId.value, + command: () => goGastos(), + }, + ], + }, + { + key: "vista", + label: "Vista", + items: [ + { key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !projectId.value, command: loadControl }, + ], + }, + ], }); +} + +onMounted(async () => { + await loadProjects(); + if (!route.query.project_id && projectId.value) { + await navigateTo(`/control-presupuesto?project_id=${projectId.value}`, { replace: true }); + } + syncChrome(); await loadControl(); + syncChrome(); }); -watch(projectId, loadControl); +watch(projectId, async () => { + await loadControl(); + syncChrome(); +}); diff --git a/web-panel/pages/gastos.vue b/web-panel/pages/gastos.vue index df10f9b..e4cfe08 100644 --- a/web-panel/pages/gastos.vue +++ b/web-panel/pages/gastos.vue @@ -115,6 +115,7 @@ const desk = useDesktop(); const { api } = useApi(); const toast = useToast(); const route = useRoute(); +const perms = usePermissions(); const tableQ = ref(""); const expenses = ref([]); @@ -124,6 +125,7 @@ const showForm = ref(false); const formError = ref(""); const budgetWarning = ref<{ level: string; message: string } | null>(null); const receiptFile = ref(null); +const canCreateExpense = computed(() => perms.can("expenses.create")); const form = reactive({ company_id: null as number | null, @@ -163,6 +165,72 @@ function onFile(e: Event) { receiptFile.value = input.files?.[0] ?? null; } +function resetForm() { + form.company_id = companies.value[0]?.id ?? null; + form.project_id = projectId.value; + form.expense_date = new Date().toISOString().slice(0, 10); + form.budget_item_id = Number(route.query.budget_item_id || 0) || null; + form.description = ""; + form.supplier_name = ""; + form.supplier_rfc = ""; + form.quantity = 0; + form.unit = ""; + form.unit_price = 0; + form.subtotal = 0; + form.total = 0; + form.tax_included = false; + receiptFile.value = null; + formError.value = ""; +} + +function openNew() { + resetForm(); + showForm.value = true; +} + +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: "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() { const [cat, pr] = await Promise.all([ api<{ companies?: { id: number; name: string }[] }>("/v1/catalogs"), @@ -204,6 +272,11 @@ async function save() { toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 }); showForm.value = false; await loadExpenses(); + if (route.query.nuevo || route.query.budget_item_id) { + const qs = new URLSearchParams(); + if (projectId.value) qs.set("project_id", String(projectId.value)); + await navigateTo(`/gastos${qs.toString() ? `?${qs}` : ""}`, { replace: true }); + } } catch (e) { formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e); } @@ -211,25 +284,20 @@ async function save() { onMounted(async () => { await loadCatalogs(); - desk.configure({ - tab: "Gastos", - treeTitle: "Obras", - tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })), - onTreeSelect: (key) => { - if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`); - }, - ribbon: [ - { - key: "gastos", - label: "Captura", - items: [ - { key: "new", label: "Nuevo gasto", icon: "pi pi-plus", command: () => { showForm.value = true; } }, - ], - }, - ], - }); + syncChrome(); await loadExpenses(); + if (route.query.nuevo) openNew(); }); -watch(projectId, loadExpenses); +watch(projectId, () => { + loadExpenses(); + syncChrome(); +}); + +watch( + () => String(route.query.nuevo || ""), + (nuevo) => { + if (nuevo) openNew(); + }, +); From 1e5bc2293615d3c90905d5fac1e0ab1fdea41d17 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 20:06:45 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20cat=C3=A1logo=20de=20proveedores=20y?= =?UTF-8?q?=20unidades=20en=20la=20captura=20de=20gastos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proveedor y unidad pasan a Select de catálogo (no texto libre). Subtotal, IVA y total se calculan al vuelo. El comprobante usa FileUpload de PrimeVue. Co-authored-by: alberto.martinez --- api/cost_modules_test.ts | 31 ++ api/expense_catalogs_http.ts | 67 +++ api/main.ts | 2 + db/core/changelog-master.xml | 1 + .../033-catalog-suppliers-units.sql | 397 ++++++++++++++++++ web-panel/components/SupplierFormDialog.vue | 129 ++++++ web-panel/composables/expenseAmounts.ts | 21 + web-panel/pages/configuracion.vue | 119 +++++- web-panel/pages/gastos.vue | 211 ++++++++-- 9 files changed, 949 insertions(+), 29 deletions(-) create mode 100644 api/expense_catalogs_http.ts create mode 100644 db/core/changesets/033-catalog-suppliers-units.sql create mode 100644 web-panel/components/SupplierFormDialog.vue create mode 100644 web-panel/composables/expenseAmounts.ts diff --git a/api/cost_modules_test.ts b/api/cost_modules_test.ts index 735b684..8c445ad 100644 --- a/api/cost_modules_test.ts +++ b/api/cost_modules_test.ts @@ -233,6 +233,37 @@ Deno.test("RPC control presupuestal: summary, items, chapters, deviations", asyn }); }); +Deno.test("RPC catálogos: proveedores y unidades de medida", async () => { + await withTestDb(async (db) => { + const created = await callCoreFn(db, "core.fn_supplier_create", { + tenant_id: TENANT_ID, + name: "Home Depot", + rfc: "HDM123456ABC", + phone: "9980000000", + }); + assertEquals(created.ok, true, `crear proveedor: ${JSON.stringify(created)}`); + const supplier = (created.data as { supplier: { id: number; rfc: string } }).supplier; + assertEquals(supplier.rfc, "HDM123456ABC"); + + const listed = await callCoreFn(db, "core.fn_supplier_list", { tenant_id: TENANT_ID, status: "activo" }); + assertEquals(listed.ok, true); + const suppliers = (listed.data as { suppliers: { name: string }[] }).suppliers; + assert(suppliers.some((s) => s.name === "Home Depot")); + + const units = await callCoreFn(db, "core.fn_measure_unit_list", { tenant_id: TENANT_ID }); + assertEquals(units.ok, true); + const unitRows = (units.data as { units: { code: string }[] }).units; + assert(unitRows.some((u) => u.code === "bulto")); + + const extra = await callCoreFn(db, "core.fn_measure_unit_add", { + tenant_id: TENANT_ID, + code: "tinaco", + label: "Tinaco", + }); + assertEquals(extra.ok, true); + }); +}); + Deno.test("RPC IAM v2: roles y permisos por role_id", async () => { const url = Deno.env.get("DATABASE_URL_IAM"); if (!url) throw new Error("DATABASE_URL_IAM requerido"); diff --git a/api/expense_catalogs_http.ts b/api/expense_catalogs_http.ts new file mode 100644 index 0000000..2d38c6c --- /dev/null +++ b/api/expense_catalogs_http.ts @@ -0,0 +1,67 @@ +import type { Hono } from "hono"; +import type { AuthUser } from "./auth.ts"; +import { tenantScope } from "./auth.ts"; +import { requireCoreAuth } from "./scope.ts"; +import { requireAnyPermission } from "./permissions.ts"; +import { callCoreFn } from "./rpc.ts"; +import { respondRpc, routeLabel } from "./http_errors.ts"; + +type App = Hono<{ Variables: { user: AuthUser; db: Db } }>; +type Db = import("./db.ts").Db; + +function tid(c: { get: (k: "user") => AuthUser }): number { + return tenantScope(c.get("user")) ?? 0; +} + +export function registerExpenseCatalogRoutes(app: App) { + const view = [...requireCoreAuth, requireAnyPermission("expenses.view", "settings.view")] as const; + const write = [...requireCoreAuth, requireAnyPermission("expenses.create", "settings.update")] as const; + + app.get("/v1/suppliers", ...view, async (c) => { + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_supplier_list", { + tenant_id: tid(c), + status: c.req.query("status"), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.post("/v1/suppliers", ...write, async (c) => { + const body = await c.req.json>(); + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_supplier_create", { + ...body, + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.patch("/v1/suppliers/:id", ...write, async (c) => { + const body = await c.req.json>(); + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_supplier_update", { + ...body, + id: Number(c.req.param("id")), + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.get("/v1/measure-units", ...view, async (c) => { + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_measure_unit_list", { + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); + + app.post("/v1/measure-units", ...write, async (c) => { + const body = await c.req.json>(); + const db = c.get("db"); + const env = await callCoreFn(db, "core.fn_measure_unit_add", { + ...body, + tenant_id: tid(c), + }, { route: routeLabel(c) }); + return respondRpc(c, env); + }); +} diff --git a/api/main.ts b/api/main.ts index b400b4f..0b3afed 100644 --- a/api/main.ts +++ b/api/main.ts @@ -65,6 +65,7 @@ import { import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts"; import { registerPayrollRoutes } from "./payroll_http.ts"; import { registerExpenseRoutes } from "./expenses_http.ts"; +import { registerExpenseCatalogRoutes } from "./expense_catalogs_http.ts"; import { registerWarehouseRoutes } from "./warehouse_http.ts"; import { registerCostControlRoutes } from "./cost_control_http.ts"; import { registerWorkProgramRoutes } from "./work_program_http.ts"; @@ -1378,6 +1379,7 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", ...requireCoreAuth, requirePerm registerPayrollRoutes(app); registerExpenseRoutes(app); +registerExpenseCatalogRoutes(app); registerWarehouseRoutes(app); registerCostControlRoutes(app); registerWorkProgramRoutes(app); diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml index ecb60c8..acf04c1 100644 --- a/db/core/changelog-master.xml +++ b/db/core/changelog-master.xml @@ -38,5 +38,6 @@ + diff --git a/db/core/changesets/033-catalog-suppliers-units.sql b/db/core/changesets/033-catalog-suppliers-units.sql new file mode 100644 index 0000000..8a94123 --- /dev/null +++ b/db/core/changesets/033-catalog-suppliers-units.sql @@ -0,0 +1,397 @@ +--liquibase formatted sql +-- PANELS · core · catálogo de proveedores y unidades de medida (gastos) + +--changeset panel:core-033a-suppliers-table endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='suppliers' +CREATE TABLE core.suppliers ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id INTEGER NOT NULL, + name TEXT NOT NULL, + legal_name TEXT NOT NULL DEFAULT '', + rfc TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL DEFAULT '', + city TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT '', + postal_code TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX idx_core_suppliers_rfc + ON core.suppliers (tenant_id, rfc) + WHERE rfc <> ''; +CREATE INDEX idx_core_suppliers_tenant_name ON core.suppliers (tenant_id, lower(name)); +GRANT SELECT, INSERT, UPDATE, DELETE ON core.suppliers TO panels_core_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app; + +--changeset panel:core-033b-measure-units-table endDelimiter:; splitStatements:true +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='measure_units' +CREATE TABLE core.measure_units ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + tenant_id INTEGER NOT NULL, + code TEXT NOT NULL, + label TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (tenant_id, code) +); +CREATE INDEX idx_core_measure_units_tenant ON core.measure_units (tenant_id); +GRANT SELECT, INSERT, UPDATE, DELETE ON core.measure_units TO panels_core_app; + +--changeset panel:core-033c-expense-supplier-id endDelimiter:; splitStatements:true +ALTER TABLE core.expense_entries + ADD COLUMN IF NOT EXISTS supplier_id BIGINT REFERENCES core.suppliers(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_core_expense_entries_supplier ON core.expense_entries (supplier_id); + +--changeset panel:core-033d-rls-suppliers-units endDelimiter:; splitStatements:true +ALTER TABLE core.suppliers ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON core.suppliers + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer); + +ALTER TABLE core.measure_units ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON core.measure_units + USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer) + WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer); + +--changeset panel:core-033e-fn-measure-units-seed splitStatements:false +CREATE OR REPLACE FUNCTION core._measure_units_seed(p_tenant_id integer) +RETURNS void +LANGUAGE plpgsql +SET search_path = core +AS $$ +BEGIN + IF p_tenant_id IS NULL OR p_tenant_id <= 0 THEN + RETURN; + END IF; + IF EXISTS (SELECT 1 FROM measure_units WHERE tenant_id = p_tenant_id) THEN + RETURN; + END IF; + INSERT INTO measure_units (tenant_id, code, label, sort_order) VALUES + (p_tenant_id, 'pza', 'Pieza', 10), + (p_tenant_id, 'm', 'Metro', 20), + (p_tenant_id, 'ml', 'Metro lineal', 30), + (p_tenant_id, 'm2', 'Metro cuadrado', 40), + (p_tenant_id, 'm3', 'Metro cúbico', 50), + (p_tenant_id, 'kg', 'Kilogramo', 60), + (p_tenant_id, 'ton', 'Tonelada', 70), + (p_tenant_id, 'lt', 'Litro', 80), + (p_tenant_id, 'gl', 'Galón', 90), + (p_tenant_id, 'bulto', 'Bulto', 100), + (p_tenant_id, 'saco', 'Saco', 110), + (p_tenant_id, 'jgo', 'Juego', 120), + (p_tenant_id, 'lote', 'Lote', 130), + (p_tenant_id, 'par', 'Par', 140), + (p_tenant_id, 'hr', 'Hora', 150), + (p_tenant_id, 'dia', 'Día', 160), + (p_tenant_id, 'via', 'Viaje', 170); +END; +$$; + +--changeset panel:core-033f-fn-supplier-list splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_supplier_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_status text := nullif(btrim(payload->>'status'), ''); + v_rows jsonb; +BEGIN + SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY lower(s.name), s.id), '[]'::jsonb) + INTO v_rows + FROM suppliers s + WHERE s.tenant_id = v_tid + AND (v_status IS NULL OR s.status = v_status); + RETURN core.rpc_ok( + jsonb_build_object('suppliers', v_rows, 'count', jsonb_array_length(v_rows)), + format('Catálogo de proveedores: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid), + jsonb_build_object('fn', 'fn_supplier_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_supplier_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033g-fn-supplier-create splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_supplier_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_name text := coalesce(nullif(btrim(payload->>'name'), ''), ''); + v_legal text := coalesce(nullif(btrim(payload->>'legal_name'), ''), ''); + v_rfc text := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g')); + v_email text := coalesce(nullif(btrim(payload->>'email'), ''), ''); + v_phone text := coalesce(nullif(btrim(payload->>'phone'), ''), ''); + v_address text := coalesce(nullif(btrim(payload->>'address'), ''), ''); + v_city text := coalesce(nullif(btrim(payload->>'city'), ''), ''); + v_state text := coalesce(nullif(btrim(payload->>'state'), ''), ''); + v_zip text := coalesce(nullif(btrim(payload->>'postal_code'), ''), ''); + v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), ''); + v_id bigint; + v_row jsonb; +BEGIN + IF v_name = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_supplier_create: el nombre del proveedor es obligatorio', + jsonb_build_object('fn', 'fn_supplier_create', 'field', 'name')); + END IF; + IF v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN + RETURN core.rpc_err('VALIDATION', format('fn_supplier_create: RFC %s no tiene 12 o 13 caracteres', v_rfc), + jsonb_build_object('fn', 'fn_supplier_create', 'field', 'rfc')); + END IF; + INSERT INTO suppliers ( + tenant_id, name, legal_name, rfc, email, phone, address, city, state, postal_code, notes + ) VALUES ( + v_tid, v_name, v_legal, v_rfc, v_email, v_phone, v_address, v_city, v_state, v_zip, v_notes + ) RETURNING id INTO v_id; + SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id; + RETURN core.rpc_created( + jsonb_build_object('supplier', v_row), + format('Proveedor %s registrado', v_name), + jsonb_build_object('fn', 'fn_supplier_create', 'id', v_id, 'tenant_id', v_tid) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', format('Ya existe un proveedor con RFC %s en este tenant', v_rfc), + jsonb_build_object('fn', 'fn_supplier_create', 'rfc', v_rfc)); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_supplier_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033h-fn-supplier-update splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_supplier_update(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_id bigint := NULLIF(payload->>'id', '')::bigint; + v_tid integer := core._cost_tenant_id(payload); + v_rfc text; + v_row jsonb; +BEGIN + IF v_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_supplier_update: id es obligatorio', + jsonb_build_object('fn', 'fn_supplier_update', 'field', 'id')); + END IF; + IF NOT EXISTS (SELECT 1 FROM suppliers WHERE id = v_id AND tenant_id = v_tid) THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_supplier_update: proveedor id=%s no encontrado', v_id), + jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id)); + END IF; + v_rfc := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g')); + IF payload ? 'rfc' AND v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN + RETURN core.rpc_err('VALIDATION', format('fn_supplier_update: RFC %s no tiene 12 o 13 caracteres', v_rfc), + jsonb_build_object('fn', 'fn_supplier_update', 'field', 'rfc')); + END IF; + UPDATE suppliers SET + name = CASE WHEN payload ? 'name' THEN coalesce(nullif(btrim(payload->>'name'), ''), name) ELSE name END, + legal_name = CASE WHEN payload ? 'legal_name' THEN coalesce(nullif(btrim(payload->>'legal_name'), ''), '') ELSE legal_name END, + rfc = CASE WHEN payload ? 'rfc' THEN v_rfc ELSE rfc END, + email = CASE WHEN payload ? 'email' THEN coalesce(nullif(btrim(payload->>'email'), ''), '') ELSE email END, + phone = CASE WHEN payload ? 'phone' THEN coalesce(nullif(btrim(payload->>'phone'), ''), '') ELSE phone END, + address = CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE address END, + city = CASE WHEN payload ? 'city' THEN coalesce(nullif(btrim(payload->>'city'), ''), '') ELSE city END, + state = CASE WHEN payload ? 'state' THEN coalesce(nullif(btrim(payload->>'state'), ''), '') ELSE state END, + postal_code = CASE WHEN payload ? 'postal_code' THEN coalesce(nullif(btrim(payload->>'postal_code'), ''), '') ELSE postal_code END, + notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END, + status = CASE WHEN payload ? 'status' THEN coalesce(nullif(btrim(payload->>'status'), ''), status) ELSE status END, + updated_at = now() + WHERE id = v_id AND tenant_id = v_tid; + SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id; + RETURN core.rpc_ok( + jsonb_build_object('supplier', v_row), + format('Proveedor id=%s actualizado', v_id), + jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', 'Ya existe otro proveedor con ese RFC en este tenant', + jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id)); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_supplier_update', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033i-fn-measure-unit-list splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_measure_unit_list(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_rows jsonb; +BEGIN + PERFORM core._measure_units_seed(v_tid); + SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.sort_order, u.code), '[]'::jsonb) + INTO v_rows + FROM measure_units u + WHERE u.tenant_id = v_tid + AND u.status = 'activo'; + RETURN core.rpc_ok( + jsonb_build_object('units', v_rows, 'count', jsonb_array_length(v_rows)), + format('Unidades de medida del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)), + jsonb_build_object('fn', 'fn_measure_unit_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows)) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_measure_unit_list', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033j-fn-measure-unit-add splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_measure_unit_add(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_code text := lower(regexp_replace(btrim(COALESCE(payload->>'code', '')), '\s+', '', 'g')); + v_label text := COALESCE(NULLIF(btrim(payload->>'label'), ''), v_code); + v_id bigint; + v_row jsonb; +BEGIN + IF v_code = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_measure_unit_add: el código de unidad es obligatorio', + jsonb_build_object('fn', 'fn_measure_unit_add', 'field', 'code')); + END IF; + PERFORM core._measure_units_seed(v_tid); + INSERT INTO measure_units (tenant_id, code, label, sort_order) + VALUES (v_tid, v_code, v_label, 500) + RETURNING id INTO v_id; + SELECT to_jsonb(u) INTO v_row FROM measure_units u WHERE id = v_id; + RETURN core.rpc_created( + jsonb_build_object('unit', v_row), + format('Unidad de medida %s (%s) agregada', v_label, v_code), + jsonb_build_object('fn', 'fn_measure_unit_add', 'id', v_id, 'tenant_id', v_tid) + ); +EXCEPTION + WHEN unique_violation THEN + RETURN core.rpc_err('CONFLICT', format('Ya existe la unidad %s en este tenant', v_code), + jsonb_build_object('fn', 'fn_measure_unit_add', 'code', v_code)); + WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_measure_unit_add', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033k-fn-expense-create-supplier splitStatements:false +CREATE OR REPLACE FUNCTION core.fn_expense_create(payload jsonb) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY INVOKER +SET search_path = core +AS $$ +DECLARE + v_tid integer := core._cost_tenant_id(payload); + v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint; + v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint; + v_expense_date date := COALESCE(NULLIF(payload->>'expense_date', '')::date, CURRENT_DATE); + v_description text := coalesce(nullif(btrim(payload->>'description'), ''), ''); + v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint; + v_budget_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint; + v_quantity numeric := COALESCE((payload->>'quantity')::numeric, 0); + v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), ''); + v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0); + v_subtotal_in numeric := COALESCE((payload->>'subtotal')::numeric, 0); + v_iva_rate numeric; + v_tax_included boolean := COALESCE((payload->>'tax_included')::boolean, false); + v_total_in numeric := (payload->>'total')::numeric; + v_subtotal numeric; v_iva_amount numeric; v_total numeric; + v_is_deductible boolean := COALESCE((payload->>'is_deductible')::boolean, true); + v_counts_toward_budget boolean := COALESCE((payload->>'counts_toward_budget')::boolean, true); + v_supplier_id bigint := NULLIF(payload->>'supplier_id', '')::bigint; + v_supplier_name text := coalesce(nullif(btrim(payload->>'supplier_name'), ''), ''); + v_supplier_rfc text := upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), '')); + v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), ''); + v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer; + v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), ''); + v_id bigint; + v_warning jsonb; + v_row jsonb; +BEGIN + IF v_company_id IS NULL THEN + RETURN core.rpc_err('VALIDATION', 'fn_expense_create: company_id es obligatorio', + jsonb_build_object('fn', 'fn_expense_create', 'field', 'company_id')); + END IF; + IF v_description = '' THEN + RETURN core.rpc_err('VALIDATION', 'fn_expense_create: description es obligatoria', + jsonb_build_object('fn', 'fn_expense_create', 'field', 'description')); + END IF; + IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN + RETURN core.rpc_err('VALIDATION', format('fn_expense_create: empresa id=%s no encontrada', v_company_id), + jsonb_build_object('fn', 'fn_expense_create', 'company_id', v_company_id)); + END IF; + IF v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proyecto id=%s no encontrado', v_project_id), + jsonb_build_object('fn', 'fn_expense_create', 'project_id', v_project_id)); + END IF; + IF v_supplier_id IS NOT NULL THEN + SELECT name, rfc INTO v_supplier_name, v_supplier_rfc + FROM suppliers + WHERE id = v_supplier_id AND tenant_id = v_tid AND status = 'activo'; + IF NOT FOUND THEN + RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proveedor id=%s no encontrado', v_supplier_id), + jsonb_build_object('fn', 'fn_expense_create', 'supplier_id', v_supplier_id)); + END IF; + END IF; + IF v_subtotal_in <= 0 AND v_quantity > 0 AND v_unit_price > 0 THEN + v_subtotal_in := round(v_quantity * v_unit_price, 2); + END IF; + SELECT default_iva_rate INTO v_iva_rate FROM core._cost_settings(v_tid); + v_iva_rate := COALESCE((payload->>'iva_rate')::numeric, v_iva_rate); + SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total + FROM core._cost_calc_amounts(v_subtotal_in, v_iva_rate, v_tax_included, v_total_in) c; + IF v_budget_item_id IS NOT NULL THEN + SELECT chapter_id INTO v_budget_chapter_id FROM budget_items WHERE id = v_budget_item_id; + END IF; + INSERT INTO expense_entries ( + tenant_id, company_id, project_id, expense_date, source, description, + supplier_id, supplier_name, supplier_rfc, budget_chapter_id, budget_item_id, + quantity, unit, unit_price, subtotal, iva_rate, iva_amount, total, + tax_included, is_deductible, counts_toward_budget, status, + created_by_id, created_by_name, notes + ) VALUES ( + v_tid, v_company_id, v_project_id, v_expense_date, 'manual', v_description, + v_supplier_id, v_supplier_name, v_supplier_rfc, v_budget_chapter_id, v_budget_item_id, + v_quantity, v_unit, v_unit_price, v_subtotal, v_iva_rate, v_iva_amount, v_total, + v_tax_included, v_is_deductible, v_counts_toward_budget, 'confirmed', + v_created_by_id, v_created_by_name, v_notes + ) RETURNING id INTO v_id; + IF v_counts_toward_budget AND v_budget_item_id IS NOT NULL THEN + v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0); + END IF; + SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id; + RETURN core.rpc_created( + jsonb_build_object('expense', v_row, 'budget_warning', v_warning), + format('Gasto registrado por %s (subtotal %s, IVA %s)', v_description, v_subtotal, v_iva_amount), + jsonb_build_object('fn', 'fn_expense_create', 'id', v_id) + ); +EXCEPTION WHEN OTHERS THEN + RETURN core.rpc_from_exception('fn_expense_create', SQLSTATE, SQLERRM); +END; +$$; + +--changeset panel:core-033l-grants endDelimiter:; splitStatements:true +GRANT EXECUTE ON FUNCTION core._measure_units_seed(integer) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_supplier_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_supplier_create(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_supplier_update(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_measure_unit_list(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_measure_unit_add(jsonb) TO panels_core_app; +GRANT EXECUTE ON FUNCTION core.fn_expense_create(jsonb) TO panels_core_app; diff --git a/web-panel/components/SupplierFormDialog.vue b/web-panel/components/SupplierFormDialog.vue new file mode 100644 index 0000000..2710079 --- /dev/null +++ b/web-panel/components/SupplierFormDialog.vue @@ -0,0 +1,129 @@ +