merge: #19 asistencia y faltas en nómina

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-08 21:12:49 +00:00
commit 9c198a3be7
No known key found for this signature in database
3 changed files with 170 additions and 6 deletions

View file

@ -217,14 +217,109 @@ export async function patchDestajoCut(db: Db, weekId: number, cutId: number, pat
if (!env.ok) throwFromRpc(env); if (!env.ok) throwFromRpc(env);
} }
type AttendanceRow = {
worker_id: number;
project_id: number;
work_date: string;
present: boolean | number;
project_name?: string | null;
};
function isoDay(value: unknown): string {
return String(value ?? "").slice(0, 10);
}
export function weekBoard(week: Record<string, unknown> | null | undefined) {
const required = Number(week?.required_net || 0);
const payable = Number(week?.payable_net || 0);
const loanRecovery = Math.max(0, roundMoney(required - payable));
const faltante = Math.max(0, roundMoney(payable - required));
return {
required_net: required,
payable_net: payable,
loan_recovery: loanRecovery,
faltante,
delta: roundMoney(payable - required),
};
}
async function attachJornalCells(
db: Db,
sheets: Array<Record<string, unknown>>,
days: string[],
tenantId: number,
): Promise<Array<Record<string, unknown>>> {
const from = days[0];
const to = days[days.length - 1];
if (!from || !to) return sheets;
const rows = 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 ?
AND p.tenant_id = ?`,
).all(from, to, tenantId) as AttendanceRow[];
return sheets.map((sheet) => {
if (sheet.kind !== "obra") return sheet;
const projectId = Number(sheet.project_id);
const lines = ((sheet.lines as Array<Record<string, unknown>>) ?? []).map((line) => {
const workerId = Number(line.worker_id);
const cells = days.map((date) => {
const here = rows.find((r) =>
Number(r.worker_id) === workerId &&
isoDay(r.work_date) === date &&
Number(r.project_id) === projectId
);
const other = rows.find((r) =>
Number(r.worker_id) === workerId &&
isoDay(r.work_date) === date &&
Number(r.project_id) !== projectId &&
Boolean(r.present)
);
return {
date,
present: Boolean(here?.present),
other_project_id: other ? Number(other.project_id) : null,
other_project_name: other?.project_name ?? null,
};
});
return { ...line, cells };
});
return { ...sheet, lines };
});
}
export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) { export async function getWeekBundle(db: Db, tenantId: number, weekStart: string) {
await ensureWeek(db, tenantId, weekStart); await ensureWeek(db, tenantId, weekStart);
const env = await callCoreFn<Record<string, unknown>>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart }); const env = await callCoreFn<Record<string, unknown>>(db, "core.fn_payroll_week_bundle", { tenant_id: tenantId, week_start: weekStart });
if (!env.ok) throwFromRpc(env); if (!env.ok) throwFromRpc(env);
const data = env.data ?? {}; const data = env.data ?? {};
const today = await resolveToday(db, tenantId); const today = await resolveToday(db, tenantId);
const start = String((data.week as Record<string, unknown>)?.week_start ?? weekStart); const week = (data.week ?? null) as Record<string, unknown> | null;
return { ...data, can_assemble: canAssembleWeek(start, today), assemble_from: weekAssembleDate(start) }; const start = isoDay(week?.week_start) || weekStart;
const days = weekDays(start);
const sheets = await attachJornalCells(
db,
(data.sheets as Array<Record<string, unknown>>) ?? [],
days,
tenantId,
);
const bounds = destajoPeriodBounds(start);
return {
...data,
week,
sheets,
days,
destajo_period: data.destajo_period ?? {
period_start: bounds.periodStart,
period_end: bounds.periodEnd,
},
board: data.board ?? weekBoard(week),
units: data.units ?? [...DEFAULT_UNITS],
can_assemble: canAssembleWeek(start, today),
assemble_from: weekAssembleDate(start),
};
} }
export async function weekCsv(db: Db, weekId: number): Promise<string> { export async function weekCsv(db: Db, weekId: number): Promise<string> {

View file

@ -21,6 +21,7 @@ import {
thisSaturday, thisSaturday,
weekAssembleDate, weekAssembleDate,
weekContaining, weekContaining,
weekDays,
} from "./payroll.ts"; } from "./payroll.ts";
// tenant_id fijo y solo-de-tests: cada test corre en su propia transacción // tenant_id fijo y solo-de-tests: cada test corre en su propia transacción
@ -81,6 +82,14 @@ Deno.test("semana lun-sáb y corte destajo jue-jue", () => {
const w = weekContaining("2026-08-26"); const w = weekContaining("2026-08-26");
assertEquals(w.weekStart, "2026-08-24"); assertEquals(w.weekStart, "2026-08-24");
assertEquals(w.weekEnd, "2026-08-29"); assertEquals(w.weekEnd, "2026-08-29");
assertEquals(weekDays("2026-08-24"), [
"2026-08-24",
"2026-08-25",
"2026-08-26",
"2026-08-27",
"2026-08-28",
"2026-08-29",
]);
const d = destajoPeriodBounds("2026-08-24"); const d = destajoPeriodBounds("2026-08-24");
assertEquals(d.periodEnd, "2026-08-27"); assertEquals(d.periodEnd, "2026-08-27");
assertEquals(d.periodStart, "2026-08-20"); assertEquals(d.periodStart, "2026-08-20");
@ -265,6 +274,59 @@ Deno.test("un día no se marca en dos obras", async () => {
}); });
}); });
type JornalCell = {
date: string;
present: boolean;
other_project_id: number | null;
other_project_name: string | null;
};
Deno.test("paquete de semana incluye días L–S y celdas de asistencia", async () => {
await withTestDb(async (db) => {
const { companyId, projectId, workerId } = await seedFixture(db);
const project2Id = await addSecondProject(db, companyId);
await setAttendance(db, TENANT_ID, {
project_id: projectId,
worker_id: workerId,
work_date: "2026-08-24",
present: true,
});
await setAttendance(db, TENANT_ID, {
project_id: project2Id,
worker_id: workerId,
work_date: "2026-08-25",
present: true,
});
const bundle = await getWeekBundle(db, TENANT_ID, "2026-08-24");
assertEquals(bundle.days, [
"2026-08-24",
"2026-08-25",
"2026-08-26",
"2026-08-27",
"2026-08-28",
"2026-08-29",
]);
const obra = (bundle.sheets as Array<{
kind: string;
project_id: number;
lines: Array<{ worker_id: number; cells?: JornalCell[] }>;
}>).find((sheet) => sheet.kind === "obra" && Number(sheet.project_id) === projectId);
assertExists(obra);
const line = obra.lines.find((row) => Number(row.worker_id) === workerId);
assertExists(line);
assertEquals(line.cells?.length, 6);
assertEquals(line.cells?.[0], {
date: "2026-08-24",
present: true,
other_project_id: null,
other_project_name: null,
});
assertEquals(line.cells?.[1]?.present, false);
assertEquals(line.cells?.[1]?.other_project_id, project2Id);
assertEquals(line.cells?.[1]?.other_project_name, "Obra Sur");
});
});
Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", async () => { Deno.test("préstamo de personal: el día en otra obra no se prellena aquí", async () => {
await withTestDb(async (db) => { await withTestDb(async (db) => {
const { companyId, projectId, workerId } = await seedFixture(db); const { companyId, projectId, workerId } = await seedFixture(db);

View file

@ -46,9 +46,15 @@
<TabPanels> <TabPanels>
<TabPanel value="obra"> <TabPanel value="obra">
<Message severity="secondary" :closable="false"> <Message severity="secondary" :closable="false">
Lun–sáb. Clic en la persona abre la ficha (consulta). El préstamo se registra con <strong>Préstamo</strong> en la barra o desde la ficha. Marque L–S para asistencia y desmarque para falta. Clic en la persona abre la ficha (consulta). El préstamo se registra con <strong>Préstamo</strong> en la barra o desde la ficha.
</Message> </Message>
<DataTable :value="obraLines" scrollable scroll-height="flex" empty-message="Sin personal de jornal en esta obra"> <DataTable
:value="obraLines"
scrollable
scroll-height="flex"
table-style="min-width: 52rem"
empty-message="Sin personal de jornal en esta obra"
>
<Column header="Persona" frozen> <Column header="Persona" frozen>
<template #body="{ data }"> <template #body="{ data }">
<button type="button" class="person-link" @click="openPerson(data.worker_id)"> <button type="button" class="person-link" @click="openPerson(data.worker_id)">
@ -60,7 +66,7 @@
v-for="(day, idx) in days" v-for="(day, idx) in days"
:key="day" :key="day"
:header="dayHeaders[idx]" :header="dayHeaders[idx]"
style="width: 5.5rem; text-align: center" style="width: 4.2rem; min-width: 3.6rem; text-align: center"
> >
<template #body="{ data }"> <template #body="{ data }">
<Tag <Tag
@ -377,7 +383,8 @@ async function load() {
destajoPeriod.value = bundle.destajo_period; destajoPeriod.value = bundle.destajo_period;
sheets.value = bundle.sheets; sheets.value = bundle.sheets;
destajoCuts.value = bundle.destajo_cuts || []; destajoCuts.value = bundle.destajo_cuts || [];
days.value = bundle.days || []; const start = String(bundle.week?.week_start || weekStart.value);
days.value = bundle.days?.length ? bundle.days : [0, 1, 2, 3, 4, 5].map((n) => addDaysIso(start, n));
board.value = { board.value = {
required_net: 0, required_net: 0,
payable_net: 0, payable_net: 0,