panels-origin/api/payroll.ts
Cursor Agent 9c8b6ea19c
fix: devolver días L–S y celdas de asistencia en el paquete de nómina
La UI de Jornal ya tenía checkboxes por día, pero GET /v1/payroll/weeks
no enviaba days ni cells, así que las columnas L–S no se renderizaban
y solo se veía el total Días en cero.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 20:58:41 +00:00

328 lines
12 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);
}
type AttendanceRow = {
worker_id: number;
project_id: number;
work_date: string;
present: boolean | number;
project_name?: string | null;
};
function isoDay(value: unknown): string {
return String(value ?? "").slice(0, 10);
}
export function weekBoard(week: Record<string, unknown> | null | undefined) {
const required = Number(week?.required_net || 0);
const payable = Number(week?.payable_net || 0);
const loanRecovery = Math.max(0, roundMoney(required - payable));
const faltante = Math.max(0, roundMoney(payable - required));
return {
required_net: required,
payable_net: payable,
loan_recovery: loanRecovery,
faltante,
delta: roundMoney(payable - required),
};
}
async function attachJornalCells(
db: Db,
sheets: Array<Record<string, unknown>>,
days: string[],
): Promise<Array<Record<string, unknown>>> {
const from = days[0];
const to = days[days.length - 1];
if (!from || !to) return sheets;
const rows = await db.prepare(
`SELECT a.worker_id, a.project_id, a.work_date, a.present, p.name AS project_name
FROM attendance a
JOIN projects p ON p.id = a.project_id
WHERE a.work_date BETWEEN ? AND ?`,
).all(from, to) as AttendanceRow[];
return sheets.map((sheet) => {
if (sheet.kind !== "obra") return sheet;
const projectId = Number(sheet.project_id);
const lines = ((sheet.lines as Array<Record<string, unknown>>) ?? []).map((line) => {
const workerId = Number(line.worker_id);
const cells = days.map((date) => {
const here = rows.find((r) =>
Number(r.worker_id) === workerId &&
isoDay(r.work_date) === date &&
Number(r.project_id) === projectId
);
const other = rows.find((r) =>
Number(r.worker_id) === workerId &&
isoDay(r.work_date) === date &&
Number(r.project_id) !== projectId &&
Boolean(r.present)
);
return {
date,
present: Boolean(here?.present),
other_project_id: other ? Number(other.project_id) : null,
other_project_name: other?.project_name ?? null,
};
});
return { ...line, cells };
});
return { ...sheet, lines };
});
}
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 week = (data.week ?? null) as Record<string, unknown> | null;
const start = isoDay(week?.week_start) || weekStart;
const days = weekDays(start);
const sheets = await attachJornalCells(db, (data.sheets as Array<Record<string, unknown>>) ?? [], days);
const bounds = destajoPeriodBounds(start);
return {
...data,
week,
sheets,
days,
destajo_period: data.destajo_period ?? {
period_start: bounds.periodStart,
period_end: bounds.periodEnd,
},
board: data.board ?? weekBoard(week),
units: data.units ?? [...DEFAULT_UNITS],
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 ?? [];
}