panels-origin/db/core/changesets/050-imss-wage.sql
Alberto Martinez 90bcd48f95 Facturas, nómina, dispersión e IMSS (#28)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Resumen

Implementa las observaciones de facturas, nómina e IMSS sobre el catálogo de proveedores y la nómina semanal que ya existían.

## Qué queda disponible

- Proveedores: régimen fiscal, código postal, estatus activo/inactivo y filtro por nombre o RFC.
- Facturas recibidas y emitidas: Excel, UUID, revisión si el RFC no está en el catálogo, clasificación a obra/concepto y gasto `source = invoice` solo para la recibida clasificada.
- Nómina: bono, descuento con observación, horas y día festivo dentro del armado semanal. La semana pagada no acepta cambios.
- Préstamos: edición (libre sin pagos; con pagos solo nota y cuota) y estado de cuenta con saldo = entregado + comisión − pagos.
- Finiquitos y liquidaciones: conceptos, neto descontando descuentos vigentes y saldo de préstamo, y gasto al pagar.
- Dispersión: corte semanal, corrección antes de definitiva y comprobante.
- Historial de alta, baja y reingreso en la ficha, más movimientos IMSS.
- Reporte por periodo en Excel y PDF.
- Jornal IMSS distinto del jornal real y consulta de cuotas exportable a Excel.

## Verificación

`deno check` de la API pasó. En este entorno no hay Postgres, así que `api/pendientes_test.ts` queda listo para correr cuando la base tenga las migraciones 042–050 y el permiso IAM 007.

<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-5e23dc3b-921e-4091-9650-7091d3a52bbf?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-5e23dc3b-921e-4091-9650-7091d3a52bbf&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-25 03:26:30 +00:00

129 lines
7 KiB
PL/PgSQL

--liquibase formatted sql
-- PANELS · core · jornal IMSS y consulta de cuotas
--changeset panel:core-050a-columns endDelimiter:; splitStatements:true
ALTER TABLE core.workers ADD COLUMN IF NOT EXISTS imss_daily_wage NUMERIC(12,2);
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_enfermedad NUMERIC(8,6) NOT NULL DEFAULT 0.020000;
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_invalidez NUMERIC(8,6) NOT NULL DEFAULT 0.017500;
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_retiro NUMERIC(8,6) NOT NULL DEFAULT 0.020000;
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_cesantia NUMERIC(8,6) NOT NULL DEFAULT 0.031500;
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_infonavit NUMERIC(8,6) NOT NULL DEFAULT 0.050000;
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS imss_rate_riesgo NUMERIC(8,6) NOT NULL DEFAULT 0.005433;
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS settlement_aguinaldo_days NUMERIC(6,2) NOT NULL DEFAULT 15;
ALTER TABLE core.payroll_settings ADD COLUMN IF NOT EXISTS settlement_prima_pct NUMERIC(6,2) NOT NULL DEFAULT 25;
--changeset panel:core-050b-fn-settings-save splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_payroll_settings_save(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_cur payroll_settings%ROWTYPE;
v_settings jsonb;
BEGIN
PERFORM core._payroll_seed_defaults(v_tid);
SELECT * INTO v_cur FROM payroll_settings WHERE tenant_id = v_tid;
UPDATE payroll_settings SET
loan_commission_enabled = COALESCE((payload->>'loan_commission_enabled')::boolean, v_cur.loan_commission_enabled),
loan_commission_pct = COALESCE((payload->>'loan_commission_pct')::numeric, v_cur.loan_commission_pct, 0),
loan_small_max = COALESCE((payload->>'loan_small_max')::numeric, v_cur.loan_small_max, 0),
imss_rate_enfermedad = COALESCE((payload->>'imss_rate_enfermedad')::numeric, v_cur.imss_rate_enfermedad),
imss_rate_invalidez = COALESCE((payload->>'imss_rate_invalidez')::numeric, v_cur.imss_rate_invalidez),
imss_rate_retiro = COALESCE((payload->>'imss_rate_retiro')::numeric, v_cur.imss_rate_retiro),
imss_rate_cesantia = COALESCE((payload->>'imss_rate_cesantia')::numeric, v_cur.imss_rate_cesantia),
imss_rate_infonavit = COALESCE((payload->>'imss_rate_infonavit')::numeric, v_cur.imss_rate_infonavit),
imss_rate_riesgo = COALESCE((payload->>'imss_rate_riesgo')::numeric, v_cur.imss_rate_riesgo),
settlement_aguinaldo_days = COALESCE((payload->>'settlement_aguinaldo_days')::numeric, v_cur.settlement_aguinaldo_days),
settlement_prima_pct = COALESCE((payload->>'settlement_prima_pct')::numeric, v_cur.settlement_prima_pct)
WHERE tenant_id = v_tid;
SELECT to_jsonb(s) INTO v_settings FROM payroll_settings s WHERE tenant_id = v_tid;
RETURN core.rpc_ok(jsonb_build_object('settings', v_settings), 'Configuración de nómina guardada',
jsonb_build_object('fn', 'fn_payroll_settings_save', 'tenant_id', v_tid));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_payroll_settings_save', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-050c-fn-imss-quote splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_imss_quote(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_tid integer := core._payroll_tenant_id(payload);
v_worker_id bigint := NULLIF(payload->>'worker_id', '')::bigint;
v_from date := NULLIF(payload->>'date_from', '')::date;
v_to date := NULLIF(payload->>'date_to', '')::date;
v_settings payroll_settings%ROWTYPE;
v_rows jsonb;
BEGIN
IF v_from IS NULL OR v_to IS NULL THEN
RETURN core.rpc_err('VALIDATION', 'fn_imss_quote: indique el periodo',
jsonb_build_object('fn', 'fn_imss_quote'));
END IF;
PERFORM core._payroll_seed_defaults(v_tid);
SELECT * INTO v_settings FROM payroll_settings WHERE tenant_id = v_tid;
SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY x.last_name_p), '[]'::jsonb) INTO v_rows
FROM (
SELECT w.id AS worker_id, w.first_name, w.last_name_p, w.nss, w.daily_wage,
w.imss_daily_wage,
COALESCE(w.imss_daily_wage, w.daily_wage) AS wage_used,
(w.imss_daily_wage IS NULL) AS used_real_wage,
COALESCE(d.days, 0) AS days,
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage)) AS sbc,
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_enfermedad) AS cuota_enfermedad,
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_invalidez) AS cuota_invalidez,
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_retiro) AS cuota_retiro,
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_cesantia) AS cuota_cesantia,
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_infonavit) AS cuota_infonavit,
core.round_money(COALESCE(d.days, 0) * COALESCE(w.imss_daily_wage, w.daily_wage) * v_settings.imss_rate_riesgo) AS cuota_riesgo
FROM workers w
LEFT JOIN (
SELECT worker_id, COUNT(*)::numeric AS days
FROM attendance
WHERE present = true AND work_date BETWEEN v_from AND v_to
GROUP BY worker_id
) d ON d.worker_id = w.id
WHERE w.tenant_id = v_tid
AND (v_worker_id IS NULL OR w.id = v_worker_id)
) x;
RETURN core.rpc_ok(
jsonb_build_object('quotes', v_rows, 'rates', to_jsonb(v_settings)),
'Consulta de cuotas IMSS',
jsonb_build_object('fn', 'fn_imss_quote')
);
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_imss_quote', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-050d-fn-imss-wage splitStatements:false
CREATE OR REPLACE FUNCTION core.fn_worker_imss_wage_set(payload jsonb)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = core
AS $$
DECLARE
v_id bigint := NULLIF(payload->>'id', '')::bigint;
v_wage numeric := NULLIF(payload->>'imss_daily_wage', '')::numeric;
BEGIN
UPDATE workers SET imss_daily_wage = v_wage, updated_at = now() WHERE id = v_id;
IF NOT FOUND THEN
RETURN core.rpc_err('NOT_FOUND', 'Trabajador no encontrado', jsonb_build_object('fn', 'fn_worker_imss_wage_set', 'id', v_id));
END IF;
RETURN core.rpc_ok(jsonb_build_object('id', v_id, 'imss_daily_wage', v_wage), 'Jornal IMSS actualizado',
jsonb_build_object('fn', 'fn_worker_imss_wage_set', 'id', v_id));
EXCEPTION WHEN OTHERS THEN
RETURN core.rpc_from_exception('fn_worker_imss_wage_set', SQLSTATE, SQLERRM);
END;
$$;
--changeset panel:core-050e-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core.fn_imss_quote(jsonb) TO panels_core_app;
GRANT EXECUTE ON FUNCTION core.fn_worker_imss_wage_set(jsonb) TO panels_core_app;