mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 19:43:18 +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>
240 lines
9.6 KiB
TypeScript
240 lines
9.6 KiB
TypeScript
import type { Db } from "./db.ts";
|
|
import { tenantTimezone, todayInTimezone } from "./db.ts";
|
|
|
|
export const DEFAULT_UNITS = [
|
|
{ code: "m", label: "Metro (m)" },
|
|
{ code: "m2", label: "Metro cuadrado (m²)" },
|
|
{ code: "m3", label: "Metro cúbico (m³)" },
|
|
{ code: "ml", label: "Metro lineal (ml)" },
|
|
{ code: "pza", label: "Pieza" },
|
|
] as const;
|
|
|
|
export type WeekStatus = "draft" | "assembled" | "paid";
|
|
|
|
export type PayrollSettings = {
|
|
tenant_id: number;
|
|
loan_commission_enabled: boolean;
|
|
loan_commission_pct: number;
|
|
loan_small_max: number;
|
|
};
|
|
|
|
type LoanRow = {
|
|
id: number;
|
|
worker_id: number;
|
|
amount: number;
|
|
delivered: number;
|
|
balance: number;
|
|
weekly_payment: number;
|
|
commission_pct: number;
|
|
commission_amount: number;
|
|
plan: string;
|
|
installments_n: number;
|
|
first_due: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
export function roundMoney(n: number): number {
|
|
return Math.round((Number(n) || 0) * 100) / 100;
|
|
}
|
|
|
|
export function commissionAmount(delivered: number, pct: number): number {
|
|
return roundMoney(delivered * (Number(pct) || 0) / 100);
|
|
}
|
|
|
|
/** "Hoy" en una zona horaria dada (Fase 4d: por tenant, ya no PAYROLL_TZ
|
|
* hardcodeado). Se mantiene aquí como alias delgado sobre
|
|
* db.ts#todayInTimezone para no duplicar la lógica. */
|
|
export function todayIso(timezone: string, now = new Date()): string {
|
|
return todayInTimezone(timezone, now);
|
|
}
|
|
|
|
/** Resuelve "hoy" para un tenant consultando su configuración. Punto de
|
|
* entrada único para los endpoints que antes usaban `todayIso()` sin
|
|
* argumentos (implícitamente America/Cancun para todos los tenants). */
|
|
export async function resolveToday(db: Db, tenantId: number): Promise<string> {
|
|
const tz = await tenantTimezone(db, tenantId);
|
|
return todayInTimezone(tz);
|
|
}
|
|
|
|
export function addDays(iso: string, days: number): string {
|
|
const [y, m, d] = iso.split("-").map(Number);
|
|
const dt = new Date(Date.UTC(y, m - 1, d + days));
|
|
return dt.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function weekdayMon0(iso: string): number {
|
|
const [y, m, d] = iso.split("-").map(Number);
|
|
const js = new Date(Date.UTC(y, m - 1, d)).getUTCDay();
|
|
return (js + 6) % 7;
|
|
}
|
|
|
|
export function weekContaining(iso: string): { weekStart: string; weekEnd: string } {
|
|
const weekStart = addDays(iso, -weekdayMon0(iso));
|
|
return { weekStart, weekEnd: addDays(weekStart, 5) };
|
|
}
|
|
|
|
export function destajoPeriodBounds(weekStart: string): { periodStart: string; periodEnd: string } {
|
|
const periodEnd = addDays(weekStart, 3);
|
|
return { periodStart: addDays(periodEnd, -7), periodEnd };
|
|
}
|
|
|
|
export function thisSaturday(iso: string): string {
|
|
return weekContaining(iso).weekEnd;
|
|
}
|
|
|
|
export function nextSaturdayAfterThisWeek(iso: string): string {
|
|
return addDays(thisSaturday(iso), 7);
|
|
}
|
|
|
|
export function weekDays(weekStart: string): string[] {
|
|
return [0, 1, 2, 3, 4, 5].map((n) => addDays(weekStart, n));
|
|
}
|
|
|
|
export function weekAssembleDate(weekStart: string): string {
|
|
return addDays(weekStart, 3);
|
|
}
|
|
|
|
export function canAssembleWeek(weekStart: string, today: string): boolean {
|
|
return today >= weekAssembleDate(weekStart);
|
|
}
|
|
|
|
export function tenantKey(tenantId: number | null | undefined): number {
|
|
return tenantId ?? 0;
|
|
}
|
|
|
|
export function moneyLabel(n: number): string {
|
|
return `$${roundMoney(n).toLocaleString("es-MX", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
export function loanConditionLabel(
|
|
loan: { delivered: number; commission_pct: number; installments_n: number },
|
|
payAmount: number,
|
|
installmentN: number,
|
|
): string {
|
|
const comm = loan.commission_pct > 0
|
|
? `préstamo ${moneyLabel(loan.delivered)} + comisión ${loan.commission_pct}%`
|
|
: `préstamo ${moneyLabel(loan.delivered)}`;
|
|
return `${moneyLabel(payAmount)} · ${installmentN}/${loan.installments_n || 1} · ${comm}`;
|
|
}
|
|
|
|
import { callCoreFn } from "./rpc.ts";
|
|
import type { RpcEnvelope } from "./http_errors.ts";
|
|
import { mapRpcToStatus } from "./http_errors.ts";
|
|
|
|
function throwFromRpc(env: RpcEnvelope): never {
|
|
const e = new Error(env.message) as Error & { status?: number };
|
|
e.status = mapRpcToStatus(String(env.code));
|
|
throw e;
|
|
}
|
|
|
|
export async function getSettings(db: Db, tenantId: number): Promise<PayrollSettings> {
|
|
const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_get", { tenant_id: tenantId });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return env.data!.settings;
|
|
}
|
|
|
|
export async function saveSettings(db: Db, tenantId: number, patch: Partial<PayrollSettings>): Promise<PayrollSettings> {
|
|
const env = await callCoreFn<{ settings: PayrollSettings }>(db, "core.fn_payroll_settings_save", { tenant_id: tenantId, ...patch });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return env.data!.settings;
|
|
}
|
|
|
|
export async function seedDefaults(db: Db, tenantId: number): Promise<void> { await getSettings(db, tenantId); }
|
|
|
|
export async function listUnits(db: Db, tenantId: number) {
|
|
const env = await callCoreFn<{ units: unknown[] }>(db, "core.fn_payroll_unit_list", { tenant_id: tenantId });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return env.data?.units ?? [];
|
|
}
|
|
|
|
export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise<number> {
|
|
const env = await callCoreFn<{ id: number }>(db, "core.fn_payroll_unit_add", { tenant_id: tenantId, code, label });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return Number(env.data?.id);
|
|
}
|
|
|
|
export async function createLoan(db: Db, tenantId: number, body: Record<string, unknown>): Promise<{ id: number }> {
|
|
const env = await callCoreFn<{ id: number }>(db, "core.fn_loan_create", { tenant_id: tenantId, ...body });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return { id: Number(env.data?.id) };
|
|
}
|
|
|
|
export async function setAttendance(db: Db, tenantId: number, body: { project_id: number; worker_id: number; work_date: string; present: boolean }) {
|
|
const env = await callCoreFn(db, "core.fn_attendance_set", { tenant_id: tenantId, ...body });
|
|
if (!env.ok) return { error: env.message, status: mapRpcToStatus(String(env.code)), other: (env.data as { other?: unknown })?.other };
|
|
return { ok: true as const };
|
|
}
|
|
|
|
export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise<number> {
|
|
const env = await callCoreFn<{ week_id: number }>(db, "core.fn_payroll_week_ensure", { tenant_id: tenantId, week_start: weekStart });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return Number(env.data?.week_id);
|
|
}
|
|
|
|
export async function recalcWeek(db: Db, weekId: number): Promise<void> {
|
|
const env = await callCoreFn(db, "core.fn_payroll_week_recalc", { week_id: weekId });
|
|
if (!env.ok) throwFromRpc(env);
|
|
}
|
|
|
|
export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
|
|
const env = await callCoreFn(db, "core.fn_payroll_week_assemble", { week_id: weekId, today: nowIso });
|
|
if (!env.ok) throwFromRpc(env);
|
|
}
|
|
|
|
export async function payWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
|
|
const env = await callCoreFn(db, "core.fn_payroll_week_pay", { week_id: weekId, today: nowIso });
|
|
if (!env.ok) throwFromRpc(env);
|
|
}
|
|
|
|
export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise<void> {
|
|
const env = await callCoreFn(db, "core.fn_payroll_jornal_worker_add", { week_id: weekId, project_id: projectId, worker_id: workerId });
|
|
if (!env.ok) throwFromRpc(env);
|
|
}
|
|
|
|
export async function addAdminLine(db: Db, weekId: number, body: { worker_id: number; amount: number; project_id?: number | null }): Promise<void> {
|
|
const env = await callCoreFn(db, "core.fn_payroll_admin_line_add", { week_id: weekId, ...body });
|
|
if (!env.ok) throwFromRpc(env);
|
|
}
|
|
|
|
export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise<void> {
|
|
const env = await callCoreFn(db, "core.fn_payroll_admin_line_update", { week_id: weekId, line_id: lineId, amount });
|
|
if (!env.ok) throwFromRpc(env);
|
|
}
|
|
|
|
export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise<void> {
|
|
const env = await callCoreFn(db, "core.fn_payroll_admin_line_remove", { week_id: weekId, line_id: lineId });
|
|
if (!env.ok) throwFromRpc(env);
|
|
}
|
|
|
|
export async function createDestajoJob(db: Db, tenantId: number, weekId: number, body: Record<string, unknown>): Promise<{ id: number }> {
|
|
const env = await callCoreFn<{ id: number }>(db, "core.fn_destajo_job_create", { tenant_id: tenantId, week_id: weekId, ...body });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return { id: Number(env.data?.id) };
|
|
}
|
|
|
|
export async function patchDestajoCut(db: Db, weekId: number, cutId: number, patch: Record<string, unknown>): Promise<void> {
|
|
const env = await callCoreFn(db, "core.fn_destajo_cut_patch", { week_id: weekId, cut_id: cutId, ...patch });
|
|
if (!env.ok) throwFromRpc(env);
|
|
}
|
|
|
|
export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) {
|
|
await ensureWeek(db, tenantId, weekStart);
|
|
const env = await callCoreFn<Record<string, unknown>>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart });
|
|
if (!env.ok) throwFromRpc(env);
|
|
const data = env.data ?? {};
|
|
const today = await resolveToday(db, tenantId);
|
|
const start = String((data.week as Record<string, unknown>)?.week_start ?? weekStart);
|
|
return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) };
|
|
}
|
|
|
|
export async function weekCsv(db: Db, weekId: number): Promise<string> {
|
|
const env = await callCoreFn<{ csv: string }>(db, "core.fn_payroll_week_csv", { week_id: weekId });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return String(env.data?.csv ?? "");
|
|
}
|
|
|
|
export async function listOpenWeeks(db: Db, tenantId: number) {
|
|
const env = await callCoreFn<{ weeks: unknown[] }>(db, "core.fn_payroll_week_list_open", { tenant_id: tenantId });
|
|
if (!env.ok) throwFromRpc(env);
|
|
return env.data?.weeks ?? [];
|
|
}
|