mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 13:23:17 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
Migrates the `core` schema business logic from inline `db.prepare()` calls in the API to PostgreSQL RPC functions (`core.fn_*`) with a unified JSON envelope for errors and HTTP status mapping.
### Database (Liquibase changesets 006–017)
- **006** — RPC infra: `rpc_ok`, `rpc_err`, `rpc_created`, `rpc_from_exception`
- **007** — Catalogs and tenant settings
- **008** — Companies CRUD
- **009** — Projects, checklists, document lists
- **010** — Workers CRUD, pipeline, checklist, assign
- **011** — Budget CRUD + `fn_budget_replace`
- **012** — Badge jobs
- **013** — Payroll (settings, attendance, loans, weeks, destajo)
- **014** — Worker import batch + document store
- **015** — Project/company/worker document metadata RPCs
- **016–017** — Fixes: `needs_badge` default on worker create; Liquibase `splitStatements:false` on function changesets
### API
- `api/rpc.ts` — `callCoreFn()`, `RpcCallError` (jsonb payload fix: pass JS object, not `JSON.stringify`)
- `api/http_errors.ts` — `mapRpcToStatus()`, `respondRpc()`, `respondApiError()`, `onAppError()`
- Refactored: `main.ts`, `companies.ts`, `db.ts`, `budget.ts`, `excel.ts`, `payroll.ts`, `payroll_http.ts`
- Front helpers: `web-panel/composables/api-response.ts`, `web-saas/composables/api-response.ts`
### Envelope contract
DB functions return `{ ok, code, layer: "db", message, context, data, errors }`. The API adds `status` (HTTP code) via `respondRpc()` / `respondApiError()`.
### Out of scope
`iam`, `platform`, `saas.ts`, auth/sessions, S3, PDF generation, Excel parsing, and bootstrap scripts still use direct SQL where appropriate.
## Test plan
- [x] `deno check main.ts` — compila sin errores de tipos
- [x] `npm run build` — web-panel y web-saas compilan
- [x] `deno test` — 25 tests unitarios (http_errors, companies, budget, mx, document_validity)
- [x] Liquibase migrations `006`–`017` aplicadas en Postgres local (`--context-filter=dev`)
- [x] API levantada localmente; `/v1/health` OK
- [x] Smoke CRUD vía `scripts/crud-smoke-test.sh`: empresas, proyectos, trabajadores, catálogos (create/get/list/patch)
- [ ] Import Excel de trabajadores (flujo multipart + S3/local storage)
- [ ] Import presupuesto desde Excel
- [ ] Flujo nómina: asistencia → cerrar semana
- [ ] CI en el remoto (sin checks reportados aún)
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7?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> <a href="https://cursor.com/background-agent?bcId=bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7&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> </div>
200 lines
6.7 KiB
PL/PgSQL
200 lines
6.7 KiB
PL/PgSQL
--liquibase formatted sql
|
|
-- PANELS · core · fix needs_badge cast en fn_worker_create/update
|
|
|
|
--changeset panel:core-016a-fn-worker-needs-badge splitStatements:false
|
|
CREATE OR REPLACE FUNCTION core.fn_worker_create(payload jsonb)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path = core
|
|
AS $$
|
|
DECLARE
|
|
v_tid integer := COALESCE(
|
|
NULLIF(payload->>'tenant_id', '')::integer,
|
|
NULLIF(current_setting('app.tenant_id', true), '')::integer
|
|
);
|
|
v_project_id bigint := NULLIF(payload->>'project_id', '')::bigint;
|
|
v_errors jsonb;
|
|
v_norm jsonb;
|
|
v_existing workers%ROWTYPE;
|
|
v_row workers%ROWTYPE;
|
|
v_project_status text;
|
|
v_refresh jsonb;
|
|
BEGIN
|
|
v_errors := core._worker_validate_fields(payload);
|
|
IF v_errors <> '{}'::jsonb THEN
|
|
RETURN core.rpc_err(
|
|
'VALIDATION',
|
|
'fn_worker_create: datos del trabajador incompletos o inválidos',
|
|
jsonb_build_object('fn', 'fn_worker_create', 'field', 'payload'),
|
|
v_errors
|
|
);
|
|
END IF;
|
|
v_norm := core._worker_normalize(payload);
|
|
SELECT * INTO v_existing
|
|
FROM workers
|
|
WHERE curp = v_norm->>'curp'
|
|
OR rfc = v_norm->>'rfc'
|
|
OR nss = v_norm->>'nss'
|
|
LIMIT 1;
|
|
IF FOUND THEN
|
|
RETURN core.rpc_err(
|
|
'CONFLICT',
|
|
core._worker_conflict_message(v_existing, v_norm->>'curp', v_norm->>'rfc', v_norm->>'nss'),
|
|
jsonb_build_object('fn', 'fn_worker_create', 'worker_id', v_existing.id)
|
|
);
|
|
END IF;
|
|
IF v_project_id IS NOT NULL THEN
|
|
SELECT status INTO v_project_status FROM projects WHERE id = v_project_id;
|
|
IF NOT FOUND THEN
|
|
RETURN core.rpc_err(
|
|
'NOT_FOUND',
|
|
format('fn_worker_create: proyecto id=%s no encontrado', v_project_id),
|
|
jsonb_build_object('fn', 'fn_worker_create', 'project_id', v_project_id)
|
|
);
|
|
END IF;
|
|
IF v_project_status <> 'activo' THEN
|
|
RETURN core.rpc_err(
|
|
'VALIDATION',
|
|
'fn_worker_create: solo se asigna personal a proyectos activos',
|
|
jsonb_build_object('fn', 'fn_worker_create', 'project_id', v_project_id, 'status', v_project_status)
|
|
);
|
|
END IF;
|
|
END IF;
|
|
INSERT INTO workers (
|
|
first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address,
|
|
blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id
|
|
) VALUES (
|
|
v_norm->>'first_name',
|
|
v_norm->>'middle_name',
|
|
v_norm->>'last_name_p',
|
|
v_norm->>'last_name_m',
|
|
v_norm->>'curp',
|
|
v_norm->>'rfc',
|
|
v_norm->>'nss',
|
|
v_norm->>'phone',
|
|
v_norm->>'email',
|
|
v_norm->>'address',
|
|
v_norm->>'blood_type',
|
|
'',
|
|
NULL,
|
|
v_norm->>'position',
|
|
v_norm->>'risk_code',
|
|
v_norm->>'work_type',
|
|
(v_norm->>'daily_wage')::numeric(12,2),
|
|
COALESCE((v_norm->'needs_badge')::boolean, true),
|
|
v_norm->>'status',
|
|
v_tid
|
|
)
|
|
RETURNING * INTO v_row;
|
|
IF v_project_id IS NOT NULL THEN
|
|
INSERT INTO assignments (worker_id, project_id, active, start_date)
|
|
VALUES (v_row.id, v_project_id, true, current_date)
|
|
ON CONFLICT (worker_id, project_id) DO UPDATE SET
|
|
active = true,
|
|
start_date = CASE
|
|
WHEN assignments.active = false THEN excluded.start_date
|
|
ELSE assignments.start_date
|
|
END,
|
|
end_date = NULL;
|
|
END IF;
|
|
v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object('worker_id', v_row.id, 'tenant_id', v_tid));
|
|
RETURN core.rpc_created(
|
|
jsonb_build_object('worker', to_jsonb(v_row), 'pipeline', v_refresh->'data'),
|
|
format('Trabajador %s %s creado (id=%s)', v_row.first_name, v_row.last_name_p, v_row.id),
|
|
jsonb_build_object('fn', 'fn_worker_create', 'id', v_row.id)
|
|
);
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_worker_create', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION core.fn_worker_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 := COALESCE(
|
|
NULLIF(payload->>'tenant_id', '')::integer,
|
|
NULLIF(current_setting('app.tenant_id', true), '')::integer
|
|
);
|
|
v_cur workers%ROWTYPE;
|
|
v_merged jsonb;
|
|
v_errors jsonb;
|
|
v_norm jsonb;
|
|
v_existing workers%ROWTYPE;
|
|
v_refresh jsonb;
|
|
BEGIN
|
|
IF v_id IS NULL THEN
|
|
RETURN core.rpc_err(
|
|
'VALIDATION',
|
|
'fn_worker_update: id es obligatorio para actualizar un trabajador',
|
|
jsonb_build_object('fn', 'fn_worker_update', 'field', 'id')
|
|
);
|
|
END IF;
|
|
SELECT * INTO v_cur FROM workers WHERE id = v_id;
|
|
IF NOT FOUND THEN
|
|
RETURN core.rpc_err(
|
|
'NOT_FOUND',
|
|
format('fn_worker_update: trabajador id=%s no encontrado', v_id),
|
|
jsonb_build_object('fn', 'fn_worker_update', 'id', v_id)
|
|
);
|
|
END IF;
|
|
v_merged := to_jsonb(v_cur) || payload;
|
|
v_errors := core._worker_validate_fields(v_merged);
|
|
IF v_errors <> '{}'::jsonb THEN
|
|
RETURN core.rpc_err(
|
|
'VALIDATION',
|
|
format('fn_worker_update: datos del trabajador inválidos (id=%s)', v_id),
|
|
jsonb_build_object('fn', 'fn_worker_update', 'id', v_id),
|
|
v_errors
|
|
);
|
|
END IF;
|
|
v_norm := core._worker_normalize(v_merged);
|
|
SELECT * INTO v_existing
|
|
FROM workers
|
|
WHERE (curp = v_norm->>'curp' OR rfc = v_norm->>'rfc' OR nss = v_norm->>'nss')
|
|
AND id <> v_id
|
|
LIMIT 1;
|
|
IF FOUND THEN
|
|
RETURN core.rpc_err(
|
|
'CONFLICT',
|
|
core._worker_conflict_message(v_existing, v_norm->>'curp', v_norm->>'rfc', v_norm->>'nss'),
|
|
jsonb_build_object('fn', 'fn_worker_update', 'id', v_id, 'worker_id', v_existing.id)
|
|
);
|
|
END IF;
|
|
UPDATE workers SET
|
|
first_name = v_norm->>'first_name',
|
|
middle_name = v_norm->>'middle_name',
|
|
last_name_p = v_norm->>'last_name_p',
|
|
last_name_m = v_norm->>'last_name_m',
|
|
curp = v_norm->>'curp',
|
|
rfc = v_norm->>'rfc',
|
|
nss = v_norm->>'nss',
|
|
phone = v_norm->>'phone',
|
|
email = v_norm->>'email',
|
|
address = v_norm->>'address',
|
|
blood_type = v_norm->>'blood_type',
|
|
hire_type = v_cur.hire_type,
|
|
company_id = v_cur.company_id,
|
|
position = v_norm->>'position',
|
|
risk_code = v_norm->>'risk_code',
|
|
work_type = v_norm->>'work_type',
|
|
daily_wage = (v_norm->>'daily_wage')::numeric(12,2),
|
|
needs_badge = COALESCE((v_norm->'needs_badge')::boolean, v_cur.needs_badge, true),
|
|
status = v_norm->>'status'
|
|
WHERE id = v_id
|
|
RETURNING * INTO v_cur;
|
|
v_refresh := core.fn_worker_refresh_pipeline(jsonb_build_object('worker_id', v_id, 'tenant_id', v_tid));
|
|
RETURN core.rpc_ok(
|
|
jsonb_build_object('worker', to_jsonb(v_cur), 'pipeline', v_refresh->'data'),
|
|
format('Trabajador %s %s actualizado (id=%s)', v_cur.first_name, v_cur.last_name_p, v_id),
|
|
jsonb_build_object('fn', 'fn_worker_update', 'id', v_id)
|
|
);
|
|
EXCEPTION WHEN OTHERS THEN
|
|
RETURN core.rpc_from_exception('fn_worker_update', SQLSTATE, SQLERRM);
|
|
END;
|
|
$$;
|