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 { 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 { 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): Promise { 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 { 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 { 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): 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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): 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): Promise { 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 | 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>, days: string[], tenantId: number, ): Promise>> { 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 ? AND p.tenant_id = ?`, ).all(from, to, tenantId) as AttendanceRow[]; return sheets.map((sheet) => { if (sheet.kind !== "obra") return sheet; const projectId = Number(sheet.project_id); const lines = ((sheet.lines as Array>) ?? []).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>(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 | null; const start = isoDay(week?.week_start) || weekStart; const days = weekDays(start); const sheets = await attachJornalCells( db, (data.sheets as Array>) ?? [], days, tenantId, ); 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 { 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 ?? []; }