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

280 lines
11 KiB
TypeScript

import { assertEquals, assertExists } from "jsr:@std/assert@1";
import { Database } from "@db/sqlite";
import {
addDays,
assembleWeek,
commissionAmount,
createDestajoJob,
createLoan,
destajoPeriodBounds,
ensureWeek,
firstDueForLoan,
getWeekBundle,
jobRemainder,
nextSaturdayAfterThisWeek,
patchDestajoCut,
payWeek,
roundMoney,
setAttendance,
thisSaturday,
weekContaining,
} from "./payroll.ts";
function mem() {
const db = new Database(":memory:");
db.exec("PRAGMA foreign_keys = ON");
db.exec(`
CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT, tenant_id INTEGER);
CREATE TABLE projects (
id INTEGER PRIMARY KEY, code TEXT, name TEXT, status TEXT DEFAULT 'activo', tenant_id INTEGER
);
CREATE TABLE workers (
id INTEGER PRIMARY KEY,
first_name TEXT, last_name_p TEXT, position TEXT DEFAULT 'Ayudante',
work_type TEXT DEFAULT 'N', daily_wage REAL DEFAULT 0,
status TEXT DEFAULT 'activo', pipeline_status TEXT DEFAULT 'activo', tenant_id INTEGER
);
CREATE TABLE assignments (
id INTEGER PRIMARY KEY, worker_id INTEGER, project_id INTEGER, active INTEGER DEFAULT 1
);
CREATE TABLE attendance (
id INTEGER PRIMARY KEY,
worker_id INTEGER, project_id INTEGER, work_date TEXT, present INTEGER DEFAULT 1,
UNIQUE (worker_id, project_id, work_date)
);
CREATE TABLE loans (
id INTEGER PRIMARY KEY,
worker_id INTEGER, amount REAL, delivered REAL DEFAULT 0, balance REAL,
weekly_payment REAL DEFAULT 0, note TEXT,
commission_pct REAL DEFAULT 0, commission_amount REAL DEFAULT 0,
plan TEXT DEFAULT 'single', installments_n INTEGER DEFAULT 1, first_due TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE payroll_settings (
tenant_id INTEGER PRIMARY KEY,
loan_commission_enabled INTEGER DEFAULT 1,
loan_commission_pct REAL DEFAULT 10,
loan_small_max REAL DEFAULT 500
);
CREATE TABLE payroll_weeks (
id INTEGER PRIMARY KEY,
week_start TEXT, week_end TEXT, status TEXT DEFAULT 'draft',
required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0,
assembled_at TEXT, paid_at TEXT, tenant_id INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX idx_weeks ON payroll_weeks(tenant_id, week_start);
CREATE TABLE payroll_sheets (
id INTEGER PRIMARY KEY, week_id INTEGER, kind TEXT, project_id INTEGER
);
CREATE UNIQUE INDEX idx_sheets ON payroll_sheets(week_id, kind, IFNULL(project_id, 0));
CREATE TABLE payroll_week_lines (
id INTEGER PRIMARY KEY,
sheet_id INTEGER, worker_id INTEGER, destajo_cut_line_id INTEGER, project_id INTEGER,
days REAL DEFAULT 0, daily_wage REAL DEFAULT 0,
qty_planned REAL DEFAULT 0, qty_actual REAL DEFAULT 0, qty_extra REAL DEFAULT 0,
unit_price REAL DEFAULT 0, unit_code TEXT, concepto TEXT,
amount REAL DEFAULT 0, gross REAL DEFAULT 0, discounts REAL DEFAULT 0,
loan_id INTEGER, loan_discount REAL DEFAULT 0, loan_label TEXT,
required_net REAL DEFAULT 0, payable_net REAL DEFAULT 0
);
CREATE TABLE destajo_units (
id INTEGER PRIMARY KEY, tenant_id INTEGER, code TEXT, label TEXT, UNIQUE(tenant_id, code)
);
CREATE TABLE destajo_periods (
id INTEGER PRIMARY KEY, period_start TEXT, period_end TEXT, week_id INTEGER, tenant_id INTEGER,
UNIQUE(tenant_id, period_end)
);
CREATE TABLE destajo_jobs (
id INTEGER PRIMARY KEY, project_id INTEGER, worker_id INTEGER, concepto TEXT,
unit_code TEXT, qty_total_estimated REAL, unit_price REAL, status TEXT DEFAULT 'open',
tenant_id INTEGER DEFAULT 0
);
CREATE TABLE destajo_cut_lines (
id INTEGER PRIMARY KEY, period_id INTEGER, job_id INTEGER,
qty_planned REAL DEFAULT 0, qty_actual REAL DEFAULT 0, qty_extra REAL DEFAULT 0,
UNIQUE(period_id, job_id)
);
CREATE TABLE loan_payments (
id INTEGER PRIMARY KEY, loan_id INTEGER, week_id INTEGER, amount REAL,
installment_n INTEGER, label TEXT, created_at TEXT DEFAULT (datetime('now'))
);
`);
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (1, 'PRY-0001', 'Obra Norte', 'activo', 1)").run();
db.prepare(
"INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (1, 'Juan', 'Perez', 'N', 500, 1)",
).run();
db.prepare("INSERT INTO assignments (worker_id, project_id, active) VALUES (1, 1, 1)").run();
return db;
}
Deno.test("semana lun-sáb y corte destajo jue-jue", () => {
const w = weekContaining("2026-08-26");
assertEquals(w.weekStart, "2026-08-24");
assertEquals(w.weekEnd, "2026-08-29");
const d = destajoPeriodBounds("2026-08-24");
assertEquals(d.periodEnd, "2026-08-27");
assertEquals(d.periodStart, "2026-08-20");
});
Deno.test("comisión suma al saldo", () => {
assertEquals(commissionAmount(500, 10), 50);
assertEquals(roundMoney(500 + 50), 550);
});
Deno.test("préstamo chico vence el sábado de la semana siguiente", () => {
assertEquals(thisSaturday("2026-08-18"), "2026-08-22");
assertEquals(nextSaturdayAfterThisWeek("2026-08-18"), "2026-08-29");
assertEquals(
firstDueForLoan({
delivered: 500,
smallMax: 500,
plan: "single",
grantIso: "2026-08-18",
weekAssembled: false,
}),
"2026-08-29",
);
assertEquals(
firstDueForLoan({
delivered: 2000,
smallMax: 500,
plan: "single",
grantIso: "2026-08-18",
weekAssembled: false,
}),
"2026-08-22",
);
assertEquals(
firstDueForLoan({
delivered: 2000,
smallMax: 500,
plan: "single",
grantIso: "2026-08-21",
weekAssembled: true,
}),
"2026-08-29",
);
});
Deno.test("armar jueves prellena jue-vie-sáb y congela requerida", () => {
const db = mem();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId);
const week = db.prepare("SELECT * FROM payroll_weeks WHERE id=?").get(weekId) as {
status: string;
required_net: number;
payable_net: number;
};
assertEquals(week.status, "assembled");
assertEquals(week.required_net, 2500);
assertEquals(week.payable_net, 2500);
const thu = db.prepare(
"SELECT present FROM attendance WHERE worker_id=1 AND work_date='2026-08-27'",
).get() as { present: number };
assertEquals(thu.present, 1);
});
Deno.test("falta viernes baja a pagar y no toca requerida", () => {
const db = mem();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId);
const r = setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-28", present: false });
assertEquals("ok" in r && r.ok, true);
const week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number;
payable_net: number;
};
assertEquals(week.required_net, 2500);
assertEquals(week.payable_net, 2000);
});
Deno.test("préstamo no baja saldo al armar; sí al pagar", () => {
const db = mem();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-24', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-25', 1)").run();
db.prepare("INSERT INTO attendance (worker_id, project_id, work_date, present) VALUES (1, 1, '2026-08-26', 1)").run();
createLoan(db, 1, { worker_id: 1, delivered: 2000, commission_pct: 10, grantIso: "2026-08-24" });
const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId);
const before = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
assertEquals(before.balance, 2200);
const week = db.prepare("SELECT required_net FROM payroll_weeks WHERE id=?").get(weekId) as { required_net: number };
assertEquals(week.required_net, 800);
payWeek(db, weekId);
const after = db.prepare("SELECT balance FROM loans WHERE worker_id=1").get() as { balance: number };
assertEquals(after.balance, 0);
});
Deno.test("un día no se marca en dos obras", () => {
const db = mem();
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
setAttendance(db, 1, { project_id: 1, worker_id: 1, work_date: "2026-08-24", present: true });
const blocked = setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-24", present: true });
assertEquals("error" in blocked, true);
});
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", () => {
const db = mem();
db.prepare("INSERT INTO projects (id, code, name, status, tenant_id) VALUES (2, 'PRY-0002', 'Obra Sur', 'activo', 1)").run();
setAttendance(db, 1, { project_id: 2, worker_id: 1, work_date: "2026-08-27", present: true });
const weekId = ensureWeek(db, 1, "2026-08-24");
assembleWeek(db, weekId);
const onA = db.prepare(
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=1 AND work_date='2026-08-27'",
).get() as { present: number } | undefined;
assertEquals(onA, undefined);
const onB = db.prepare(
"SELECT present FROM attendance WHERE worker_id=1 AND project_id=2 AND work_date='2026-08-27'",
).get() as { present: number };
assertEquals(onB.present, 1);
});
Deno.test("destajo: estimado del corte, realizado menor, remanente y extra viernes", () => {
const db = mem();
db.prepare("DELETE FROM assignments WHERE worker_id=1").run();
db.prepare(
"INSERT INTO workers (id, first_name, last_name_p, work_type, daily_wage, tenant_id) VALUES (2, 'Luis', 'Destajo', 'D', 0, 1)",
).run();
const weekId = ensureWeek(db, 1, "2026-08-24");
const job = createDestajoJob(db, 1, weekId, {
project_id: 1,
worker_id: 2,
concepto: "Muro tablaroca",
unit_code: "m2",
qty_total_estimated: 200,
unit_price: 100,
qty_planned: 80,
});
assembleWeek(db, weekId);
let week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number;
payable_net: number;
};
assertEquals(week.required_net, 8000);
const cut = db.prepare("SELECT id FROM destajo_cut_lines WHERE job_id=?").get(job.id) as { id: number };
patchDestajoCut(db, weekId, cut.id, { qty_actual: 50 });
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number;
payable_net: number;
};
assertEquals(week.required_net, 8000);
assertEquals(week.payable_net, 5000);
patchDestajoCut(db, weekId, cut.id, { qty_extra: 10 });
week = db.prepare("SELECT required_net, payable_net FROM payroll_weeks WHERE id=?").get(weekId) as {
required_net: number;
payable_net: number;
};
assertEquals(week.payable_net, 6000);
payWeek(db, weekId);
assertEquals(jobRemainder(db, job.id), 140);
const nextId = ensureWeek(db, 1, addDays("2026-08-24", 7));
assertExists(nextId);
const bundle = getWeekBundle(db, 1, "2026-08-31");
assertEquals((bundle.destajo_jobs as { remainder: number }[])[0].remainder, 140);
});