panels-origin/api/companies.ts
Alberto Martinez b9bf7044b2 refactor(core): migrate core CRUD to PostgreSQL RPC + unified HTTP errors
<!-- 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>&nbsp;<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>&nbsp;</div>
2026-09-04 02:34:43 +00:00

211 lines
6.8 KiB
TypeScript

import type { Db } from "./db.ts";
import { callCoreFn, RpcCallError } from "./rpc.ts";
import type { RpcEnvelope } from "./http_errors.ts";
import { mapRpcToStatus } from "./http_errors.ts";
export type Company = {
id: number;
code: string;
name: string;
parent_id: number | null;
kind: "principal" | "sub";
status: "activo" | "inactivo";
tenant_id?: number | null;
registro_patronal?: string;
razon_social?: string;
nombre_comercial?: string;
rfc?: string;
regimen_fiscal?: string;
clase_riesgo?: string;
domicilio_fiscal?: string;
codigo_postal?: string;
ciudad?: string;
estado?: string;
telefono?: string;
email?: string;
representante_legal?: string;
giro?: string;
created_at?: string;
parent_code?: string | null;
parent_name?: string | null;
worker_count?: number;
};
export type CompanyProfileInput = {
name?: string;
code?: string;
status?: string;
parent_id?: number | null;
registro_patronal?: string;
razon_social?: string;
nombre_comercial?: string;
rfc?: string;
regimen_fiscal?: string;
clase_riesgo?: string;
domicilio_fiscal?: string;
codigo_postal?: string;
ciudad?: string;
estado?: string;
telefono?: string;
email?: string;
representante_legal?: string;
giro?: string;
};
const CODE_RE = /^[A-Z0-9][A-Z0-9_-]{1,15}$/;
const RFC_RE = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
function trimText(value: unknown): string {
return (value ?? "").toString().trim();
}
function normRfc(value: unknown): string {
return trimText(value).toUpperCase().replace(/\s+/g, "");
}
function envelopeError<T>(env: RpcEnvelope<T>): { error: string; status: number } {
return { error: env.message, status: mapRpcToStatus(String(env.code)) };
}
export async function listCompanies(database: Db, tenantId?: number | null): Promise<Company[]> {
const env = await callCoreFn<{ companies: Company[] }>(database, "core.fn_company_list", {
tenant_id: tenantId ?? null,
});
if (!env.ok) throw new RpcCallError(env);
return (env.data?.companies ?? []) as Company[];
}
export async function companyById(database: Db, id: number): Promise<Company | undefined> {
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get", { id });
if (!env.ok) return undefined;
return env.data?.company as Company | undefined;
}
export async function companyByCode(database: Db, code: string): Promise<Company | undefined> {
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_get_by_code", {
code: normCompanyCode(code),
});
if (!env.ok) return undefined;
return env.data?.company as Company | undefined;
}
export async function principalCompany(database: Db, tenantId?: number | null): Promise<Company | undefined> {
const companies = await listCompanies(database, tenantId);
return companies.find((c) => c.kind === "principal");
}
export function normCompanyCode(value: string | null | undefined): string {
return (value ?? "").toString().trim().toUpperCase().replace(/\s+/g, "");
}
export function companyCodeFromName(name: string): string {
const slug = name
.normalize("NFD")
.replace(/\p{M}/gu, "")
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "")
.slice(0, 12);
return slug || "EMP";
}
export async function nextCompanyCode(database: Db, name: string): Promise<string> {
const env = await callCoreFn<{ code: string }>(database, "core.fn_next_company_code", { name });
if (!env.ok) throw new RpcCallError(env);
return String(env.data?.code ?? companyCodeFromName(name));
}
export async function resolveCompany(
database: Db,
body: { company_id?: number | null; hire_type?: string | null },
): Promise<Company | undefined> {
if (body.company_id) {
const byId = await companyById(database, Number(body.company_id));
if (byId) return byId;
}
const code = normCompanyCode(body.hire_type);
if (code) return await companyByCode(database, code);
return undefined;
}
export function validateCompanyCode(code: string): string | null {
if (!CODE_RE.test(code)) {
return "Código de 2 a 16 caracteres (letras, números, _ o -)";
}
return null;
}
export function validateCompanyProfile(
input: CompanyProfileInput,
opts: { requireLegal?: boolean } = {},
): string | null {
const rfc = normRfc(input.rfc);
if (rfc && !RFC_RE.test(rfc)) {
return "RFC inválido (formato mexicano de 12 o 13 caracteres)";
}
if (opts.requireLegal) {
if (!trimText(input.razon_social) && !trimText(input.name) && !trimText(input.nombre_comercial)) {
return "Indique razón social o nombre comercial";
}
}
const clase = trimText(input.clase_riesgo).toUpperCase();
if (clase && !["I", "II", "III", "IV", "V"].includes(clase)) {
return "Clase de riesgo IMSS debe ser I, II, III, IV o V";
}
return null;
}
export function normalizeCompanyProfile(input: CompanyProfileInput, fallbackName = "") {
const nombreComercial = trimText(input.nombre_comercial) || trimText(input.name) || fallbackName;
const razonSocial = trimText(input.razon_social) || nombreComercial || fallbackName;
const name = trimText(input.name) || nombreComercial || razonSocial || fallbackName;
return {
name,
nombre_comercial: nombreComercial,
razon_social: razonSocial,
rfc: normRfc(input.rfc),
regimen_fiscal: trimText(input.regimen_fiscal),
registro_patronal: trimText(input.registro_patronal).toUpperCase(),
clase_riesgo: trimText(input.clase_riesgo).toUpperCase(),
domicilio_fiscal: trimText(input.domicilio_fiscal),
codigo_postal: trimText(input.codigo_postal),
ciudad: trimText(input.ciudad),
estado: trimText(input.estado),
telefono: trimText(input.telefono),
email: trimText(input.email).toLowerCase(),
representante_legal: trimText(input.representante_legal),
giro: trimText(input.giro),
};
}
export async function createSubcompany(
database: Db,
input: CompanyProfileInput,
tenantId?: number | null,
): Promise<{ company?: Company; error?: string; status?: number }> {
const profileErr = validateCompanyProfile(input, { requireLegal: true });
if (profileErr) return { error: profileErr, status: 400 };
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_create", {
...input,
tenant_id: tenantId ?? null,
});
if (!env.ok) return envelopeError(env);
return { company: env.data?.company as Company };
}
export async function updateCompany(
database: Db,
id: number,
input: CompanyProfileInput,
tenantId?: number | null,
): Promise<{ company?: Company; error?: string; status?: 400 | 404 }> {
const env = await callCoreFn<{ company: Company }>(database, "core.fn_company_update", {
id,
tenant_id: tenantId ?? null,
...input,
});
if (!env.ok) {
const st = mapRpcToStatus(String(env.code));
return { error: env.message, status: st === 404 ? 404 : 400 };
}
return { company: env.data?.company as Company };
}