mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 14:33:17 +00:00
Fase 2 (driver): - api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run, placeholders ? -> $n, withTenant con set_config para RLS), con parsers de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto del codigo heredado de SQLite (fechas/montos como string, ids como number) siga funcionando sin reescribir cada call-site a mano. - api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados por base/esquema (panels_platform, panels_product.iam, panels_product.core), owner pool para bootstrap/scripts/lookups administrativos que cruzan tenant a proposito. - api/redis.ts: clientes iam/core separados (ACL panels_iam_redis / panels_core_redis). - api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco, no HMAC autocontenido); revocacion real (logout, cambio de password). - api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage (S3), con fallback a disco local si no hay credenciales S3 (dev). - api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la transaccion con app.tenant_id fijado (RLS) para cada request. - api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la llave; aplicado a /v1/catalogs. Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/ payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts): - Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT, now()/current_date, booleanos reales, RETURNING via lastInsertId()). - IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id ya no dependen de que el handler recuerde el WHERE tenant_id -- Row Level Security lo hace estructuralmente (verificado con un segundo tenant real: 404 en vez de fuga de datos). - API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito. Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion que siempre se revierte, contra el mismo baseline de Liquibase que produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts reescrito con fixtures reales; 11/11 pasan contra Postgres. Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados (ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot desnormalizado (uploaded_by_id/name); seed() en runtime eliminado, reemplazado por scripts/bootstrap-admin.ts (one-shot). Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT), PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone, document_validity.ts ya no usa new Date() crudo. Verificado end-to-end contra Postgres+Redis reales: login, sesiones, catalogos con cache, alta de trabajador, subida/descarga de documento cifrado, y el fix de IDOR probado con un segundo tenant real (403/404 en vez de fuga de datos). Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
1037 lines
40 KiB
TypeScript
1037 lines
40 KiB
TypeScript
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<string> {
|
|
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<PayrollSettings> {
|
|
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<PayrollSettings>,
|
|
): Promise<PayrollSettings> {
|
|
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<void> {
|
|
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<number> {
|
|
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<number> {
|
|
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<number> {
|
|
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<LoanRow[]> {
|
|
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<number> {
|
|
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<void> {
|
|
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<number>();
|
|
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<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 = 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<number> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, unknown>;
|
|
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<string, unknown>[];
|
|
|
|
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<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 === 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<string, unknown>[];
|
|
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<string> {
|
|
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<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 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);
|
|
}
|