fix: catálogo de proveedores y unidades en la captura de gastos

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 <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-08 20:06:45 +00:00
parent 6fd673f581
commit 1e5bc22936
No known key found for this signature in database
9 changed files with 949 additions and 29 deletions

View file

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

View file

@ -0,0 +1,67 @@
import type { Hono } from "hono";
import type { AuthUser } from "./auth.ts";
import { tenantScope } from "./auth.ts";
import { requireCoreAuth } from "./scope.ts";
import { requireAnyPermission } from "./permissions.ts";
import { callCoreFn } from "./rpc.ts";
import { respondRpc, routeLabel } from "./http_errors.ts";
type App = Hono<{ Variables: { user: AuthUser; db: Db } }>;
type Db = import("./db.ts").Db;
function tid(c: { get: (k: "user") => AuthUser }): number {
return tenantScope(c.get("user")) ?? 0;
}
export function registerExpenseCatalogRoutes(app: App) {
const view = [...requireCoreAuth, requireAnyPermission("expenses.view", "settings.view")] 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<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_supplier_create", {
...body,
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.patch("/v1/suppliers/:id", ...write, async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_supplier_update", {
...body,
id: Number(c.req.param("id")),
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.get("/v1/measure-units", ...view, async (c) => {
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_measure_unit_list", {
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
app.post("/v1/measure-units", ...write, async (c) => {
const body = await c.req.json<Record<string, unknown>>();
const db = c.get("db");
const env = await callCoreFn(db, "core.fn_measure_unit_add", {
...body,
tenant_id: tid(c),
}, { route: routeLabel(c) });
return respondRpc(c, env);
});
}

View file

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

View file

@ -38,5 +38,6 @@
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
<include file="changesets/033-catalog-suppliers-units.sql" relativeToChangelogFile="true"/>
</databaseChangeLog>

View file

@ -0,0 +1,397 @@
--liquibase formatted sql
-- PANELS · core · catálogo de proveedores y unidades de medida (gastos)
--changeset panel:core-033a-suppliers-table endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='suppliers'
CREATE TABLE core.suppliers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
name TEXT NOT NULL,
legal_name TEXT NOT NULL DEFAULT '',
rfc TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
address TEXT NOT NULL DEFAULT '',
city TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT '',
postal_code TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_core_suppliers_rfc
ON core.suppliers (tenant_id, rfc)
WHERE rfc <> '';
CREATE INDEX idx_core_suppliers_tenant_name ON core.suppliers (tenant_id, lower(name));
GRANT SELECT, INSERT, UPDATE, DELETE ON core.suppliers TO panels_core_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA core TO panels_core_app;
--changeset panel:core-033b-measure-units-table endDelimiter:; splitStatements:true
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='core' AND table_name='measure_units'
CREATE TABLE core.measure_units (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id INTEGER NOT NULL,
code TEXT NOT NULL,
label TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, code)
);
CREATE INDEX idx_core_measure_units_tenant ON core.measure_units (tenant_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON core.measure_units TO panels_core_app;
--changeset panel:core-033c-expense-supplier-id endDelimiter:; splitStatements:true
ALTER TABLE core.expense_entries
ADD COLUMN IF NOT EXISTS supplier_id BIGINT REFERENCES core.suppliers(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_core_expense_entries_supplier ON core.expense_entries (supplier_id);
--changeset panel:core-033d-rls-suppliers-units endDelimiter:; splitStatements:true
ALTER TABLE core.suppliers ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.suppliers
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
ALTER TABLE core.measure_units ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON core.measure_units
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
--changeset panel:core-033e-fn-measure-units-seed splitStatements:false
CREATE OR REPLACE FUNCTION core._measure_units_seed(p_tenant_id integer)
RETURNS void
LANGUAGE plpgsql
SET search_path = core
AS $$
BEGIN
IF p_tenant_id IS NULL OR p_tenant_id <= 0 THEN
RETURN;
END IF;
IF EXISTS (SELECT 1 FROM measure_units WHERE tenant_id = p_tenant_id) THEN
RETURN;
END IF;
INSERT INTO measure_units (tenant_id, code, label, sort_order) VALUES
(p_tenant_id, 'pza', 'Pieza', 10),
(p_tenant_id, 'm', 'Metro', 20),
(p_tenant_id, 'ml', 'Metro lineal', 30),
(p_tenant_id, 'm2', 'Metro cuadrado', 40),
(p_tenant_id, 'm3', 'Metro cúbico', 50),
(p_tenant_id, 'kg', 'Kilogramo', 60),
(p_tenant_id, 'ton', 'Tonelada', 70),
(p_tenant_id, 'lt', 'Litro', 80),
(p_tenant_id, 'gl', 'Galón', 90),
(p_tenant_id, 'bulto', 'Bulto', 100),
(p_tenant_id, 'saco', 'Saco', 110),
(p_tenant_id, 'jgo', 'Juego', 120),
(p_tenant_id, 'lote', 'Lote', 130),
(p_tenant_id, 'par', 'Par', 140),
(p_tenant_id, 'hr', 'Hora', 150),
(p_tenant_id, 'dia', 'Día', 160),
(p_tenant_id, 'via', 'Viaje', 170);
END;
$$;
--changeset panel:core-033f-fn-supplier-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_supplier_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_status text := nullif(btrim(payload->>'status'), '');
v_rows jsonb;
BEGIN
SELECT COALESCE(jsonb_agg(to_jsonb(s) ORDER BY lower(s.name), s.id), '[]'::jsonb)
INTO v_rows
FROM suppliers s
WHERE s.tenant_id = v_tid
AND (v_status IS NULL OR s.status = v_status);
RETURN core.rpc_ok(
jsonb_build_object('suppliers', v_rows, 'count', jsonb_array_length(v_rows)),
format('Catálogo de proveedores: %s registro(s) para tenant %s', jsonb_array_length(v_rows), v_tid),
jsonb_build_object('fn', 'fn_supplier_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_supplier_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033g-fn-supplier-create splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_supplier_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_name text := coalesce(nullif(btrim(payload->>'name'), ''), '');
v_legal text := coalesce(nullif(btrim(payload->>'legal_name'), ''), '');
v_rfc text := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g'));
v_email text := coalesce(nullif(btrim(payload->>'email'), ''), '');
v_phone text := coalesce(nullif(btrim(payload->>'phone'), ''), '');
v_address text := coalesce(nullif(btrim(payload->>'address'), ''), '');
v_city text := coalesce(nullif(btrim(payload->>'city'), ''), '');
v_state text := coalesce(nullif(btrim(payload->>'state'), ''), '');
v_zip text := coalesce(nullif(btrim(payload->>'postal_code'), ''), '');
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
v_id bigint;
v_row jsonb;
BEGIN
IF v_name = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_supplier_create: el nombre del proveedor es obligatorio',
jsonb_build_object('fn', 'fn_supplier_create', 'field', 'name'));
END IF;
IF v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN
RETURN core.rpc_err('VALIDATION', format('fn_supplier_create: RFC %s no tiene 12 o 13 caracteres', v_rfc),
jsonb_build_object('fn', 'fn_supplier_create', 'field', 'rfc'));
END IF;
INSERT INTO suppliers (
tenant_id, name, legal_name, rfc, email, phone, address, city, state, postal_code, notes
) VALUES (
v_tid, v_name, v_legal, v_rfc, v_email, v_phone, v_address, v_city, v_state, v_zip, v_notes
) RETURNING id INTO v_id;
SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id;
RETURN core.rpc_created(
jsonb_build_object('supplier', v_row),
format('Proveedor %s registrado', v_name),
jsonb_build_object('fn', 'fn_supplier_create', 'id', v_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('Ya existe un proveedor con RFC %s en este tenant', v_rfc),
jsonb_build_object('fn', 'fn_supplier_create', 'rfc', v_rfc));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_supplier_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033h-fn-supplier-update splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_supplier_update(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_tid integer := core._cost_tenant_id(payload);
v_rfc text;
v_row jsonb;
BEGIN
IF v_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_supplier_update: id es obligatorio',
jsonb_build_object('fn', 'fn_supplier_update', 'field', 'id'));
END IF;
IF NOT EXISTS (SELECT 1 FROM suppliers WHERE id = v_id AND tenant_id = v_tid) THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_supplier_update: proveedor id=%s no encontrado', v_id),
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id));
END IF;
v_rfc := upper(regexp_replace(coalesce(payload->>'rfc', ''), '\s+', '', 'g'));
IF payload ? 'rfc' AND v_rfc <> '' AND char_length(v_rfc) NOT IN (12, 13) THEN
RETURN core.rpc_err('VALIDATION', format('fn_supplier_update: RFC %s no tiene 12 o 13 caracteres', v_rfc),
jsonb_build_object('fn', 'fn_supplier_update', 'field', 'rfc'));
END IF;
UPDATE suppliers SET
name = CASE WHEN payload ? 'name' THEN coalesce(nullif(btrim(payload->>'name'), ''), name) ELSE name END,
legal_name = CASE WHEN payload ? 'legal_name' THEN coalesce(nullif(btrim(payload->>'legal_name'), ''), '') ELSE legal_name END,
rfc = CASE WHEN payload ? 'rfc' THEN v_rfc ELSE rfc END,
email = CASE WHEN payload ? 'email' THEN coalesce(nullif(btrim(payload->>'email'), ''), '') ELSE email END,
phone = CASE WHEN payload ? 'phone' THEN coalesce(nullif(btrim(payload->>'phone'), ''), '') ELSE phone END,
address = CASE WHEN payload ? 'address' THEN coalesce(nullif(btrim(payload->>'address'), ''), '') ELSE address END,
city = CASE WHEN payload ? 'city' THEN coalesce(nullif(btrim(payload->>'city'), ''), '') ELSE city END,
state = CASE WHEN payload ? 'state' THEN coalesce(nullif(btrim(payload->>'state'), ''), '') ELSE state END,
postal_code = CASE WHEN payload ? 'postal_code' THEN coalesce(nullif(btrim(payload->>'postal_code'), ''), '') ELSE postal_code END,
notes = CASE WHEN payload ? 'notes' THEN coalesce(nullif(btrim(payload->>'notes'), ''), '') ELSE notes END,
status = CASE WHEN payload ? 'status' THEN coalesce(nullif(btrim(payload->>'status'), ''), status) ELSE status END,
updated_at = now()
WHERE id = v_id AND tenant_id = v_tid;
SELECT to_jsonb(s) INTO v_row FROM suppliers s WHERE id = v_id;
RETURN core.rpc_ok(
jsonb_build_object('supplier', v_row),
format('Proveedor id=%s actualizado', v_id),
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', 'Ya existe otro proveedor con ese RFC en este tenant',
jsonb_build_object('fn', 'fn_supplier_update', 'id', v_id));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_supplier_update', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033i-fn-measure-unit-list splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_measure_unit_list(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_rows jsonb;
BEGIN
PERFORM core._measure_units_seed(v_tid);
SELECT COALESCE(jsonb_agg(to_jsonb(u) ORDER BY u.sort_order, u.code), '[]'::jsonb)
INTO v_rows
FROM measure_units u
WHERE u.tenant_id = v_tid
AND u.status = 'activo';
RETURN core.rpc_ok(
jsonb_build_object('units', v_rows, 'count', jsonb_array_length(v_rows)),
format('Unidades de medida del tenant %s: %s registro(s)', v_tid, jsonb_array_length(v_rows)),
jsonb_build_object('fn', 'fn_measure_unit_list', 'tenant_id', v_tid, 'count', jsonb_array_length(v_rows))
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_measure_unit_list', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033j-fn-measure-unit-add splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_measure_unit_add(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_code text := lower(regexp_replace(btrim(COALESCE(payload->>'code', '')), '\s+', '', 'g'));
v_label text := COALESCE(NULLIF(btrim(payload->>'label'), ''), v_code);
v_id bigint;
v_row jsonb;
BEGIN
IF v_code = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_measure_unit_add: el código de unidad es obligatorio',
jsonb_build_object('fn', 'fn_measure_unit_add', 'field', 'code'));
END IF;
PERFORM core._measure_units_seed(v_tid);
INSERT INTO measure_units (tenant_id, code, label, sort_order)
VALUES (v_tid, v_code, v_label, 500)
RETURNING id INTO v_id;
SELECT to_jsonb(u) INTO v_row FROM measure_units u WHERE id = v_id;
RETURN core.rpc_created(
jsonb_build_object('unit', v_row),
format('Unidad de medida %s (%s) agregada', v_label, v_code),
jsonb_build_object('fn', 'fn_measure_unit_add', 'id', v_id, 'tenant_id', v_tid)
);
EXCEPTION
WHEN unique_violation THEN
RETURN core.rpc_err('CONFLICT', format('Ya existe la unidad %s en este tenant', v_code),
jsonb_build_object('fn', 'fn_measure_unit_add', 'code', v_code));
WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_measure_unit_add', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033k-fn-expense-create-supplier splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_expense_create(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._cost_tenant_id(payload);
v_company_id bigint := NULLIF(payload->>'company_id', '')::bigint;
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
v_expense_date date := COALESCE(NULLIF(payload->>'expense_date', '')::date, CURRENT_DATE);
v_description text := coalesce(nullif(btrim(payload->>'description'), ''), '');
v_budget_item_id bigint := NULLIF(payload->>'budget_item_id', '')::bigint;
v_budget_chapter_id bigint := NULLIF(payload->>'budget_chapter_id', '')::bigint;
v_quantity numeric := COALESCE((payload->>'quantity')::numeric, 0);
v_unit text := coalesce(nullif(btrim(payload->>'unit'), ''), '');
v_unit_price numeric := COALESCE((payload->>'unit_price')::numeric, 0);
v_subtotal_in numeric := COALESCE((payload->>'subtotal')::numeric, 0);
v_iva_rate numeric;
v_tax_included boolean := COALESCE((payload->>'tax_included')::boolean, false);
v_total_in numeric := (payload->>'total')::numeric;
v_subtotal numeric; v_iva_amount numeric; v_total numeric;
v_is_deductible boolean := COALESCE((payload->>'is_deductible')::boolean, true);
v_counts_toward_budget boolean := COALESCE((payload->>'counts_toward_budget')::boolean, true);
v_supplier_id bigint := NULLIF(payload->>'supplier_id', '')::bigint;
v_supplier_name text := coalesce(nullif(btrim(payload->>'supplier_name'), ''), '');
v_supplier_rfc text := upper(coalesce(nullif(btrim(payload->>'supplier_rfc'), ''), ''));
v_notes text := coalesce(nullif(btrim(payload->>'notes'), ''), '');
v_created_by_id integer := NULLIF(payload->>'created_by_id', '')::integer;
v_created_by_name text := coalesce(nullif(btrim(payload->>'created_by_name'), ''), '');
v_id bigint;
v_warning jsonb;
v_row jsonb;
BEGIN
IF v_company_id IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_create: company_id es obligatorio',
jsonb_build_object('fn', 'fn_expense_create', 'field', 'company_id'));
END IF;
IF v_description = '' THEN
RETURN core.rpc_err('VALIDATION', 'fn_expense_create: description es obligatoria',
jsonb_build_object('fn', 'fn_expense_create', 'field', 'description'));
END IF;
IF NOT EXISTS (SELECT 1 FROM companies WHERE id = v_company_id) THEN
RETURN core.rpc_err('VALIDATION', format('fn_expense_create: empresa id=%s no encontrada', v_company_id),
jsonb_build_object('fn', 'fn_expense_create', 'company_id', v_company_id));
END IF;
IF v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE id = v_project_id) THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proyecto id=%s no encontrado', v_project_id),
jsonb_build_object('fn', 'fn_expense_create', 'project_id', v_project_id));
END IF;
IF v_supplier_id IS NOT NULL THEN
SELECT name, rfc INTO v_supplier_name, v_supplier_rfc
FROM suppliers
WHERE id = v_supplier_id AND tenant_id = v_tid AND status = 'activo';
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', format('fn_expense_create: proveedor id=%s no encontrado', v_supplier_id),
jsonb_build_object('fn', 'fn_expense_create', 'supplier_id', v_supplier_id));
END IF;
END IF;
IF v_subtotal_in <= 0 AND v_quantity > 0 AND v_unit_price > 0 THEN
v_subtotal_in := round(v_quantity * v_unit_price, 2);
END IF;
SELECT default_iva_rate INTO v_iva_rate FROM core._cost_settings(v_tid);
v_iva_rate := COALESCE((payload->>'iva_rate')::numeric, v_iva_rate);
SELECT c.subtotal, c.iva_amount, c.total INTO v_subtotal, v_iva_amount, v_total
FROM core._cost_calc_amounts(v_subtotal_in, v_iva_rate, v_tax_included, v_total_in) c;
IF v_budget_item_id IS NOT NULL THEN
SELECT chapter_id INTO v_budget_chapter_id FROM budget_items WHERE id = v_budget_item_id;
END IF;
INSERT INTO expense_entries (
tenant_id, company_id, project_id, expense_date, source, description,
supplier_id, supplier_name, supplier_rfc, budget_chapter_id, budget_item_id,
quantity, unit, unit_price, subtotal, iva_rate, iva_amount, total,
tax_included, is_deductible, counts_toward_budget, status,
created_by_id, created_by_name, notes
) VALUES (
v_tid, v_company_id, v_project_id, v_expense_date, 'manual', v_description,
v_supplier_id, v_supplier_name, v_supplier_rfc, v_budget_chapter_id, v_budget_item_id,
v_quantity, v_unit, v_unit_price, v_subtotal, v_iva_rate, v_iva_amount, v_total,
v_tax_included, v_is_deductible, v_counts_toward_budget, 'confirmed',
v_created_by_id, v_created_by_name, v_notes
) RETURNING id INTO v_id;
IF v_counts_toward_budget AND v_budget_item_id IS NOT NULL THEN
v_warning := core._cost_budget_warning(v_tid, v_budget_item_id, 0, 0);
END IF;
SELECT to_jsonb(e) INTO v_row FROM expense_entries e WHERE id = v_id;
RETURN core.rpc_created(
jsonb_build_object('expense', v_row, 'budget_warning', v_warning),
format('Gasto registrado por %s (subtotal %s, IVA %s)', v_description, v_subtotal, v_iva_amount),
jsonb_build_object('fn', 'fn_expense_create', 'id', v_id)
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_expense_create', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-033l-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core._measure_units_seed(integer) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_supplier_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_supplier_create(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_supplier_update(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_measure_unit_list(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_measure_unit_add(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_expense_create(jsonb) TO panels_core_app;

View file

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

View file

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

View file

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

View file

@ -47,41 +47,82 @@
<Select v-model="form.project_id" :options="projects" option-label="name" option-value="id" show-clear placeholder="Gasto de empresa" class="w-full" />
</FormField>
<FormField label="Fecha" required>
<InputText v-model="form.expense_date" type="date" class="w-full" />
<DatePicker v-model="expenseDate" date-format="dd/mm/yy" show-icon class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker v-if="form.project_id" v-model="form.budget_item_id" :project-id="form.project_id" />
<BudgetItemPicker
v-if="form.project_id"
v-model="form.budget_item_id"
:project-id="form.project_id"
@select="onBudgetSelect"
/>
<InputText v-else disabled placeholder="Seleccione una obra para ligar concepto" class="w-full" />
</FormField>
<FormField label="Descripción" class="span-2" required>
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
</FormField>
<FormField label="Proveedor">
<InputText v-model="form.supplier_name" class="w-full" />
</FormField>
<FormField label="RFC proveedor">
<InputText v-model="form.supplier_rfc" class="w-full identifier-input" />
<FormField label="Proveedor" class="span-2">
<div class="flex gap-2 align-items-center">
<Select
v-model="form.supplier_id"
:options="supplierOptions"
option-label="label"
option-value="id"
placeholder="Seleccione proveedor"
filter
show-clear
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nuevo proveedor" title="Nuevo proveedor" @click="showSupplierForm = true" />
</div>
<small v-if="selectedSupplierRfc" class="muted">RFC {{ selectedSupplierRfc }}</small>
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
</FormField>
<FormField label="Unidad">
<InputText v-model="form.unit" class="w-full" />
<div class="flex gap-2 align-items-center">
<Select
v-model="form.unit"
:options="units"
option-label="label"
option-value="code"
placeholder="Seleccione unidad"
filter
show-clear
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nueva unidad" title="Nueva unidad" @click="showUnitForm = true" />
</div>
</FormField>
<FormField label="Precio unitario">
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
<FormField label="Subtotal">
<InputNumber v-model="form.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<InputNumber :model-value="computedAmounts.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="IVA incluido en total">
<Checkbox v-model="form.tax_included" binary />
<FormField label="IVA incluido en el precio">
<div class="flex align-items-center gap-2">
<Checkbox v-model="form.tax_included" binary input-id="tax-included" />
<label for="tax-included" class="muted">El precio ya trae IVA</label>
</div>
</FormField>
<FormField label="IVA">
<InputNumber :model-value="computedAmounts.ivaAmount" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="Total con IVA">
<InputNumber v-model="form.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
<InputNumber :model-value="computedAmounts.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" readonly />
</FormField>
<FormField label="Comprobante PDF" class="span-2">
<input type="file" accept="application/pdf" @change="onFile" />
<FileUpload
mode="basic"
:auto="false"
choose-label="Seleccionar PDF"
accept="application/pdf,.pdf"
custom-upload
@select="onPickReceipt"
/>
<div class="muted mt-1">{{ receiptFile ? receiptFile.name : "Ningún archivo seleccionado" }}</div>
</FormField>
</div>
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
@ -90,13 +131,33 @@
<Button label="Guardar" icon="pi pi-check" @click="save" />
</template>
</Dialog>
<SupplierFormDialog v-model="showSupplierForm" @saved="onSupplierSaved" />
<Dialog v-model:visible="showUnitForm" header="Nueva unidad" modal :style="{ width: '420px' }">
<div class="form-grid form-grid-compact">
<FormField label="Código" required>
<InputText v-model="unitForm.code" class="w-full identifier-input" placeholder="bulto" />
</FormField>
<FormField label="Nombre" required>
<InputText v-model="unitForm.label" class="w-full" placeholder="Bulto" />
</FormField>
</div>
<Message v-if="unitError" severity="error" class="mt-3" :closable="false">{{ unitError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showUnitForm = false" />
<Button label="Guardar" icon="pi pi-check" @click="saveUnit" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
import { displayErrorMessage } from "~/composables/api-response";
import { expenseAmounts } from "~/composables/expenseAmounts";
type Expense = {
id: number;
@ -121,10 +182,16 @@ const tableQ = ref("");
const expenses = ref<Expense[]>([]);
const companies = ref<{ id: number; name: string }[]>([]);
const projects = ref<{ id: number; name: string }[]>([]);
const suppliers = ref<Supplier[]>([]);
const units = ref<{ code: string; label: string }[]>([]);
const showForm = ref(false);
const showSupplierForm = ref(false);
const showUnitForm = ref(false);
const formError = ref("");
const unitError = ref("");
const budgetWarning = ref<{ level: string; message: string } | null>(null);
const receiptFile = ref<File | null>(null);
const ivaRate = ref(0.16);
const canCreateExpense = computed(() => perms.can("expenses.create"));
const form = reactive({
@ -133,16 +200,37 @@ const form = reactive({
expense_date: new Date().toISOString().slice(0, 10),
budget_item_id: null as number | null,
description: "",
supplier_name: "",
supplier_rfc: "",
supplier_id: null as number | null,
quantity: 0,
unit: "",
unit_price: 0,
subtotal: 0,
total: 0,
tax_included: false,
});
const unitForm = reactive({ code: "", label: "" });
const expenseDate = computed({
get: () => parseDate(form.expense_date),
set: (value: Date | null) => {
form.expense_date = isoDate(value) || new Date().toISOString().slice(0, 10);
},
});
const computedAmounts = computed(() =>
expenseAmounts(form.quantity, form.unit_price, form.tax_included, ivaRate.value),
);
const supplierOptions = computed(() =>
suppliers.value.map((s) => ({
...s,
label: s.rfc ? `${s.name} · ${s.rfc}` : s.name,
})),
);
const selectedSupplierRfc = computed(() =>
suppliers.value.find((s) => s.id === form.supplier_id)?.rfc || "",
);
const projectId = computed(() => Number(route.query.project_id || 0) || null);
const projectLabel = computed(() => {
const p = projects.value.find((x) => x.id === projectId.value);
@ -160,24 +248,46 @@ function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
function onFile(e: Event) {
const input = e.target as HTMLInputElement;
receiptFile.value = input.files?.[0] ?? null;
function parseDate(value: unknown): Date | null {
const raw = String(value || "").slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const [year, month, day] = raw.split("-").map(Number);
return new Date(year, month - 1, day);
}
function isoDate(value: Date | null) {
if (!value) return "";
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function onPickReceipt(event: { files: File[] }) {
receiptFile.value = event.files?.[0] || null;
}
function onBudgetSelect(item: { unit?: string } | null) {
const code = String(item?.unit || "").trim().toLowerCase();
if (code && units.value.some((u) => u.code === code)) form.unit = code;
}
function onSupplierSaved(supplier: Supplier) {
suppliers.value = [...suppliers.value.filter((s) => s.id !== supplier.id), supplier]
.sort((a, b) => a.name.localeCompare(b.name, "es"));
form.supplier_id = supplier.id;
}
function resetForm() {
form.company_id = companies.value[0]?.id ?? null;
form.project_id = projectId.value;
form.expense_date = new Date().toISOString().slice(0, 10);
form.expense_date = isoDate(new Date());
form.budget_item_id = Number(route.query.budget_item_id || 0) || null;
form.description = "";
form.supplier_name = "";
form.supplier_rfc = "";
form.supplier_id = null;
form.quantity = 0;
form.unit = "";
form.unit_price = 0;
form.subtotal = 0;
form.total = 0;
form.tax_included = false;
receiptFile.value = null;
formError.value = "";
@ -239,6 +349,49 @@ async function loadCatalogs() {
companies.value = cat.companies ?? [];
projects.value = pr.projects ?? [];
if (!form.company_id && companies.value[0]) form.company_id = companies.value[0].id;
try {
const sup = await api<{ suppliers?: Supplier[]; data?: { suppliers?: Supplier[] } }>("/v1/suppliers?status=activo");
suppliers.value = sup.suppliers ?? sup.data?.suppliers ?? [];
} catch {
suppliers.value = [];
}
try {
const un = await api<{ units?: { code: string; label: string }[]; data?: { units?: { code: string; label: string }[] } }>("/v1/measure-units");
units.value = un.units ?? un.data?.units ?? [];
} catch {
units.value = [];
}
try {
const settings = await api<{ default_iva_rate?: number; data?: { default_iva_rate?: number } }>("/v1/cost-settings");
ivaRate.value = Number(settings.default_iva_rate ?? settings.data?.default_iva_rate ?? 0.16) || 0.16;
} catch {
ivaRate.value = 0.16;
}
}
async function saveUnit() {
unitError.value = "";
if (!unitForm.code.trim()) {
unitError.value = "El código de unidad es obligatorio";
return;
}
try {
const r = await api<{ unit?: { code: string; label: string }; data?: { unit?: { code: string; label: string } } }>(
"/v1/measure-units",
{ method: "POST", json: { code: unitForm.code, label: unitForm.label } },
);
const unit = r.unit ?? r.data?.unit;
if (unit) {
units.value = [...units.value.filter((u) => u.code !== unit.code), unit]
.sort((a, b) => a.label.localeCompare(b.label, "es"));
form.unit = unit.code;
}
unitForm.code = "";
unitForm.label = "";
showUnitForm.value = false;
} catch (e) {
unitError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
}
async function loadExpenses() {
@ -259,7 +412,13 @@ async function save() {
"/v1/expenses",
{
method: "POST",
json: { ...form, project_id: form.project_id || null },
json: {
...form,
project_id: form.project_id || null,
subtotal: computedAmounts.value.subtotal,
total: computedAmounts.value.total,
iva_rate: ivaRate.value,
},
},
);
const expenseId = r.data?.expense?.id;