panels-origin/api/payroll.ts
2026-08-25 20:21:49 -05:00

997 lines
37 KiB
TypeScript

import type { Database } from "@db/sqlite";
export const PAYROLL_TZ = "America/Cancun";
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: number;
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;
};
function lid(db: Database): number {
return Number((db.prepare("SELECT last_insert_rowid() AS id").get() as { id: number }).id);
}
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);
}
export function todayIso(now = new Date()): string {
return now.toLocaleDateString("en-CA", { timeZone: PAYROLL_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 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 function getSettings(db: Database, tenantId: number): PayrollSettings {
seedDefaults(db, tenantId);
const row = db.prepare("SELECT * FROM payroll_settings WHERE tenant_id=?").get(tenantId) as PayrollSettings;
return row;
}
export function saveSettings(
db: Database,
tenantId: number,
patch: Partial<PayrollSettings>,
): PayrollSettings {
const cur = 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,
};
db.prepare(
`UPDATE payroll_settings
SET loan_commission_enabled=?, loan_commission_pct=?, loan_small_max=?
WHERE tenant_id=?`,
).run(
next.loan_commission_enabled ? 1 : 0,
Number(next.loan_commission_pct) || 0,
Number(next.loan_small_max) || 0,
tenantId,
);
return getSettings(db, tenantId);
}
export function seedDefaults(db: Database, tenantId: number) {
db.prepare(
`INSERT INTO payroll_settings (tenant_id, loan_commission_enabled, loan_commission_pct, loan_small_max)
VALUES (?, 1, 10, 500)
ON CONFLICT(tenant_id) DO NOTHING`,
).run(tenantId);
const n = db.prepare("SELECT COUNT(*) AS n FROM destajo_units WHERE tenant_id=?").get(tenantId) as { n: number };
if (n.n === 0) {
const ins = db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)");
for (const u of DEFAULT_UNITS) ins.run(tenantId, u.code, u.label);
}
}
export function listUnits(db: Database, tenantId: number) {
seedDefaults(db, tenantId);
return db.prepare("SELECT * FROM destajo_units WHERE tenant_id=? ORDER BY id").all(tenantId);
}
export function addUnit(db: Database, tenantId: number, code: string, label: string) {
const c = code.trim().toLowerCase().replace(/\s+/g, "");
const l = label.trim() || c;
if (!c) throw new Error("Unidad requerida");
db.prepare("INSERT INTO destajo_units (tenant_id, code, label) VALUES (?, ?, ?)").run(tenantId, c, l);
return lid(db);
}
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 function createLoan(
db: Database,
tenantId: number,
body: {
worker_id: number;
delivered: number;
commission_enabled?: boolean;
commission_pct?: number;
plan?: "single" | "installments";
installments_n?: number;
note?: string;
grantIso?: string;
},
): { id: number } {
const worker = 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 = 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 ?? todayIso();
const week = weekContaining(grantIso);
const current = db.prepare(
"SELECT status FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, week.weekStart) as { 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,
});
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,
);
return { id: lid(db) };
}
export function presentOnOtherProject(
db: Database,
workerId: number,
workDate: string,
exceptProjectId?: number,
): { project_id: number; project_name: string } | null {
const row = 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=1
AND a.project_id != ?`,
).get(workerId, workDate, exceptProjectId ?? 0) as
| { project_id: number; project_name: string }
| undefined;
return row ?? null;
}
export function setAttendance(
db: Database,
tenantId: number,
body: { project_id: number; worker_id: number; work_date: string; present: boolean },
): { ok: true } | { error: string; status: number; other?: { project_id: number; project_name: string } } {
if (body.present) {
const other = 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,
};
}
}
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 ? 1 : 0);
const { weekStart } = weekContaining(body.work_date);
const week = 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") recalcWeek(db, week.id);
return { ok: true };
}
export function ensureWeek(db: Database, tenantId: number, weekStart: string): number {
seedDefaults(db, tenantId);
const { weekEnd } = weekContaining(weekStart);
const start = weekContaining(weekStart).weekStart;
let week = db.prepare(
"SELECT id FROM payroll_weeks WHERE tenant_id=? AND week_start=?",
).get(tenantId, start) as { id: number } | undefined;
if (!week) {
db.prepare(
`INSERT INTO payroll_weeks (week_start, week_end, status, tenant_id)
VALUES (?, ?, 'draft', ?)`,
).run(start, weekEnd, tenantId);
week = { id: lid(db) };
}
const weekId = week.id;
const destajo = destajoPeriodBounds(start);
const period = db.prepare(
"SELECT id FROM destajo_periods WHERE tenant_id=? AND period_end=?",
).get(tenantId, destajo.periodEnd) as { id: number } | undefined;
if (!period) {
db.prepare(
`INSERT INTO destajo_periods (period_start, period_end, week_id, tenant_id)
VALUES (?, ?, ?, ?)`,
).run(destajo.periodStart, destajo.periodEnd, weekId, tenantId);
}
const projects = 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 IFNULL(project_id, 0)=IFNULL(?, 0)`,
);
const insSheet = db.prepare(
`INSERT INTO payroll_sheets (week_id, kind, project_id) VALUES (?, ?, ?)`,
);
const ensureSheet = (kind: string, projectId: number | null) => {
if (hasSheet.get(weekId, kind, projectId)) return;
insSheet.run(weekId, kind, projectId);
};
for (const p of projects) {
ensureSheet("obra", p.id);
ensureSheet("destajo", p.id);
}
ensureSheet("admin", null);
return weekId;
}
function jornalRoster(db: Database, projectId: number, weekStart: string, weekEnd: string, weekId = 0) {
return 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=1 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;
}[];
}
function sheetLine(
db: Database,
sheetId: number,
workerId: number,
destajoCutLineId: number | null,
): number {
const existing = 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;
db.prepare(
`INSERT INTO payroll_week_lines (sheet_id, worker_id, destajo_cut_line_id)
VALUES (?, ?, ?)`,
).run(sheetId, workerId, destajoCutLineId);
return lid(db);
}
function dueLoansForSaturday(db: Database, workerId: number, saturday: string): LoanRow[] {
return 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[];
}
function paidInstallments(db: Database, loanId: number): number {
const row = db.prepare("SELECT COUNT(*) AS n FROM loan_payments WHERE loan_id=?").get(loanId) as { n: number };
return row.n;
}
export function recalcWeek(db: Database, weekId: number) {
const week = 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 days = weekDays(week.week_start);
const saturday = week.week_end;
const sheets = db.prepare("SELECT * FROM payroll_sheets WHERE week_id=?").all(weekId) as {
id: number;
kind: string;
project_id: number | null;
}[];
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 = jornalRoster(db, sheet.project_id, week.week_start, week.week_end, weekId);
for (const w of roster) {
const lineId = sheetLine(db, sheet.id, w.id, null);
const att = db.prepare(
`SELECT COUNT(*) AS n FROM attendance
WHERE worker_id=? AND project_id=? AND work_date BETWEEN ? AND ? AND present=1`,
).get(w.id, sheet.project_id, week.week_start, week.week_end) as { n: number };
const frozen = 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(att.n * wage);
db.prepare(
`UPDATE payroll_week_lines
SET days=?, daily_wage=?, amount=?, gross=?, payable_net=?
WHERE id=?`,
).run(att.n, wage, gross, gross, gross, lineId);
}
}
if (sheet.kind === "destajo" && sheet.project_id) {
const period = db.prepare(
"SELECT id FROM destajo_periods WHERE week_id=?",
).get(weekId) as { id: number } | undefined;
if (!period) continue;
const cuts = 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 = 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);
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 = 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)) db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(row.id);
}
}
if (sheet.kind === "admin") {
const lines = 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);
db.prepare("UPDATE payroll_week_lines SET gross=?, payable_net=? WHERE id=?").run(
gross,
roundMoney(gross - line.discounts),
line.id,
);
}
}
}
const allLines = 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 = dueLoansForSaturday(db, workerId, saturday);
let remainingNet = Math.max(0, gross);
let placed = false;
for (const loan of loans) {
if (remainingNet <= 0) break;
const n = 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);
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) {
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)) {
db.prepare("UPDATE payroll_week_lines SET payable_net=? WHERE id=?").run(
roundMoney(line.gross - line.discounts),
line.id,
);
}
}
}
const tot = 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 };
db.prepare("UPDATE payroll_weeks SET payable_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
if (week.status === "draft") {
db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(roundMoney(tot.n), weekId);
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);
}
}
function prefillThuSat(db: Database, projectId: number, weekStart: string, workerId: number) {
for (const offset of [3, 4, 5]) {
const day = addDays(weekStart, offset);
if (presentOnOtherProject(db, workerId, day, projectId)) continue;
const existing = db.prepare(
"SELECT id, present FROM attendance WHERE worker_id=? AND project_id=? AND work_date=?",
).get(workerId, projectId, day) as { id: number; present: number } | undefined;
if (existing) continue;
db.prepare(
"INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (?, ?, ?, 1)",
).run(workerId, projectId, day);
}
}
export function assembleWeek(db: Database, weekId: number) {
const week = 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 });
const sheets = 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 = jornalRoster(db, sheet.project_id, week.week_start, addDays(week.week_start, 5), weekId);
for (const w of roster) {
const assigned = db.prepare(
"SELECT 1 AS ok FROM assignments WHERE worker_id=? AND project_id=? AND active=1",
).get(w.id, sheet.project_id) as { ok: number } | undefined;
if (!assigned) continue;
if (w.work_type !== "N") {
const hasLine = db.prepare(
"SELECT id FROM payroll_week_lines WHERE sheet_id=? AND worker_id=?",
).get(sheet.id, w.id);
if (!hasLine) continue;
}
prefillThuSat(db, sheet.project_id, week.week_start, w.id);
}
}
const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
if (period) {
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);
}
db.prepare(
"UPDATE payroll_weeks SET status='assembled', assembled_at=datetime('now') WHERE id=?",
).run(weekId);
recalcWeek(db, weekId);
const payable = db.prepare("SELECT payable_net FROM payroll_weeks WHERE id=?").get(weekId) as { payable_net: number };
db.prepare("UPDATE payroll_weeks SET required_net=? WHERE id=?").run(payable.payable_net, weekId);
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 function payWeek(db: Database, weekId: number) {
const week = 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") {
assembleWeek(db, weekId);
}
recalcWeek(db, weekId);
const lines = 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 = 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 = paidInstallments(db, loan.id) + 1;
db.prepare(
`INSERT INTO loan_payments (loan_id, week_id, amount, installment_n, label) VALUES (?, ?, ?, ?, ?)`,
).run(loan.id, weekId, take, n, line.loan_label);
db.prepare("UPDATE loans SET balance = ROUND(balance - ?, 2) WHERE id=?").run(take, loan.id);
}
const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number } | undefined;
if (period) {
const jobs = 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 = 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 (paid.n >= job.qty_total_estimated) {
db.prepare("UPDATE destajo_jobs SET status='done' WHERE id=?").run(job.id);
}
}
}
db.prepare(
"UPDATE payroll_weeks SET status='paid', paid_at=datetime('now') WHERE id=?",
).run(weekId);
}
export function addJornalWorker(db: Database, weekId: number, projectId: number, workerId: number) {
const week = 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 = 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 });
sheetLine(db, sheet.id, workerId, null);
recalcWeek(db, weekId);
}
export function addAdminLine(
db: Database,
weekId: number,
body: { worker_id: number; amount: number; project_id?: number | null },
) {
const week = 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 = 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 = sheetLine(db, sheet.id, body.worker_id, null);
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,
);
recalcWeek(db, weekId);
}
export function updateAdminLine(db: Database, weekId: number, lineId: number, amount: number) {
const week = 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 });
db.prepare("UPDATE payroll_week_lines SET amount=? WHERE id=?").run(roundMoney(amount), lineId);
recalcWeek(db, weekId);
}
export function removeAdminLine(db: Database, weekId: number, lineId: number) {
const week = 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 });
db.prepare("DELETE FROM payroll_week_lines WHERE id=?").run(lineId);
recalcWeek(db, weekId);
}
export function jobRemainder(db: Database, jobId: number): number {
const job = 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 = 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 - paid.n));
}
export function createDestajoJob(
db: Database,
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;
},
): { id: number } {
const week = 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 });
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 = lid(db);
const period = db.prepare("SELECT id FROM destajo_periods WHERE week_id=?").get(weekId) as { id: number };
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);
recalcWeek(db, weekId);
return { id: jobId };
}
export function patchDestajoCut(
db: Database,
weekId: number,
cutId: number,
patch: { qty_planned?: number; qty_actual?: number; qty_extra?: number },
) {
const week = 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 = 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;
if (week.status === "assembled" && patch.qty_planned != null) {
// required already frozen; planned can still be edited for display but does not rewrite required
}
db.prepare("UPDATE destajo_cut_lines SET qty_planned=?, qty_actual=?, qty_extra=? WHERE id=?").run(
planned,
actual,
extra,
cutId,
);
recalcWeek(db, weekId);
}
export function ensureCutForOpenJobs(db: Database, weekId: number) {
const period = 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 = 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) ins.run(period.id, job.id);
}
type AttCell = {
date: string;
present: boolean;
other_project_id: number | null;
other_project_name: string | null;
};
export function getWeekBundle(db: Database, tenantId: number, weekStart: string) {
const weekId = ensureWeek(db, tenantId, weekStart);
ensureCutForOpenJobs(db, weekId);
recalcWeek(db, weekId);
const week = db.prepare(
`SELECT * FROM payroll_weeks WHERE id=?`,
).get(weekId) as Record<string, unknown>;
const period = 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 = 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 = 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: number;
project_name: string;
}[];
const sheets = sheetsRaw.map((sheet) => {
const lines = db.prepare(
`SELECT l.*, w.first_name, w.last_name_p, 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 === 1 && 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 };
});
return { ...sheet, lines: withCells };
});
const jobs = db.prepare(
`SELECT j.*, w.first_name, w.last_name_p, 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 = jobs.map((job) => ({ ...job, remainder: jobRemainder(db, Number(job.id)) }));
const cuts = period
? 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.last_name_p, 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 = getSettings(db, tenantId);
const units = listUnits(db, tenantId);
const required = Number(week.required_net) || 0;
const payable = Number(week.payable_net) || 0;
return {
week,
destajo_period: period,
sheets,
destajo_jobs: jobsOut,
destajo_cuts: cuts,
settings,
units,
days,
board: {
required_net: required,
payable_net: payable,
delta: roundMoney(required - payable),
},
};
}
export function weekCsv(db: Database, weekId: number): string {
const lines = 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 function listOpenWeeks(db: Database, tenantId: number) {
return db.prepare(
`SELECT * FROM payroll_weeks WHERE tenant_id=? AND status != 'paid' ORDER BY week_start DESC`,
).all(tenantId);
}