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}`; } export async function getSettings(db: Db, tenantId: number): Promise { 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, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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(); 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(); 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; 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[]; 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[]; 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[]; 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 { 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[]; 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); }