mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 11:23:18 +00:00
323 lines
13 KiB
TypeScript
323 lines
13 KiB
TypeScript
import { assertEquals, assertExists, assertThrows } from "jsr:@std/assert@1";
|
|
import { Database } from "@db/sqlite";
|
|
import {
|
|
addDays,
|
|
assembleWeek,
|
|
canAssembleWeek,
|
|
commissionAmount,
|
|
createDestajoJob,
|
|
createLoan,
|
|
destajoPeriodBounds,
|
|
ensureWeek,
|
|
firstDueForLoan,
|
|
getWeekBundle,
|
|
jobRemainder,
|
|
nextSaturdayAfterThisWeek,
|
|
patchDestajoCut,
|
|
payWeek,
|
|
roundMoney,
|
|
setAttendance,
|
|
thisSaturday,
|
|
weekAssembleDate,
|
|
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("armar jueves solo a partir del jueves de esa semana", () => {
|
|
assertEquals(weekAssembleDate("2026-08-24"), "2026-08-27");
|
|
assertEquals(canAssembleWeek("2026-08-24", "2026-08-26"), false);
|
|
assertEquals(canAssembleWeek("2026-08-24", "2026-08-27"), true);
|
|
assertEquals(canAssembleWeek("2026-08-24", "2026-08-29"), true);
|
|
assertEquals(canAssembleWeek("2026-08-24", "2026-08-31"), true);
|
|
assertEquals(canAssembleWeek("2026-08-31", "2026-08-26"), false);
|
|
const db = mem();
|
|
const weekId = ensureWeek(db, 1, "2026-08-24");
|
|
assertThrows(
|
|
() => assembleWeek(db, weekId, "2026-08-26"),
|
|
Error,
|
|
"jueves",
|
|
);
|
|
const draft = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
|
assertEquals(draft.status, "draft");
|
|
assembleWeek(db, weekId, "2026-08-28");
|
|
const after = db.prepare("SELECT status FROM payroll_weeks WHERE id=?").get(weekId) as { status: string };
|
|
assertEquals(after.status, "assembled");
|
|
});
|
|
|
|
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, "2026-08-27");
|
|
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, "2026-08-27");
|
|
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, "2026-08-27");
|
|
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("tablero: recuperación de préstamos no es sobrante", () => {
|
|
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, "2026-08-27");
|
|
createLoan(db, 1, { worker_id: 1, delivered: 1000, commission_pct: 20, grantIso: "2026-08-24" });
|
|
const bundle = getWeekBundle(db, 1, "2026-08-24");
|
|
const board = bundle.board as {
|
|
required_net: number;
|
|
payable_net: number;
|
|
loan_recovery: number;
|
|
faltante: number;
|
|
};
|
|
assertEquals(board.required_net, 2500);
|
|
assertEquals(board.loan_recovery, 1200);
|
|
assertEquals(board.payable_net, 1300);
|
|
assertEquals(board.faltante, 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, "2026-08-27");
|
|
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, "2026-08-27");
|
|
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);
|
|
});
|