panels-origin/api/payroll.ts
Cursor Agent 493829d028
api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e)
Fase 2 (driver):
- api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run,
  placeholders ? -> $n, withTenant con set_config para RLS), con parsers
  de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto
  del codigo heredado de SQLite (fechas/montos como string, ids como
  number) siga funcionando sin reescribir cada call-site a mano.
- api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados
  por base/esquema (panels_platform, panels_product.iam,
  panels_product.core), owner pool para bootstrap/scripts/lookups
  administrativos que cruzan tenant a proposito.
- api/redis.ts: clientes iam/core separados (ACL panels_iam_redis /
  panels_core_redis).
- api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco,
  no HMAC autocontenido); revocacion real (logout, cambio de password).
- api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage
  (S3), con fallback a disco local si no hay credenciales S3 (dev).
- api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la
  transaccion con app.tenant_id fijado (RLS) para cada request.
- api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la
  llave; aplicado a /v1/catalogs.

Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/
payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts):
- Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT,
  now()/current_date, booleanos reales, RETURNING via lastInsertId()).
- IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id
  ya no dependen de que el handler recuerde el WHERE tenant_id -- Row
  Level Security lo hace estructuralmente (verificado con un segundo
  tenant real: 404 en vez de fuga de datos).
- API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito.

Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion
que siempre se revierte, contra el mismo baseline de Liquibase que
produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts
reescrito con fixtures reales; 11/11 pasan contra Postgres.

Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados
(ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot
desnormalizado (uploaded_by_id/name); seed() en runtime eliminado,
reemplazado por scripts/bootstrap-admin.ts (one-shot).

Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT),
PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone,
document_validity.ts ya no usa new Date() crudo.

Verificado end-to-end contra Postgres+Redis reales: login, sesiones,
catalogos con cache, alta de trabajador, subida/descarga de documento
cifrado, y el fix de IDOR probado con un segundo tenant real (403/404
en vez de fuga de datos).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-02 20:47:45 +00:00

1037 lines
40 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}`;
}
export async function getSettings(db: Db, tenantId: number): Promise<PayrollSettings> {
await seedDefaults(db, tenantId);
return await db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings;
}
export async function saveSettings(
db: Db,
tenantId: number,
patch: Partial<PayrollSettings>,
): Promise<PayrollSettings> {
const cur = await getSettings(db, tenantId);
const next = {
loan_commission_enabled: patch.loan_commission_enabled ?? cur.loan_commission_enabled,
loan_commission_pct: patch.loan_commission_pct ?? cur.loan_commission_pct,
loan_small_max: patch.loan_small_max ?? cur.loan_small_max,
};
await db.prepare(
`UPDATE payroll_settings
SET loan_commission_enabled=?, loan_commission_pct=?, loan_small_max=?
WHERE tenant_id=?`,
).run(
Boolean(next.loan_commission_enabled),
Number(next.loan_commission_pct) || 0,
Number(next.loan_small_max) || 0,
tenantId,
);
return await getSettings(db, tenantId);
}
export async function seedDefaults(db: Db, tenantId: number): Promise<void> {
await db.prepare(
`INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max)
VALUES (?, true, 10, 500)
ON CONFLICT(tenant_id) DO NOTHING`,
).run(tenantId);
const n = await db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number };
if (Number(n.n) === 0) {
const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)");
for (const u of DEFAULT_UNITS) await ins.run(tenantId, u.code, u.label);
}
}
export async function listUnits(db: Db, tenantId: number) {
await seedDefaults(db, tenantId);
return await db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId);
}
export async function addUnit(db: Db, tenantId: number, code: string, label: string): Promise<number> {
const c = code.trim().toLowerCase().replace(/\s+/g, "");
const l = label.trim() || c;
if (!c) throw new Error("Unidad requerida");
await db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l);
return await db.lastInsertId();
}
export function firstDueForLoan(opts: {
delivered: number;
smallMax: number;
plan: "single" | "installments";
grantIso: string;
weekAssembled: boolean;
}): string {
const small = opts.delivered <= opts.smallMax && opts.plan === "single";
if (small) return nextSaturdayAfterThisWeek(opts.grantIso);
if (opts.weekAssembled) return nextSaturdayAfterThisWeek(opts.grantIso);
return thisSaturday(opts.grantIso);
}
export async function createLoan(
db: Db,
tenantId: number,
body: {
worker_id: number;
delivered: number;
commission_enabled?: boolean;
commission_pct?: number;
plan?: "single" | "installments";
installments_n?: number;
note?: string;
grantIso?: string;
},
): Promise<{ id: number }> {
const worker = await db.prepare("SELECT id FROM workers WHERE id=?").get(body.worker_id);
if (!worker) throw Object.assign(new Error("Persona no encontrada"), { status: 404 });
const settings = await getSettings(db, tenantId);
const delivered = roundMoney(body.delivered);
if (delivered <= 0) throw Object.assign(new Error("Monto requerido"), { status: 400 });
const enabled = body.commission_enabled ?? Boolean(settings.loan_commission_enabled);
const pct = enabled ? Number(body.commission_pct ?? settings.loan_commission_pct) || 0 : 0;
const comm = commissionAmount(delivered, pct);
const balance = roundMoney(delivered + comm);
const small = delivered <= settings.loan_small_max;
let plan = body.plan ?? "single";
let installments = Math.max(1, Math.floor(body.installments_n ?? 1));
if (small) {
plan = "single";
installments = 1;
}
if (plan === "installments" && installments < 2) installments = 2;
if (plan === "single") installments = 1;
const weekly = roundMoney(balance / installments);
const grantIso = body.grantIso ?? await resolveToday(db, tenantId);
const week = weekContaining(grantIso);
const current = await db.prepare(
"SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, week.weekStart) as { id: number; status: string } | undefined;
const assembled = current?.status === "assembled" || current?.status === "paid";
const firstDue = firstDueForLoan({
delivered,
smallMax: settings.loan_small_max,
plan,
grantIso,
weekAssembled: assembled && weekdayMon0(grantIso) >= 4,
});
await db.prepare(
`INSERT INTO loans
(worker_id, amount, delivered, balance, weekly_payment, note,
commission_pct, commission_amount, plan, installments_n, first_due)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
body.worker_id,
delivered,
delivered,
balance,
weekly,
body.note ?? null,
pct,
comm,
plan,
installments,
firstDue,
);
const id = await db.lastInsertId();
if (current && current.status !== "paid") await recalcWeek(db, current.id);
return { id };
}
export async function presentOnOtherProject(
db: Db,
workerId: number,
workDate: string,
exceptProjectId?: number,
): Promise<{ project_id: number; project_name: string } | null> {
const row = await db.prepare(
`SELECT a.project_id, p.name AS project_name
FROM attendance a JOIN projects p ON p.id=a.project_id
WHERE a.worker_id=? AND a.work_date=? AND a.present=true
AND a.project_id != ?`,
).get(workerId, workDate, exceptProjectId ?? 0) as
| { project_id: number; project_name: string }
| undefined;
return row ?? null;
}
export async function setAttendance(
db: Db,
tenantId: number,
body: { project_id: number; worker_id: number; work_date: string; present: boolean },
): Promise<{ ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } }> {
if (body.present) {
const other = await presentOnOtherProject(db, body.worker_id, body.work_date, body.project_id);
if (other) {
return {
error: `Ese día ya está marcado en ${other.project_name}`,
status: 409,
other,
};
}
}
await db.prepare(
`INSERT INTO attendance (worker_id, project_id, work_date, present)
VALUES (?, ?, ?, ?)
ON CONFLICT(worker_id, project_id, work_date) DO UPDATE SET present=excluded.present`,
).run(body.worker_id, body.project_id, body.work_date, body.present);
const { weekStart } = weekContaining(body.work_date);
const week = await db.prepare(
"SELECT id, status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, weekStart) as { id: number; status: string } | undefined;
if (week && week.status !== "paid") await recalcWeek(db, week.id);
return { ok: true };
}
export async function ensureWeek(db: Db, tenantId: number, weekStart: string): Promise<number> {
await seedDefaults(db, tenantId);
const { weekEnd } = weekContaining(weekStart);
const start = weekContaining(weekStart).weekStart;
let week = await db.prepare(
"SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, start) as { id: number } | undefined;
if (!week) {
await db.prepare(
`INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
VALUES (?, ?, 'draft', ?)`,
).run(start, weekEnd, tenantId);
week = { id: await db.lastInsertId() };
}
const weekId = week.id;
const destajo = destajoPeriodBounds(start);
const period = await db.prepare(
"SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?",
).get(tenantId, destajo.periodEnd) as { id: number } | undefined;
if (!period) {
await db.prepare(
`INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
VALUES (?, ?, ?, ?)`,
).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId);
}
const projects = await db.prepare(
`SELECT id FROM projects
WHERE status IN ('activo', 'pausado')
AND (? = 0 OR tenant_id = ? OR tenant_id IS NULL)
ORDER BY name`,
).all(tenantId, tenantId) as { id: number }[];
const hasSheet = db.prepare(
`SELECT id FROM payroll_sheets
WHERE week_id=? AND kind=? AND COALESCE(project_id, 0)=COALESCE(?, 0)`,
);
const insSheet = db.prepare(
`INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`,
);
const ensureSheet = async (kind: string, projectId: number | null) => {
if (await hasSheet.get(weekId, kind, projectId)) return;
await insSheet.run(weekId, kind, projectId);
};
for (const p of projects) {
await ensureSheet("obra", p.id);
await ensureSheet("destajo", p.id);
}
await ensureSheet("admin", null);
return weekId;
}
async function jornalRoster(db: Db, projectId: number, weekStart: string, weekEnd: string, weekId = 0) {
return await db.prepare(
`SELECT w.id, w.first_name, w.last_name_p, w.position, w.daily_wage, w.work_type
FROM workers w
WHERE w.status='activo' AND w.pipeline_status != 'baja'
AND (
EXISTS (
SELECT 1 FROM assignments a
WHERE a.worker_id=w.id AND a.project_id=? AND a.active=true AND w.work_type='N'
)
OR EXISTS (
SELECT 1 FROM attendance att
WHERE att.worker_id=w.id AND att.project_id=? AND att.work_date BETWEEN ? AND ?
)
OR EXISTS (
SELECT 1 FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE l.worker_id=w.id AND s.project_id=? AND s.kind='obra' AND s.week_id=?
)
)
ORDER BY w.last_name_p, w.first_name`,
).all(projectId, projectId, weekStart, weekEnd, projectId, weekId) as {
id: number;
first_name: string;
last_name_p: string;
position: string;
daily_wage: number;
work_type: string;
}[];
}
async function sheetLine(
db: Db,
sheetId: number,
workerId: number,
destajoCutLineId: number | null,
): Promise<number> {
const existing = await db.prepare(
destajoCutLineId
? "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND destajo_cut_line_id=?"
: "SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=? AND destajo_cut_line_id IS NULL",
).get(sheetId, destajoCutLineId ?? workerId) as { id: number } | undefined;
if (existing) return existing.id;
await db.prepare(
`INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id)
VALUES (?, ?, ?)`,
).run(sheetId, workerId, destajoCutLineId);
return await db.lastInsertId();
}
async function dueLoansForSaturday(db: Db, workerId: number, saturday: string): Promise<LoanRow[]> {
return await db.prepare(
`SELECT * FROM loans
WHERE worker_id=? AND balance > 0 AND first_due IS NOT NULL AND first_due <= ?
ORDER BY first_due, id`,
).all(workerId, saturday) as LoanRow[];
}
async function paidInstallments(db: Db, loanId: number): Promise<number> {
const row = await db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number };
return Number(row.n);
}
export async function recalcWeek(db: Db, weekId: number): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
id: number;
week_start: string;
week_end: string;
status: string;
required_net: number;
tenant_id: number;
} | undefined;
if (!week || week.status === "paid") return;
const saturday = week.week_end;
const sheets = await db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as {
id: number;
kind: string;
project_id: number | null;
}[];
await db.prepare("UPDATE payroll_week_lines SET loan_id=NULL, loan_discount=0, loan_label=NULL WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)")
.run(weekId);
for (const sheet of sheets) {
if (sheet.kind === "obra" && sheet.project_id) {
const roster = await jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId);
for (const w of roster) {
const lineId = await sheetLine(db, sheet.id, w.id, null);
const att = await db.prepare(
`SELECT COUNT(*) AS n FROM attendance
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=true`,
).get(w.id, sheet.project_id, week.week_start, week.week_end) as { n: number };
const frozen = await db.prepare("SELECT daily_wage FROM payroll_week_lines WHERE id=?").get(lineId) as { daily_wage: number };
const wage = week.status === "assembled" && frozen.daily_wage > 0 ? frozen.daily_wage : w.daily_wage;
const gross = roundMoney(Number(att.n) * wage);
await db.prepare(
`UPDATE payroll_week_lines
SET days=?, daily_wage=?, amount=?, gross=?, payable_net=?
WHERE id=?`,
).run(Number(att.n), wage, gross, gross, gross, lineId);
}
}
if (sheet.kind === "destajo" && sheet.project_id) {
const period = await db.prepare(
"SELECT id FROM destajo_periods WHERE week_id=?",
).get(weekId) as { id: number } | undefined;
if (!period) continue;
const cuts = await db.prepare(
`SELECT c.*, j.worker_id, j.unit_price, j.unit_code, j.concepto
FROM destajo_cut_lines c
JOIN destajo_jobs j ON j.id=c.job_id
WHERE c.period_id=? AND j.project_id=?`,
).all(period.id, sheet.project_id) as {
id: number;
worker_id: number;
qty_planned: number;
qty_actual: number;
qty_extra: number;
unit_price: number;
unit_code: string;
concepto: string;
}[];
const keep = new Set<number>();
for (const cut of cuts) {
const lineId = await sheetLine(db, sheet.id, cut.worker_id, cut.id);
keep.add(lineId);
const payableQty = roundMoney(
week.status === "draft" && cut.qty_actual === 0 && cut.qty_extra === 0
? cut.qty_planned
: cut.qty_actual + cut.qty_extra,
);
const gross = roundMoney(payableQty * cut.unit_price);
const requiredGross = roundMoney(cut.qty_planned * cut.unit_price);
await db.prepare(
`UPDATE payroll_week_lines
SET destajo_cut_line_id=?, qty_planned=?, qty_actual=?, qty_extra=?,
unit_price=?, unit_code=?, concepto=?, amount=?, gross=?, payable_net=?
WHERE id=?`,
).run(
cut.id,
cut.qty_planned,
cut.qty_actual,
cut.qty_extra,
cut.unit_price,
cut.unit_code,
cut.concepto,
requiredGross,
gross,
gross,
lineId,
);
}
const stale = await db.prepare(
"SELECT id, destajo_cut_line_id FROM payroll_week_lines WHERE sheet_id=?",
).all(sheet.id) as { id: number; destajo_cut_line_id: number | null }[];
for (const row of stale) {
if (!keep.has(row.id)) await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id);
}
}
if (sheet.kind === "admin") {
const lines = await db.prepare("SELECT id, amount, discounts FROM payroll_week_lines WHERE sheet_id=?").all(sheet.id) as {
id: number;
amount: number;
discounts: number;
}[];
for (const line of lines) {
const gross = roundMoney(line.amount);
await db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run(
gross,
roundMoney(gross - line.discounts),
line.id,
);
}
}
}
const allLines = await db.prepare(
`SELECT l.id, l.worker_id, l.gross, l.discounts, l.payable_net
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE s.week_id=?
ORDER BY l.gross DESC, l.id`,
).all(weekId) as { id: number; worker_id: number; gross: number; discounts: number; payable_net: number }[];
const byWorker = new Map<number, typeof allLines>();
for (const line of allLines) {
const list = byWorker.get(line.worker_id) ?? [];
list.push(line);
byWorker.set(line.worker_id, list);
}
for (const [workerId, lines] of byWorker) {
const gross = roundMoney(lines.reduce((s, l) => s + l.gross - l.discounts, 0));
const loans = await dueLoansForSaturday(db, workerId, saturday);
let remainingNet = Math.max(0, gross);
let placed = false;
for (const loan of loans) {
if (remainingNet <= 0) break;
const n = await paidInstallments(db, loan.id) + 1;
const isLast = n >= (loan.installments_n || 1);
const want = isLast ? loan.balance : Math.min(loan.weekly_payment || loan.balance, loan.balance);
const take = roundMoney(Math.min(want, remainingNet));
if (take <= 0) continue;
remainingNet = roundMoney(remainingNet - take);
if (!placed) {
const host = lines[0];
const label = loanConditionLabel(loan, take, n);
await db.prepare(
`UPDATE payroll_week_lines SET loan_id=?, loan_discount=?, loan_label=?, payable_net=? WHERE id=?`,
).run(loan.id, take, label, roundMoney(host.gross - host.discounts - take), host.id);
placed = true;
}
break;
}
if (!placed) {
for (const line of lines) {
await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
roundMoney(line.gross - line.discounts),
line.id,
);
}
} else {
for (const line of lines.slice(1)) {
await db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
roundMoney(line.gross - line.discounts),
line.id,
);
}
}
}
const tot = await db.prepare(
`SELECT COALESCE(SUM(l.payable_net), 0) AS n
FROM payroll_week_lines l JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE s.week_id=?`,
).get(weekId) as { n: number };
await db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
if (week.status === "draft") {
await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
await db.prepare(
`UPDATE payroll_week_lines SET required_net=payable_net
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
).run(weekId);
}
}
async function prefillThuSat(db: Db, projectId: number, weekStart: string, workerId: number): Promise<void> {
for (const offset of [3, 4, 5]) {
const day = addDays(weekStart, offset);
if (await presentOnOtherProject(db, workerId, day, projectId)) continue;
const existing = await db.prepare(
"SELECT id, present FROM attendance WHERE worker_id=? AND project_id=? AND work_date=?",
).get(workerId, projectId, day) as { id: number; present: boolean } | undefined;
if (existing) continue;
await db.prepare(
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, true)",
).run(workerId, projectId, day);
}
}
export async function assembleWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
id: number;
week_start: string;
status: string;
tenant_id: number;
} | undefined;
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
if (!canAssembleWeek(week.week_start, nowIso)) {
throw Object.assign(
new Error("La nómina se arma el jueves o después. Aún no es jueves de esta semana."),
{ status: 400 },
);
}
const sheets = await db.prepare(
"SELECT * FROM payroll_sheets WHERE week_id=? AND kind='obra'",
).all(weekId) as { id: number; project_id: number | null }[];
for (const sheet of sheets) {
if (!sheet.project_id) continue;
const roster = await jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId);
for (const w of roster) {
const assigned = await db.prepare(
"SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=true",
).get(w.id, sheet.project_id) as { ok: number } | undefined;
if (!assigned) continue;
if (w.work_type !== "N") {
const hasLine = await db.prepare(
"SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?",
).get(sheet.id, w.id);
if (!hasLine) continue;
}
await prefillThuSat(db, sheet.project_id, week.week_start, w.id);
}
}
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
if (period) {
await db.prepare(
`UPDATE destajo_cut_lines SET qty_actual = qty_planned
WHERE period_id=? AND qty_actual=0 AND qty_planned>0`,
).run(period.id);
}
await db.prepare(
"UPDATE payroll_weeks SET status='assembled', assembled_at=now() WHERE id=?",
).run(weekId);
await recalcWeek(db, weekId);
const payable = await db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number };
await db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId);
await db.prepare(
`UPDATE payroll_week_lines SET required_net=payable_net
WHERE sheet_id IN (SELECT id FROM payroll_sheets WHERE week_id=?)`,
).run(weekId);
}
export async function payWeek(db: Db, weekId: number, nowIso: string): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
id: number;
week_end: string;
status: string;
} | undefined;
if (!week) throw Object.assign(new Error("Semana no encontrada"), { status: 404 });
if (week.status === "paid") throw Object.assign(new Error("La semana ya está pagada"), { status: 400 });
if (week.status !== "assembled") {
await assembleWeek(db, weekId, nowIso);
}
await recalcWeek(db, weekId);
const lines = await db.prepare(
`SELECT l.loan_id, l.loan_discount, l.loan_label, l.worker_id
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE s.week_id=? AND l.loan_id IS NOT NULL AND l.loan_discount > 0`,
).all(weekId) as { loan_id: number; loan_discount: number; loan_label: string; worker_id: number }[];
for (const line of lines) {
const loan = await db.prepare("SELECT * FROM loans WHERE id=?").get(line.loan_id) as LoanRow | undefined;
if (!loan || loan.balance <= 0) continue;
const take = roundMoney(Math.min(line.loan_discount, loan.balance));
if (take <= 0) continue;
const n = await paidInstallments(db, loan.id) + 1;
await db.prepare(
`INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`,
).run(loan.id, weekId, take, n, line.loan_label);
await db.prepare("UPDATE loans SET balance = ROUND((balance - ?)::numeric, 2) WHERE id=?").run(take, loan.id);
}
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
if (period) {
const jobs = await db.prepare("SELECT id, qty_total_estimated FROM destajo_jobs").all() as {
id: number;
qty_total_estimated: number;
}[];
for (const job of jobs) {
const paid = await db.prepare(
`SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
FROM destajo_cut_lines c
JOIN destajo_periods p ON p.id=c.period_id
JOIN payroll_weeks w ON w.id=p.week_id
WHERE c.job_id=? AND (w.status='paid' OR w.id=?)`,
).get(job.id, weekId) as { n: number };
if (Number(paid.n) >= job.qty_total_estimated) {
await db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id);
}
}
}
await db.prepare(
"UPDATE payroll_weeks SET status='paid', paid_at=now() WHERE id=?",
).run(weekId);
}
export async function addJornalWorker(db: Db, weekId: number, projectId: number, workerId: number): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
const sheet = await db.prepare(
"SELECT id FROM payroll_sheets WHERE week_id=? AND kind='obra' AND project_id=?",
).get(weekId, projectId) as { id: number } | undefined;
if (!sheet) throw Object.assign(new Error("Hoja de obra no encontrada"), { status: 404 });
await sheetLine(db, sheet.id, workerId, null);
await recalcWeek(db, weekId);
}
export async function addAdminLine(
db: Db,
weekId: number,
body: { worker_id: number; amount: number; project_id?: number | null },
): Promise<void> {
const week = await db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
const sheet = await db.prepare(
"SELECT id FROM payroll_sheets WHERE week_id=? AND kind='admin' AND project_id IS NULL",
).get(weekId) as { id: number } | undefined;
if (!sheet) throw Object.assign(new Error("Hoja administrativa no encontrada"), { status: 404 });
const lineId = await sheetLine(db, sheet.id, body.worker_id, null);
await db.prepare("UPDATE payroll_week_lines SET amount=?, project_id=?, gross=?, payable_net=? WHERE id=?").run(
roundMoney(body.amount),
body.project_id ?? null,
roundMoney(body.amount),
roundMoney(body.amount),
lineId,
);
await recalcWeek(db, weekId);
}
export async function updateAdminLine(db: Db, weekId: number, lineId: number, amount: number): Promise<void> {
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
await db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId);
await recalcWeek(db, weekId);
}
export async function removeAdminLine(db: Db, weekId: number, lineId: number): Promise<void> {
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
await db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId);
await recalcWeek(db, weekId);
}
export async function jobRemainder(db: Db, jobId: number): Promise<number> {
const job = await db.prepare("SELECT qty_total_estimated FROM destajo_jobs WHERE id=?").get(jobId) as
| { qty_total_estimated: number }
| undefined;
if (!job) return 0;
const paid = await db.prepare(
`SELECT COALESCE(SUM(c.qty_actual + c.qty_extra), 0) AS n
FROM destajo_cut_lines c
JOIN destajo_periods p ON p.id=c.period_id
JOIN payroll_weeks w ON w.id=p.week_id
WHERE c.job_id=? AND w.status='paid'`,
).get(jobId) as { n: number };
return roundMoney(Math.max(0, job.qty_total_estimated - Number(paid.n)));
}
export async function createDestajoJob(
db: Db,
tenantId: number,
weekId: number,
body: {
project_id: number;
worker_id: number;
concepto: string;
unit_code: string;
qty_total_estimated: number;
unit_price: number;
qty_planned?: number;
qty_extra?: number;
},
): Promise<{ id: number }> {
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
const concepto = body.concepto.trim();
if (!concepto) throw Object.assign(new Error("Concepto requerido"), { status: 400 });
await db.prepare(
`INSERT INTO destajo_jobs
(project_id, worker_id, concepto, unit_code, qty_total_estimated, unit_price, tenant_id)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
body.project_id,
body.worker_id,
concepto,
body.unit_code,
body.qty_total_estimated,
body.unit_price,
tenantId,
);
const jobId = await db.lastInsertId();
const period = await db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number };
await db.prepare(
`INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
VALUES (?, ?, ?, 0, ?)`,
).run(period.id, jobId, body.qty_planned ?? 0, body.qty_extra ?? 0);
await recalcWeek(db, weekId);
return { id: jobId };
}
export async function patchDestajoCut(
db: Db,
weekId: number,
cutId: number,
patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number },
): Promise<void> {
const week = await db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string } | undefined;
if (!week || week.status === "paid") throw Object.assign(new Error("No se puede editar"), { status: 400 });
const cut = await db.prepare("SELECT * FROM destajo_cut_lines WHERE id=?").get(cutId) as {
qty_planned: number;
qty_actual: number;
qty_extra: number;
} | undefined;
if (!cut) throw Object.assign(new Error("Partida no encontrada"), { status: 404 });
const planned = patch.qty_planned ?? cut.qty_planned;
const actual = patch.qty_actual ?? cut.qty_actual;
const extra = patch.qty_extra ?? cut.qty_extra;
await db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run(
planned,
actual,
extra,
cutId,
);
await recalcWeek(db, weekId);
}
export async function ensureCutForOpenJobs(db: Db, weekId: number): Promise<void> {
const period = await db.prepare("SELECT id, tenant_id FROM destajo_periods WHERE week_id=?").get(weekId) as
| { id: number; tenant_id: number }
| undefined;
if (!period) return;
const jobs = await db.prepare(
"SELECT id FROM destajo_jobs WHERE tenant_id=? AND status='open'",
).all(period.tenant_id) as { id: number }[];
const ins = db.prepare(
`INSERT INTO destajo_cut_lines (period_id, job_id, qty_planned, qty_actual, qty_extra)
VALUES (?, ?, 0, 0, 0)
ON CONFLICT(period_id, job_id) DO NOTHING`,
);
for (const job of jobs) await ins.run(period.id, job.id);
}
type AttCell = {
date: string;
present: boolean;
other_project_id: number | null;
other_project_name: string | null;
};
export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) {
const weekId = await ensureWeek(db, tenantId, weekStart);
await ensureCutForOpenJobs(db, weekId);
await recalcWeek(db, weekId);
const week = await db.prepare(
`SELECT * FROM payroll_weeks WHERE id=?`,
).get(weekId) as Record<string, unknown>;
const period = await db.prepare("SELECT * FROM destajo_periods WHERE week_id=?").get(weekId);
const start = String(week.week_start);
const end = String(week.week_end);
const days = weekDays(start);
const sheetsRaw = await db.prepare(
`SELECT s.*, p.name AS project_name, p.code AS project_code
FROM payroll_sheets s
LEFT JOIN projects p ON p.id=s.project_id
WHERE s.week_id=?
ORDER BY CASE s.kind WHEN 'obra' THEN 0 WHEN 'destajo' THEN 1 ELSE 2 END, p.name`,
).all(weekId) as Record<string, unknown>[];
const attRows = 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(start, end) as {
worker_id: number;
project_id: number;
work_date: string;
present: boolean;
project_name: string;
}[];
const sheets = [];
for (const sheet of sheetsRaw) {
const lines = await db.prepare(
`SELECT l.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, w.position, w.work_type
FROM payroll_week_lines l
JOIN workers w ON w.id=l.worker_id
WHERE l.sheet_id=?
ORDER BY w.last_name_p, w.first_name, l.id`,
).all(Number(sheet.id)) as Record<string, unknown>[];
const withCells = lines.map((line) => {
const cells: AttCell[] = days.map((date) => {
const mine = attRows.find((a) =>
a.worker_id === line.worker_id && a.project_id === sheet.project_id && a.work_date === date
);
const other = attRows.find((a) =>
a.worker_id === line.worker_id && a.present === true && a.project_id !== sheet.project_id && a.work_date === date
);
return {
date,
present: Boolean(mine?.present),
other_project_id: other && !mine?.present ? other.project_id : null,
other_project_name: other && !mine?.present ? other.project_name : null,
};
});
return { ...line, cells };
});
sheets.push({ ...sheet, lines: withCells });
}
const jobs = await db.prepare(
`SELECT j.*, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name
FROM destajo_jobs j
JOIN workers w ON w.id=j.worker_id
JOIN projects p ON p.id=j.project_id
WHERE j.tenant_id=?
ORDER BY j.status, j.id DESC`,
).all(tenantId) as Record<string, unknown>[];
const jobsOut = [];
for (const job of jobs) jobsOut.push({ ...job, remainder: await jobRemainder(db, Number(job.id)) });
const cuts = period
? await db.prepare(
`SELECT c.*, j.concepto, j.unit_code, j.unit_price, j.qty_total_estimated, j.worker_id, j.project_id,
j.status AS job_status, w.first_name, w.middle_name, w.last_name_p, w.last_name_m, p.name AS project_name
FROM destajo_cut_lines c
JOIN destajo_jobs j ON j.id=c.job_id
JOIN workers w ON w.id=j.worker_id
JOIN projects p ON p.id=j.project_id
WHERE c.period_id=?
ORDER BY p.name, w.last_name_p`,
).all((period as { id: number }).id)
: [];
const settings = await getSettings(db, tenantId);
const units = await listUnits(db, tenantId);
const required = Number(week.required_net) || 0;
const payable = Number(week.payable_net) || 0;
const recoveryRow = await db.prepare(
`SELECT COALESCE(SUM(l.loan_discount), 0) AS n
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE s.week_id=?`,
).get(weekId) as { n: number };
const loanRecovery = roundMoney(Number(recoveryRow.n));
const faltante = roundMoney(Math.max(0, payable - required));
const today = await resolveToday(db, tenantId);
return {
week,
destajo_period: period,
sheets,
destajo_jobs: jobsOut,
destajo_cuts: cuts,
settings,
units,
days,
board: {
required_net: required,
payable_net: payable,
loan_recovery: loanRecovery,
faltante,
delta: roundMoney(required - payable),
},
can_assemble: canAssembleWeek(start, today),
assemble_from: weekAssembleDate(start),
};
}
export async function weekCsv(db: Db, weekId: number): Promise<string> {
const lines = await db.prepare(
`SELECT w.first_name, w.last_name_p, s.kind, p.name AS project_name,
l.days, l.daily_wage, l.concepto, l.qty_actual, l.qty_extra, l.unit_code,
l.gross, l.discounts, l.loan_discount, l.loan_label, l.payable_net, l.required_net
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
JOIN workers w ON w.id=l.worker_id
LEFT JOIN projects p ON p.id=s.project_id
WHERE s.week_id=?
ORDER BY s.kind, p.name, w.last_name_p`,
).all(weekId) as Record<string, unknown>[];
const header =
"Nombre,Apellido,Hoja,Proyecto,Dias,Jornal,Concepto,Cantidad,Unidad,Bruto,Descuentos,Prestamo,EtiquetaPrestamo,Requerido,APagar";
const rows = lines.map((l) =>
[
l.first_name,
l.last_name_p,
l.kind,
l.project_name ?? "",
l.days,
l.daily_wage,
l.concepto ?? "",
Number(l.qty_actual || 0) + Number(l.qty_extra || 0),
l.unit_code ?? "",
l.gross,
l.discounts,
l.loan_discount,
String(l.loan_label ?? "").replaceAll(",", " "),
l.required_net,
l.payable_net,
].join(",")
);
return [header, ...rows].join("\n");
}
export async function listOpenWeeks(db: Db, tenantId: number) {
return await db.prepare(
`SELECT w.*,
(SELECT COALESCE(SUM(l.loan_discount), 0)
FROM payroll_week_lines l
JOIN payroll_sheets s ON s.id=l.sheet_id
WHERE s.week_id=w.id) AS loan_recovery
FROM payroll_weeks w
WHERE w.tenant_id=? AND w.status != 'paid'
ORDER BY w.week_start DESC`,
).all(tenantId);
}