diff --git a/api/auth.ts b/api/auth.ts index 74a49e2..f0a79b4 100644 --- a/api/auth.ts +++ b/api/auth.ts @@ -163,7 +163,8 @@ export function tenantScope(user: AuthUser): number | null { export async function login(username: string, password: string): Promise { const user = username.trim(); - if (!user) return null; + const pass = password.trim(); + if (!user || !pass) return null; const db = await getDb(); const appRow = db.prepare( @@ -171,7 +172,7 @@ export async function login(username: string, password: string): Promise & { password_hash: string }) | undefined; - if (appRow && await verifyPassword(password, appRow.password_hash)) { + if (appRow && await verifyPassword(pass, appRow.password_hash)) { const authUser = asAppUser(appRow); const { tenantAccessBlocked } = await import("./saas.ts"); const pdb = await getPlatformDb(); @@ -189,7 +190,7 @@ export async function login(username: string, password: string): Promise { await mkdir(PDFS_DIR, { recursive: true }); await mkdir(LOGOS_DIR, { recursive: true }); await mkdir(PROJECTS_DIR, { recursive: true }); + await mkdir(COMPANIES_DIR, { recursive: true }); await runLiquibase(); db = new Database(DB_PATH); db.exec("PRAGMA foreign_keys = ON;"); @@ -28,15 +29,16 @@ export async function getDb(): Promise { export function seedDocumentTypes(database: Database) { const upsert = database.prepare( `INSERT INTO document_types - (code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + (code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(code) DO UPDATE SET label = excluded.label, required = excluded.required, validity_mode = excluded.validity_mode, freshness_days = excluded.freshness_days, requires_issued_at = excluded.requires_issued_at, - requires_expires_at = excluded.requires_expires_at`, + requires_expires_at = excluded.requires_expires_at, + category = excluded.category`, ); for (const d of DOCUMENT_TYPE_SEED) { upsert.run( @@ -47,10 +49,39 @@ export function seedDocumentTypes(database: Database) { d.freshness_days, d.requires_issued_at, d.requires_expires_at, + d.category, ); } } +export function seedProjectDocumentTypes(database: Database) { + const upsert = database.prepare( + `INSERT INTO project_document_types (code, label, required, category) + VALUES (?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + label = excluded.label, + required = excluded.required, + category = excluded.category`, + ); + for (const d of PROJECT_DOCUMENT_TYPE_SEED) { + upsert.run(d.code, d.label, d.required, d.category); + } +} + +export function seedCompanyDocumentTypes(database: Database) { + const upsert = database.prepare( + `INSERT INTO company_document_types (code, label, required, category) + VALUES (?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + label = excluded.label, + required = excluded.required, + category = excluded.category`, + ); + for (const d of COMPANY_DOCUMENT_TYPE_SEED) { + upsert.run(d.code, d.label, d.required, d.category); + } +} + export function nextProjectCode(database: Database): string { const row = database.prepare( `SELECT COALESCE(MAX(CAST(substr(code, 5) AS INTEGER)), 0) + 1 AS n @@ -114,17 +145,8 @@ async function seed(database: Database) { ); seedDocumentTypes(database); - - const projectDocs = [ - ["contrato", "Contrato", 1], - ["presupuesto", "Presupuesto", 1], - ["planos", "Planos", 0], - ["otro", "Otro", 0], - ]; - const insPDoc = database.prepare( - "INSERT OR IGNORE INTO project_document_types (code, label, required) VALUES (?, ?, ?)", - ); - for (const d of projectDocs) insPDoc.run(...d); + seedProjectDocumentTypes(database); + seedCompanyDocumentTypes(database); const defaultTenantId = 1; let principal = database.prepare( @@ -210,7 +232,7 @@ export function workerImssContext(database: Database, workerId: number): WorkerI export function checklistItemsFor(database: Database, workerId: number) { const types = database.prepare( `SELECT code, label, required, validity_mode, freshness_days, - requires_issued_at, requires_expires_at + requires_issued_at, requires_expires_at, category FROM document_types`, ).all() as { code: string; @@ -220,6 +242,7 @@ export function checklistItemsFor(database: Database, workerId: number) { freshness_days: number | null; requires_issued_at: number; requires_expires_at: number; + category: string; }[]; const currentDocs = database.prepare( `SELECT type_code, issued_at, expires_at, uploaded_at, imss_alta_at @@ -256,6 +279,7 @@ export function checklistItemsFor(database: Database, workerId: number) { freshness_days: t.freshness_days, requires_issued_at: policy.requires_issued_at, requires_expires_at: policy.requires_expires_at, + category: t.category || "otro", present: evaled.present, valid: evaled.valid, validity_status: evaled.validity_status, @@ -328,10 +352,14 @@ export function projectDir(projectId: number): string { return join(PROJECTS_DIR, String(projectId)); } +export function companyDir(companyId: number): string { + return join(COMPANIES_DIR, String(companyId)); +} + export function projectChecklistFor(database: Database, projectId: number) { const types = database.prepare( - "SELECT code, label, required FROM project_document_types", - ).all() as { code: string; label: string; required: number }[]; + "SELECT code, label, required, category FROM project_document_types", + ).all() as { code: string; label: string; required: number; category: string }[]; const current = database.prepare( `SELECT type_code FROM project_documents WHERE project_id = ? AND is_current = 1`, ).all(projectId) as { type_code: string }[]; @@ -340,6 +368,24 @@ export function projectChecklistFor(database: Database, projectId: number) { code: t.code, label: t.label, required: !!t.required, + category: t.category || "otro", + present: have.has(t.code), + })); +} + +export function companyChecklistFor(database: Database, companyId: number) { + const types = database.prepare( + "SELECT code, label, required, category FROM company_document_types", + ).all() as { code: string; label: string; required: number; category: string }[]; + const current = database.prepare( + `SELECT type_code FROM company_documents WHERE company_id = ? AND is_current = 1`, + ).all(companyId) as { type_code: string }[]; + const have = new Set(current.map((c) => c.type_code)); + return types.map((t) => ({ + code: t.code, + label: t.label, + required: !!t.required, + category: t.category || "otro", present: have.has(t.code), })); } diff --git a/api/document_validity.ts b/api/document_validity.ts index e02bf9a..404c2d6 100644 --- a/api/document_validity.ts +++ b/api/document_validity.ts @@ -148,23 +148,72 @@ export function evaluateDocumentValidity( }; } -export const DOCUMENT_TYPE_SEED: Array<{ +export type CatalogDocType = { code: string; label: string; required: number; + category: string; validity_mode: ValidityMode; freshness_days: number | null; requires_issued_at: number; requires_expires_at: number; -}> = [ - { code: "foto", label: "Foto", required: 1, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, - { code: "ine", label: "INE", required: 1, validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 }, - { code: "curp", label: "Constancia CURP", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, - { code: "nss", label: "Constancia NSS", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, - { code: "rfc", label: "Constancia RFC", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, - { code: "domicilio", label: "Comprobante de domicilio", required: 1, validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, - { code: "alta_imss", label: "Alta IMSS", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, - { code: "baja_imss", label: "Baja IMSS", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, - { code: "contrato", label: "Contrato", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, - { code: "otro", label: "Otro", required: 0, validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, +}; + +/** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */ +export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [ + { code: "foto", label: "Foto", required: 1, category: "identidad", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 }, + { code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, + { code: "alta_imss", label: "Alta IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "baja_imss", label: "Baja IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "aviso_privacidad", label: "Aviso de privacidad", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "dc3_nom031", label: "DC-3 NOM-031 (construcción)", required: 0, category: "sst", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "epp", label: "Acuse de entrega de EPP", required: 0, category: "sst", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "medico", label: "Certificado médico", required: 0, category: "sst", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "dc3_altura", label: "DC-3 trabajo en alturas", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "dc3_soldadura", label: "DC-3 soldadura", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "dc3_montacargas", label: "DC-3 montacargas", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "licencia", label: "Licencia de conducir", required: 0, category: "oficio", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "cedula", label: "Cédula profesional", required: 0, category: "oficio", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, + { code: "otro", label: "Otro", required: 0, category: "otro", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, +]; + +export const PROJECT_DOCUMENT_TYPE_SEED: Array<{ + code: string; + label: string; + required: number; + category: string; +}> = [ + { code: "contrato", label: "Contrato", required: 1, category: "contrato" }, + { code: "presupuesto", label: "Presupuesto", required: 1, category: "contrato" }, + { code: "planos", label: "Planos", required: 0, category: "tecnico" }, + { code: "licencia_construccion", label: "Licencia de construcción", required: 0, category: "permisos" }, + { code: "uso_suelo", label: "Uso de suelo", required: 0, category: "permisos" }, + { code: "proteccion_civil", label: "Anuencia de Protección Civil", required: 0, category: "permisos" }, + { code: "mia", label: "MIA / resolutivo ambiental", required: 0, category: "ambiental" }, + { code: "siroc_acuse", label: "Acuse SIROC", required: 0, category: "imss" }, + { code: "programa_sst", label: "Programa de seguridad", required: 0, category: "sst" }, + { code: "seguro_obra", label: "Seguro de obra", required: 0, category: "contrato" }, + { code: "fianza", label: "Fianza", required: 0, category: "contrato" }, + { code: "otro", label: "Otro", required: 0, category: "otro" }, +]; + +export const COMPANY_DOCUMENT_TYPE_SEED: Array<{ + code: string; + label: string; + required: number; + category: string; +}> = [ + { code: "repse_federal", label: "REPSE federal (STPS)", required: 0, category: "repse" }, + { code: "repse_estatal", label: "REPSE estatal (SATQ)", required: 0, category: "repse" }, + { code: "opinion_sat", label: "Opinión de cumplimiento SAT (32-D)", required: 0, category: "fiscal" }, + { code: "opinion_imss", label: "Opinión de cumplimiento IMSS", required: 0, category: "fiscal" }, + { code: "opinion_infonavit", label: "Opinión de cumplimiento INFONAVIT", required: 0, category: "fiscal" }, + { code: "acta_constitutiva", label: "Acta constitutiva", required: 0, category: "legal" }, + { code: "poder_notarial", label: "Poder notarial", required: 0, category: "legal" }, + { code: "otro", label: "Otro", required: 0, category: "otro" }, ]; diff --git a/api/document_validity_test.ts b/api/document_validity_test.ts index 65bd8d7..1010730 100644 --- a/api/document_validity_test.ts +++ b/api/document_validity_test.ts @@ -2,6 +2,9 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { evaluateDocumentValidity, freshnessRequired, + DOCUMENT_TYPE_SEED, + PROJECT_DOCUMENT_TYPE_SEED, + COMPANY_DOCUMENT_TYPE_SEED, type DocTypePolicy, type WorkerImssContext, } from "./document_validity.ts"; @@ -122,3 +125,17 @@ Deno.test("missing issued_at for freshness", () => { ); assertEquals(result.validity_status, "missing_dates"); }); + +Deno.test("núcleo de persona no se agranda", () => { + const required = DOCUMENT_TYPE_SEED.filter((d) => d.required).map((d) => d.code).sort(); + assertEquals(required, ["curp", "domicilio", "foto", "ine", "nss", "rfc"]); +}); + +Deno.test("núcleo de obra no se agranda", () => { + const required = PROJECT_DOCUMENT_TYPE_SEED.filter((d) => d.required).map((d) => d.code).sort(); + assertEquals(required, ["contrato", "presupuesto"]); +}); + +Deno.test("empresa no tiene documentos obligatorios", () => { + assertEquals(COMPANY_DOCUMENT_TYPE_SEED.every((d) => d.required === 0), true); +}); diff --git a/api/excel.ts b/api/excel.ts index a6382e5..1dbec39 100644 --- a/api/excel.ts +++ b/api/excel.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { encryptBytes } from "./docs_crypto.ts"; import { sha256Hex } from "./crypto.ts"; import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; -import { refreshPipeline, workerDir, lastInsertId, projectDir } from "./db.ts"; +import { refreshPipeline, workerDir, lastInsertId, projectDir, companyDir } from "./db.ts"; import { resolveCompany } from "./companies.ts"; export const IMPORT_COLUMNS = [ @@ -325,6 +325,34 @@ export async function storeProjectDocument( ).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); } +export async function storeCompanyDocument( + db: Database, + companyId: number, + type: string, + filename: string, + mime: string, + bytes: Uint8Array, + userId: number | null, +) { + const allowed = db.prepare("SELECT code FROM company_document_types WHERE code = ?").get(type); + if (!allowed) throw new Error("Tipo de documento no válido"); + const { iv, cipher } = await encryptBytes(bytes); + const hash = await sha256Hex(bytes); + const storage = `${crypto.randomUUID()}.enc`; + const dir = companyDir(companyId); + await mkdir(dir, { recursive: true }); + await Deno.writeFile(join(dir, storage), cipher); + db.prepare("UPDATE company_documents SET is_current = 0 WHERE company_id = ? AND type_code = ?").run( + companyId, + type, + ); + db.prepare( + `INSERT INTO company_documents + (company_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 'manual', ?)`, + ).run(companyId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); +} + async function fetchPhoto(url: string): Promise { if (!url.startsWith("http")) return null; try { diff --git a/api/main.ts b/api/main.ts index 733278a..53639e6 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; -import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts"; +import { getDb, checklistFor, refreshPipeline, workerDir, lastInsertId, nextProjectCode, isProjectStatus, projectById, projectMustBe, PROJECT_STATUS_CATALOG, projectDir, projectChecklistFor, companyDir, companyChecklistFor, imssFlagsFor, checklistItemsFor } from "./db.ts"; import { config } from "./config.ts"; import { clearSession, createSessionCookie, login, changePassword, requireAuth, requirePlatformAdmin, tenantScope, type AuthUser } from "./auth.ts"; import { getPlatformDb } from "./platform_db.ts"; @@ -8,7 +8,7 @@ import { createTenant, getTenantDetail, issueTenantAdminAccess, listTenants, upd import { smtpConfigured, testSmtp } from "./mail.ts"; import { saveSmtpSettings, smtpPublicView } from "./smtp.ts"; import { decryptBytes } from "./docs_crypto.ts"; -import { importExcel, storeDocument, storeProjectDocument, findExisting, assign, buildImportTemplate } from "./excel.ts"; +import { importExcel, storeDocument, storeProjectDocument, storeCompanyDocument, findExisting, assign, buildImportTemplate } from "./excel.ts"; import { normalizeWorker, validateCurp, @@ -23,7 +23,8 @@ import { type WorkerInput, } from "./mx.ts"; import { generateBadgePdf, saveJobPdf, loadCurrentPhoto, badgeQrPng } from "./pdf.ts"; -import { createSubcompany, listCompanies, resolveCompany, updateCompany } from "./companies.ts"; +import { registerPayrollRoutes } from "./payroll_http.ts"; +import { createSubcompany, listCompanies, resolveCompany, updateCompany, companyById } from "./companies.ts"; import { buildBudgetTemplate, exportBudgetWorkbook, @@ -36,6 +37,17 @@ import { join } from "node:path"; const app = new Hono<{ Variables: { user: AuthUser } }>(); +function scopedCompany( + db: Awaited>, + id: number, + tid: number | null, +) { + const company = companyById(db, id); + if (!company) return undefined; + if (tid != null && company.tenant_id != null && company.tenant_id !== tid) return undefined; + return company; +} + function corsOrigins(): string[] { const defaults = [ "http://localhost:3000", @@ -199,6 +211,7 @@ app.get("/v1/catalogs", requireAuth, async (c) => { themes: db.prepare("SELECT * FROM badge_themes").all(), document_types: db.prepare("SELECT * FROM document_types").all(), project_document_types: db.prepare("SELECT * FROM project_document_types").all(), + company_document_types: db.prepare("SELECT * FROM company_document_types").all(), project_statuses: PROJECT_STATUS_CATALOG, companies: listCompanies(db, tid), }); @@ -228,6 +241,59 @@ app.patch("/v1/companies/:id", requireAuth, async (c) => { return c.json({ company: result.company }); }); +app.get("/v1/companies/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const company = scopedCompany(db, id, tenantScope(c.get("user"))); + if (!company) return c.json({ error: "Empresa no encontrada" }, 404); + const documents = db.prepare( + "SELECT id, type_code, original_name, mime, size_bytes, is_current, uploaded_at FROM company_documents WHERE company_id = ? ORDER BY uploaded_at DESC", + ).all(id); + return c.json({ + company, + documents, + checklist: companyChecklistFor(db, id), + }); +}); + +app.post("/v1/companies/:id/documents", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const form = await c.req.formData(); + const type = String(form.get("type") || ""); + const file = form.get("file"); + if (!(file instanceof File) || !type) return c.json({ error: "type y file requeridos" }, 400); + const db = await getDb(); + if (!scopedCompany(db, id, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); + const bytes = new Uint8Array(await file.arrayBuffer()); + try { + await storeCompanyDocument(db, id, type, file.name, file.type || "application/octet-stream", bytes, c.get("user").id); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : "No se pudo guardar el documento" }, 400); + } + return c.json({ ok: true, checklist: companyChecklistFor(db, id) }); +}); + +app.get("/v1/companies/:id/documents/:docId", requireAuth, async (c) => { + const companyId = Number(c.req.param("id")); + const docId = Number(c.req.param("docId")); + const db = await getDb(); + if (!scopedCompany(db, companyId, tenantScope(c.get("user")))) return c.json({ error: "Empresa no encontrada" }, 404); + const doc = db.prepare( + "SELECT * FROM company_documents WHERE id=? AND company_id=?", + ).get(docId, companyId) as { + storage_name: string; + iv: string; + mime: string; + original_name: string; + } | undefined; + if (!doc) return c.json({ error: "Documento no encontrado" }, 404); + const enc = await Deno.readFile(join(companyDir(companyId), doc.storage_name)); + const plain = await decryptBytes(doc.iv, enc); + c.header("Content-Type", doc.mime); + c.header("Content-Disposition", `inline; filename="${doc.original_name}"`); + return c.body(plain.buffer as ArrayBuffer); +}); + app.get("/v1/projects", requireAuth, async (c) => { const db = await getDb(); const tid = tenantScope(c.get("user")); @@ -749,7 +815,7 @@ app.get("/v1/workers/:id", requireAuth, async (c) => { const { items, freshness_required } = checklistItemsFor(db, id); const flags = imssFlagsFor(db, id); const docTypes = db.prepare( - `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at + `SELECT code, label, required, validity_mode, freshness_days, requires_issued_at, requires_expires_at, category FROM document_types ORDER BY required DESC, label`, ).all(); return c.json({ @@ -1094,165 +1160,7 @@ app.patch("/v1/badge-jobs/:id/people/:workerId", requireAuth, async (c) => { return c.json({ ok: true }); }); -app.get("/v1/attendance", requireAuth, async (c) => { - const projectId = Number(c.req.query("project_id")); - const from = c.req.query("from") ?? ""; - const to = c.req.query("to") ?? ""; - const db = await getDb(); - const rows = db.prepare( - `SELECT a.*, w.first_name, w.last_name_p FROM attendance a - JOIN workers w ON w.id=a.worker_id - WHERE a.project_id=? AND a.work_date BETWEEN ? AND ? - ORDER BY a.work_date, w.last_name_p`, - ).all(projectId, from, to); - return c.json({ attendance: rows }); -}); - -app.put("/v1/attendance", requireAuth, async (c) => { - const body = await c.req.json<{ - project_id: number; - worker_id: number; - work_date: string; - present: boolean; - }>(); - const db = await getDb(); - const blocked = projectMustBe( - projectById(db, body.project_id), - ["activo", "pausado"], - "No se registra asistencia en un proyecto concluido o cancelado", - ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); - 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); - return c.json({ ok: true }); -}); - -app.get("/v1/loans", requireAuth, async (c) => { - const db = await getDb(); - const workerId = c.req.query("worker_id"); - const sql = workerId - ? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC" - : "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC"; - const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all(); - return c.json({ loans }); -}); - -app.post("/v1/loans", requireAuth, async (c) => { - const body = await c.req.json<{ - worker_id: number; - amount: number; - weekly_payment: number; - note?: string; - }>(); - if (!body.worker_id || !body.amount) return c.json({ error: "worker_id y amount requeridos" }, 400); - const db = await getDb(); - db.prepare( - "INSERT INTO loans (worker_id, amount, balance, weekly_payment, note) VALUES (?, ?, ?, ?, ?)", - ).run(body.worker_id, body.amount, body.amount, body.weekly_payment ?? 0, body.note ?? null); - return c.json({ id: lastInsertId(db) }, 201); -}); - -app.post("/v1/payroll/periods", requireAuth, async (c) => { - const body = await c.req.json<{ - project_id: number; - week_start: string; - week_end: string; - extra_discounts?: Record; - }>(); - const db = await getDb(); - const blocked = projectMustBe( - projectById(db, body.project_id), - ["activo", "pausado"], - "No se genera nómina de un proyecto concluido o cancelado", - ); - if (blocked) return c.json({ error: blocked.error }, blocked.status); - db.prepare( - "INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')", - ).run(body.project_id, body.week_start, body.week_end); - const periodId = lastInsertId(db); - const workers = db.prepare( - `SELECT w.id, w.daily_wage FROM workers w - JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=1 - WHERE w.status='activo'`, - ).all(body.project_id) as { id: number; daily_wage: number }[]; - - const ins = db.prepare( - `INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ); - for (const w of workers) { - 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, body.project_id, body.week_start, body.week_end) as { n: number }; - const days = att.n; - const gross = days * w.daily_wage; - const extra = Number(body.extra_discounts?.[w.id] ?? 0); - const loan = db.prepare( - "SELECT id, balance, weekly_payment FROM loans WHERE worker_id=? AND balance>0 ORDER BY id LIMIT 1", - ).get(w.id) as { id: number; balance: number; weekly_payment: number } | undefined; - let loanPay = 0; - if (loan) { - loanPay = Math.min(loan.weekly_payment, loan.balance, Math.max(0, gross - extra)); - db.prepare("UPDATE loans SET balance = balance - ? WHERE id=?").run(loanPay, loan.id); - } - const net = gross - extra - loanPay; - ins.run(periodId, w.id, days, w.daily_wage, gross, extra, loanPay, net); - } - return c.json({ id: periodId }); -}); - -app.get("/v1/payroll/periods", requireAuth, async (c) => { - const db = await getDb(); - const projectId = c.req.query("project_id"); - const sql = projectId - ? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC` - : `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`; - const periods = projectId ? db.prepare(sql).all(Number(projectId)) : db.prepare(sql).all(); - return c.json({ periods }); -}); - -app.get("/v1/payroll/periods/:id", requireAuth, async (c) => { - const id = Number(c.req.param("id")); - const db = await getDb(); - const period = db.prepare( - `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`, - ).get(id); - if (!period) return c.json({ error: "Periodo no encontrado" }, 404); - const lines = db.prepare( - `SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l - JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`, - ).all(id); - return c.json({ period, lines }); -}); - -app.get("/v1/payroll/periods/:id/csv", requireAuth, async (c) => { - const id = Number(c.req.param("id")); - const db = await getDb(); - const lines = db.prepare( - `SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net - FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`, - ).all(id) as Record[]; - const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto"; - const rows = lines.map((l) => - [l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",") - ); - const csv = [header, ...rows].join("\n"); - c.header("Content-Type", "text/csv; charset=utf-8"); - c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`); - return c.body(csv); -}); - -app.patch("/v1/payroll/periods/:id", requireAuth, async (c) => { - const id = Number(c.req.param("id")); - const { status } = await c.req.json<{ status: string }>(); - const db = await getDb(); - db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); - return c.json({ ok: true }); -}); +registerPayrollRoutes(app); const port = config.port; await getDb(); diff --git a/api/payroll.ts b/api/payroll.ts new file mode 100644 index 0000000..339d32e --- /dev/null +++ b/api/payroll.ts @@ -0,0 +1,997 @@ +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 { + 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(); + 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(); + 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; + 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[]; + + 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[]; + 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[]; + 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[]; + 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); +} diff --git a/api/payroll_http.ts b/api/payroll_http.ts new file mode 100644 index 0000000..9b8bfb6 --- /dev/null +++ b/api/payroll_http.ts @@ -0,0 +1,454 @@ +import type { Hono } from "hono"; +import type { AuthUser } from "./auth.ts"; +import { tenantScope, requireAuth } from "./auth.ts"; +import { getDb, lastInsertId, projectById, projectMustBe } from "./db.ts"; +import { generateLoanReceiptPdf } from "./pdf.ts"; +import { + addAdminLine, + addJornalWorker, + addUnit, + assembleWeek, + createDestajoJob, + createLoan, + getSettings, + getWeekBundle, + listOpenWeeks, + listUnits, + moneyLabel, + payWeek, + patchDestajoCut, + removeAdminLine, + saveSettings, + setAttendance, + tenantKey, + todayIso, + updateAdminLine, + weekContaining, + weekCsv, +} from "./payroll.ts"; + +type App = Hono<{ Variables: { user: AuthUser } }>; + +function tid(c: { get: (k: "user") => AuthUser }): number { + return tenantKey(tenantScope(c.get("user"))); +} + +async function pdfBody( + c: { header: (k: string, v: string) => void; body: (data: ArrayBuffer) => unknown }, + bytes: Uint8Array, + filename: string, +) { + c.header("Content-Type", "application/pdf"); + c.header("Content-Disposition", `attachment; filename="${filename}"`); + return c.body(bytes.buffer as ArrayBuffer); +} + +export function registerPayrollRoutes(app: App) { + app.get("/v1/attendance", requireAuth, async (c) => { + const projectId = Number(c.req.query("project_id")); + const from = c.req.query("from") ?? ""; + const to = c.req.query("to") ?? ""; + const db = await getDb(); + const rows = db.prepare( + `SELECT a.*, w.first_name, w.last_name_p FROM attendance a + JOIN workers w ON w.id=a.worker_id + WHERE a.project_id=? AND a.work_date BETWEEN ? AND ? + ORDER BY a.work_date, w.last_name_p`, + ).all(projectId, from, to); + return c.json({ attendance: rows }); + }); + + app.put("/v1/attendance", requireAuth, async (c) => { + const body = await c.req.json<{ + project_id: number; + worker_id: number; + work_date: string; + present: boolean; + }>(); + const db = await getDb(); + const blocked = projectMustBe( + projectById(db, body.project_id), + ["activo", "pausado"], + "No se registra asistencia en un proyecto concluido o cancelado", + ); + if (blocked) return c.json({ error: blocked.error }, blocked.status); + const result = setAttendance(db, tid(c), body); + if ("error" in result) { + return c.json({ error: result.error, other: result.other }, result.status === 409 ? 409 : 400); + } + return c.json({ ok: true }); + }); + + app.get("/v1/payroll/settings", requireAuth, async (c) => { + const db = await getDb(); + const tenantId = tid(c); + return c.json({ settings: getSettings(db, tenantId), units: listUnits(db, tenantId) }); + }); + + app.patch("/v1/payroll/settings", requireAuth, async (c) => { + const db = await getDb(); + const body = await c.req.json<{ + loan_commission_enabled?: number | boolean; + loan_commission_pct?: number; + loan_small_max?: number; + }>(); + const settings = saveSettings(db, tid(c), { + loan_commission_enabled: body.loan_commission_enabled === undefined + ? undefined + : body.loan_commission_enabled ? 1 : 0, + loan_commission_pct: body.loan_commission_pct, + loan_small_max: body.loan_small_max, + }); + return c.json({ settings }); + }); + + app.post("/v1/destajo/units", requireAuth, async (c) => { + const body = await c.req.json<{ code: string; label?: string }>(); + const db = await getDb(); + try { + const id = addUnit(db, tid(c), body.code, body.label ?? body.code); + return c.json({ id }, 201); + } catch (e) { + return c.json({ error: e instanceof Error ? e.message : "No se pudo guardar" }, 400); + } + }); + + app.get("/v1/loans", requireAuth, async (c) => { + const db = await getDb(); + const workerId = c.req.query("worker_id"); + const sql = workerId + ? "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.worker_id=? ORDER BY l.id DESC" + : "SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id ORDER BY l.id DESC"; + const loans = workerId ? db.prepare(sql).all(Number(workerId)) : db.prepare(sql).all(); + return c.json({ loans }); + }); + + app.post("/v1/loans", requireAuth, async (c) => { + const body = await c.req.json<{ + worker_id: number; + amount?: number; + delivered?: number; + weekly_payment?: number; + note?: string; + commission_enabled?: boolean; + commission_pct?: number; + plan?: "single" | "installments"; + installments_n?: number; + }>(); + const db = await getDb(); + try { + const created = createLoan(db, tid(c), { + worker_id: body.worker_id, + delivered: Number(body.delivered ?? body.amount ?? 0), + commission_enabled: body.commission_enabled, + commission_pct: body.commission_pct, + plan: body.plan, + installments_n: body.installments_n, + note: body.note, + }); + return c.json(created, 201); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.get("/v1/loans/:id/recibo", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const loan = db.prepare( + `SELECT l.*, w.first_name, w.last_name_p FROM loans l JOIN workers w ON w.id=l.worker_id WHERE l.id=?`, + ).get(id) as { + delivered: number; + commission_pct: number; + commission_amount: number; + balance: number; + weekly_payment: number; + plan: string; + installments_n: number; + first_due: string; + created_at: string; + first_name: string; + last_name_p: string; + note: string | null; + } | undefined; + if (!loan) return c.json({ error: "Préstamo no encontrado" }, 404); + const bytes = await generateLoanReceiptPdf({ + title: "Recibo de aceptación de préstamo", + subtitle: `Registrado ${String(loan.created_at).slice(0, 10)}`, + person: `${loan.last_name_p} ${loan.first_name}`, + lines: [ + { label: "Entregado", value: moneyLabel(loan.delivered) }, + { + label: "Comisión", + value: loan.commission_pct > 0 + ? `${loan.commission_pct}% · ${moneyLabel(loan.commission_amount)}` + : "Sin comisión", + }, + { label: "Saldo a descontar", value: moneyLabel(loan.delivered + loan.commission_amount) }, + { + label: "Plan", + value: loan.plan === "installments" ? `${loan.installments_n} cuotas` : "1 exhibición", + }, + { label: "Primer descuento", value: loan.first_due || "—" }, + { label: "Abono", value: moneyLabel(loan.weekly_payment) }, + { label: "Nota", value: loan.note || "—" }, + ], + }); + return pdfBody(c, bytes, `prestamo-aceptacion-${id}.pdf`); + }); + + app.get("/v1/loan-payments/:id/recibo", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const pay = db.prepare( + `SELECT p.*, l.delivered, l.commission_pct, w.first_name, w.last_name_p, wk.week_end + FROM loan_payments p + JOIN loans l ON l.id=p.loan_id + JOIN workers w ON w.id=l.worker_id + JOIN payroll_weeks wk ON wk.id=p.week_id + WHERE p.id=?`, + ).get(id) as { + amount: number; + installment_n: number; + label: string; + delivered: number; + commission_pct: number; + first_name: string; + last_name_p: string; + week_end: string; + } | undefined; + if (!pay) return c.json({ error: "Recibo no encontrado" }, 404); + const bytes = await generateLoanReceiptPdf({ + title: "Recibo de pago de préstamo", + subtitle: `Descuento del sábado ${pay.week_end}`, + person: `${pay.last_name_p} ${pay.first_name}`, + lines: [ + { label: "Descuento", value: moneyLabel(pay.amount) }, + { label: "Exhibición", value: String(pay.installment_n) }, + { label: "Condiciones", value: pay.label || "—" }, + ], + }); + return pdfBody(c, bytes, `prestamo-pago-${id}.pdf`); + }); + + app.get("/v1/payroll/weeks", requireAuth, async (c) => { + const db = await getDb(); + const tenantId = tid(c); + const start = c.req.query("week_start") || weekContaining(todayIso()).weekStart; + const bundle = getWeekBundle(db, tenantId, start); + return c.json(bundle); + }); + + app.get("/v1/payroll/weeks/open", requireAuth, async (c) => { + const db = await getDb(); + return c.json({ weeks: listOpenWeeks(db, tid(c)) }); + }); + + app.post("/v1/payroll/weeks/:id/assemble", requireAuth, async (c) => { + const db = await getDb(); + try { + assembleWeek(db, Number(c.req.param("id"))); + return c.json({ ok: true }); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.post("/v1/payroll/weeks/:id/pay", requireAuth, async (c) => { + const db = await getDb(); + try { + payWeek(db, Number(c.req.param("id"))); + return c.json({ ok: true }); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.get("/v1/payroll/weeks/:id/csv", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const csv = weekCsv(db, id); + c.header("Content-Type", "text/csv; charset=utf-8"); + c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`); + return c.body(csv); + }); + + app.post("/v1/payroll/weeks/:id/jornal", requireAuth, async (c) => { + const body = await c.req.json<{ project_id: number; worker_id: number }>(); + const db = await getDb(); + try { + addJornalWorker(db, Number(c.req.param("id")), body.project_id, body.worker_id); + return c.json({ ok: true }); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.post("/v1/payroll/weeks/:id/admin", requireAuth, async (c) => { + const body = await c.req.json<{ worker_id: number; amount: number; project_id?: number | null }>(); + const db = await getDb(); + try { + addAdminLine(db, Number(c.req.param("id")), body); + return c.json({ ok: true }); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.patch("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => { + const body = await c.req.json<{ amount: number }>(); + const db = await getDb(); + try { + updateAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId")), body.amount); + return c.json({ ok: true }); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.delete("/v1/payroll/weeks/:id/admin/:lineId", requireAuth, async (c) => { + const db = await getDb(); + try { + removeAdminLine(db, Number(c.req.param("id")), Number(c.req.param("lineId"))); + return c.json({ ok: true }); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.post("/v1/destajo/jobs", requireAuth, async (c) => { + const body = await c.req.json<{ + week_id: number; + project_id: number; + worker_id: number; + concepto: string; + unit_code: string; + qty_total_estimated: number; + unit_price: number; + qty_planned?: number; + qty_extra?: number; + }>(); + const db = await getDb(); + try { + const created = createDestajoJob(db, tid(c), body.week_id, body); + return c.json(created, 201); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.patch("/v1/destajo/cuts/:id", requireAuth, async (c) => { + const body = await c.req.json<{ + week_id: number; + qty_planned?: number; + qty_actual?: number; + qty_extra?: number; + }>(); + const db = await getDb(); + try { + patchDestajoCut(db, body.week_id, Number(c.req.param("id")), body); + return c.json({ ok: true }); + } catch (e) { + const err = e as Error & { status?: number }; + return c.json({ error: err.message }, err.status === 404 ? 404 : 400); + } + }); + + app.post("/v1/payroll/periods", requireAuth, async (c) => { + const body = await c.req.json<{ + project_id: number; + week_start: string; + week_end: string; + extra_discounts?: Record; + }>(); + const db = await getDb(); + const blocked = projectMustBe( + projectById(db, body.project_id), + ["activo", "pausado"], + "No se genera nómina de un proyecto concluido o cancelado", + ); + if (blocked) return c.json({ error: blocked.error }, blocked.status); + db.prepare( + "INSERT INTO payroll_periods (project_id, week_start, week_end, status) VALUES (?, ?, ?, 'draft')", + ).run(body.project_id, body.week_start, body.week_end); + const periodId = lastInsertId(db); + const workers = db.prepare( + `SELECT w.id, w.daily_wage FROM workers w + JOIN assignments a ON a.worker_id=w.id AND a.project_id=? AND a.active=1 + WHERE w.status='activo'`, + ).all(body.project_id) as { id: number; daily_wage: number }[]; + const ins = db.prepare( + `INSERT INTO payroll_lines (period_id, worker_id, days, daily_wage, gross, discounts, loan_payment, net) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const w of workers) { + 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, body.project_id, body.week_start, body.week_end) as { n: number }; + const days = att.n; + const gross = days * w.daily_wage; + const extra = Number(body.extra_discounts?.[w.id] ?? 0); + const net = gross - extra; + ins.run(periodId, w.id, days, w.daily_wage, gross, extra, 0, net); + } + return c.json({ id: periodId }); + }); + + app.get("/v1/payroll/periods", requireAuth, async (c) => { + const db = await getDb(); + const projectId = c.req.query("project_id"); + const sql = projectId + ? `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.project_id=? ORDER BY pe.id DESC` + : `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id ORDER BY pe.id DESC`; + const periods = projectId ? db.prepare(sql).all(Number(projectId)) : db.prepare(sql).all(); + return c.json({ periods }); + }); + + app.get("/v1/payroll/periods/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const period = db.prepare( + `SELECT pe.*, p.name AS project_name FROM payroll_periods pe JOIN projects p ON p.id=pe.project_id WHERE pe.id=?`, + ).get(id); + if (!period) return c.json({ error: "Periodo no encontrado" }, 404); + const lines = db.prepare( + `SELECT l.*, w.first_name, w.last_name_p, w.position FROM payroll_lines l + JOIN workers w ON w.id=l.worker_id WHERE l.period_id=? ORDER BY w.last_name_p`, + ).all(id); + return c.json({ period, lines }); + }); + + app.get("/v1/payroll/periods/:id/csv", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const db = await getDb(); + const lines = db.prepare( + `SELECT w.first_name, w.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net + FROM payroll_lines l JOIN workers w ON w.id=l.worker_id WHERE l.period_id=?`, + ).all(id) as Record[]; + const header = "Nombre,Apellido,Dias,Jornal,Bruto,Descuentos,Prestamo,Neto"; + const rows = lines.map((l) => + [l.first_name, l.last_name_p, l.days, l.daily_wage, l.gross, l.discounts, l.loan_payment, l.net].join(",") + ); + const csv = [header, ...rows].join("\n"); + c.header("Content-Type", "text/csv; charset=utf-8"); + c.header("Content-Disposition", `attachment; filename="nomina-${id}.csv"`); + return c.body(csv); + }); + + app.patch("/v1/payroll/periods/:id", requireAuth, async (c) => { + const id = Number(c.req.param("id")); + const { status } = await c.req.json<{ status: string }>(); + const db = await getDb(); + db.prepare("UPDATE payroll_periods SET status=? WHERE id=?").run(status, id); + return c.json({ ok: true }); + }); +} diff --git a/api/payroll_test.ts b/api/payroll_test.ts new file mode 100644 index 0000000..a95f3d3 --- /dev/null +++ b/api/payroll_test.ts @@ -0,0 +1,280 @@ +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); +}); diff --git a/api/pdf.ts b/api/pdf.ts index c19123c..b53071b 100644 --- a/api/pdf.ts +++ b/api/pdf.ts @@ -348,3 +348,49 @@ export async function saveJobPdf(bytes: Uint8Array, jobId: number): Promise max && cur) { + lines.push(cur); + cur = word; + } else { + cur = next; + } + } + if (cur) lines.push(cur); + return lines; +} + +export async function generateLoanReceiptPdf(opts: { + title: string; + subtitle: string; + person: string; + lines: { label: string; value: string }[]; +}): Promise { + const pdf = await PDFDocument.create(); + const page = pdf.addPage([612, 792]); + const font = await pdf.embedFont(StandardFonts.Helvetica); + const bold = await pdf.embedFont(StandardFonts.HelveticaBold); + page.drawText("ARCTEC / PANEL OBRA", { x: 56, y: 740, size: 11, font: bold, color: rgb(0.12, 0.18, 0.28) }); + page.drawText(opts.title, { x: 56, y: 710, size: 18, font: bold }); + page.drawText(opts.subtitle, { x: 56, y: 688, size: 10, font, color: rgb(0.3, 0.3, 0.3) }); + page.drawText(opts.person, { x: 56, y: 650, size: 14, font: bold }); + let y = 610; + for (const row of opts.lines) { + page.drawText(row.label, { x: 56, y, size: 10, font, color: rgb(0.35, 0.35, 0.35) }); + const valueLines = wrap(row.value, bold, 11, 280); + let vy = y; + for (const line of valueLines) { + page.drawText(line, { x: 280, y: vy, size: 11, font: bold }); + vy -= 14; + } + y = Math.min(y, vy) - 18; + } + page.drawText("Recibo interno. No es un CFDI.", { x: 56, y: 80, size: 9, font, color: rgb(0.4, 0.4, 0.4) }); + return await pdf.save(); +} diff --git a/api/schema.sql b/api/schema.sql index b7ab904..0bc7e33 100644 --- a/api/schema.sql +++ b/api/schema.sql @@ -125,7 +125,8 @@ CREATE TABLE IF NOT EXISTS document_types ( validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')), freshness_days INTEGER, requires_issued_at INTEGER NOT NULL DEFAULT 0, - requires_expires_at INTEGER NOT NULL DEFAULT 0 + requires_expires_at INTEGER NOT NULL DEFAULT 0, + category TEXT NOT NULL DEFAULT 'identidad' ); CREATE TABLE IF NOT EXISTS documents ( @@ -151,7 +152,8 @@ CREATE TABLE IF NOT EXISTS documents ( CREATE TABLE IF NOT EXISTS project_document_types ( code TEXT PRIMARY KEY, label TEXT NOT NULL, - required INTEGER NOT NULL DEFAULT 1 + required INTEGER NOT NULL DEFAULT 1, + category TEXT NOT NULL DEFAULT 'contrato' ); CREATE TABLE IF NOT EXISTS project_documents ( @@ -170,6 +172,29 @@ CREATE TABLE IF NOT EXISTS project_documents ( uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS company_document_types ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 0, + category TEXT NOT NULL DEFAULT 'otro' +); + +CREATE TABLE IF NOT EXISTS company_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + type_code TEXT NOT NULL REFERENCES company_document_types(code), + original_name TEXT NOT NULL, + mime TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + iv TEXT NOT NULL, + storage_name TEXT NOT NULL, + is_current INTEGER NOT NULL DEFAULT 1, + parse_status TEXT NOT NULL DEFAULT 'manual', + uploaded_by INTEGER REFERENCES users(id), + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); + CREATE TABLE IF NOT EXISTS badge_jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES projects(id), @@ -191,9 +216,15 @@ CREATE TABLE IF NOT EXISTS loans ( id INTEGER PRIMARY KEY AUTOINCREMENT, worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE, amount REAL NOT NULL, + delivered REAL NOT NULL DEFAULT 0, balance REAL NOT NULL, weekly_payment REAL NOT NULL, note TEXT, + commission_pct REAL NOT NULL DEFAULT 0, + commission_amount REAL NOT NULL DEFAULT 0, + plan TEXT NOT NULL DEFAULT 'single', + installments_n INTEGER NOT NULL DEFAULT 1, + first_due TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); @@ -227,6 +258,107 @@ CREATE TABLE IF NOT EXISTS payroll_lines ( net REAL NOT NULL ); +CREATE TABLE IF NOT EXISTS payroll_settings ( + tenant_id INTEGER NOT NULL PRIMARY KEY, + loan_commission_enabled INTEGER NOT NULL DEFAULT 1, + loan_commission_pct REAL NOT NULL DEFAULT 10, + loan_small_max REAL NOT NULL DEFAULT 500 +); + +CREATE TABLE IF NOT EXISTS payroll_weeks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + week_start TEXT NOT NULL, + week_end TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')), + required_net REAL NOT NULL DEFAULT 0, + payable_net REAL NOT NULL DEFAULT 0, + assembled_at TEXT, + paid_at TEXT, + tenant_id INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS payroll_sheets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')), + project_id INTEGER REFERENCES projects(id) +); + +CREATE TABLE IF NOT EXISTS payroll_week_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sheet_id INTEGER NOT NULL REFERENCES payroll_sheets(id) ON DELETE CASCADE, + worker_id INTEGER NOT NULL REFERENCES workers(id), + destajo_cut_line_id INTEGER, + project_id INTEGER REFERENCES projects(id), + days REAL NOT NULL DEFAULT 0, + daily_wage REAL NOT NULL DEFAULT 0, + qty_planned REAL NOT NULL DEFAULT 0, + qty_actual REAL NOT NULL DEFAULT 0, + qty_extra REAL NOT NULL DEFAULT 0, + unit_price REAL NOT NULL DEFAULT 0, + unit_code TEXT, + concepto TEXT, + amount REAL NOT NULL DEFAULT 0, + gross REAL NOT NULL DEFAULT 0, + discounts REAL NOT NULL DEFAULT 0, + loan_id INTEGER REFERENCES loans(id), + loan_discount REAL NOT NULL DEFAULT 0, + loan_label TEXT, + required_net REAL NOT NULL DEFAULT 0, + payable_net REAL NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS destajo_units ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL DEFAULT 0, + code TEXT NOT NULL, + label TEXT NOT NULL, + UNIQUE (tenant_id, code) +); + +CREATE TABLE IF NOT EXISTS destajo_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE, + tenant_id INTEGER NOT NULL DEFAULT 0, + UNIQUE (tenant_id, period_end) +); + +CREATE TABLE IF NOT EXISTS destajo_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + worker_id INTEGER NOT NULL REFERENCES workers(id), + concepto TEXT NOT NULL, + unit_code TEXT NOT NULL, + qty_total_estimated REAL NOT NULL, + unit_price REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')), + tenant_id INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS destajo_cut_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_id INTEGER NOT NULL REFERENCES destajo_periods(id) ON DELETE CASCADE, + job_id INTEGER NOT NULL REFERENCES destajo_jobs(id) ON DELETE CASCADE, + qty_planned REAL NOT NULL DEFAULT 0, + qty_actual REAL NOT NULL DEFAULT 0, + qty_extra REAL NOT NULL DEFAULT 0, + UNIQUE (period_id, job_id) +); + +CREATE TABLE IF NOT EXISTS loan_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + loan_id INTEGER NOT NULL REFERENCES loans(id) ON DELETE CASCADE, + week_id INTEGER NOT NULL REFERENCES payroll_weeks(id), + amount REAL NOT NULL, + installment_n INTEGER NOT NULL DEFAULT 1, + label TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + CREATE TABLE IF NOT EXISTS budget_chapters ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE, diff --git a/db/app/changelog-master.xml b/db/app/changelog-master.xml index 86cfc35..0982bef 100644 --- a/db/app/changelog-master.xml +++ b/db/app/changelog-master.xml @@ -8,4 +8,6 @@ + + diff --git a/db/app/changesets/004-document-catalog.sql b/db/app/changesets/004-document-catalog.sql new file mode 100644 index 0000000..f2a18fe --- /dev/null +++ b/db/app/changesets/004-document-catalog.sql @@ -0,0 +1,40 @@ +--liquibase formatted sql + +--changeset panel:app-004a-doc-type-category endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('document_types') WHERE name='category' +ALTER TABLE document_types ADD COLUMN category TEXT NOT NULL DEFAULT 'identidad'; + +--changeset panel:app-004b-project-doc-category endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('project_document_types') WHERE name='category' +ALTER TABLE project_document_types ADD COLUMN category TEXT NOT NULL DEFAULT 'contrato'; + +--changeset panel:app-004c-company-document-types endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='company_document_types' +CREATE TABLE company_document_types ( + code TEXT PRIMARY KEY, + label TEXT NOT NULL, + required INTEGER NOT NULL DEFAULT 0, + category TEXT NOT NULL DEFAULT 'otro' +); + +--changeset panel:app-004d-company-documents endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='company_documents' +CREATE TABLE company_documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE, + type_code TEXT NOT NULL REFERENCES company_document_types(code), + original_name TEXT NOT NULL, + mime TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + sha256 TEXT NOT NULL, + iv TEXT NOT NULL, + storage_name TEXT NOT NULL, + is_current INTEGER NOT NULL DEFAULT 1, + parse_status TEXT NOT NULL DEFAULT 'manual', + uploaded_by INTEGER REFERENCES users(id), + uploaded_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/db/app/changesets/005-payroll-week.sql b/db/app/changesets/005-payroll-week.sql new file mode 100644 index 0000000..bee8761 --- /dev/null +++ b/db/app/changesets/005-payroll-week.sql @@ -0,0 +1,164 @@ +--liquibase formatted sql + +--changeset panel:app-005a-payroll-settings endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_settings' +CREATE TABLE payroll_settings ( + tenant_id INTEGER NOT NULL PRIMARY KEY, + loan_commission_enabled INTEGER NOT NULL DEFAULT 1, + loan_commission_pct REAL NOT NULL DEFAULT 10, + loan_small_max REAL NOT NULL DEFAULT 500 +); + +--changeset panel:app-005b-payroll-weeks endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_weeks' +CREATE TABLE payroll_weeks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + week_start TEXT NOT NULL, + week_end TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')), + required_net REAL NOT NULL DEFAULT 0, + payable_net REAL NOT NULL DEFAULT 0, + assembled_at TEXT, + paid_at TEXT, + tenant_id INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE UNIQUE INDEX idx_payroll_weeks_tenant_start ON payroll_weeks(tenant_id, week_start); + +--changeset panel:app-005c-payroll-sheets endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_sheets' +CREATE TABLE payroll_sheets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')), + project_id INTEGER REFERENCES projects(id) +); +CREATE UNIQUE INDEX idx_payroll_sheets_week_kind_project ON payroll_sheets(week_id, kind, IFNULL(project_id, 0)); + +--changeset panel:app-005d-payroll-week-lines endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='payroll_week_lines' +CREATE TABLE payroll_week_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sheet_id INTEGER NOT NULL REFERENCES payroll_sheets(id) ON DELETE CASCADE, + worker_id INTEGER NOT NULL REFERENCES workers(id), + destajo_cut_line_id INTEGER, + project_id INTEGER REFERENCES projects(id), + days REAL NOT NULL DEFAULT 0, + daily_wage REAL NOT NULL DEFAULT 0, + qty_planned REAL NOT NULL DEFAULT 0, + qty_actual REAL NOT NULL DEFAULT 0, + qty_extra REAL NOT NULL DEFAULT 0, + unit_price REAL NOT NULL DEFAULT 0, + unit_code TEXT, + concepto TEXT, + amount REAL NOT NULL DEFAULT 0, + gross REAL NOT NULL DEFAULT 0, + discounts REAL NOT NULL DEFAULT 0, + loan_id INTEGER REFERENCES loans(id), + loan_discount REAL NOT NULL DEFAULT 0, + loan_label TEXT, + required_net REAL NOT NULL DEFAULT 0, + payable_net REAL NOT NULL DEFAULT 0 +); + +--changeset panel:app-005e-destajo-units endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_units' +CREATE TABLE destajo_units ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tenant_id INTEGER NOT NULL DEFAULT 0, + code TEXT NOT NULL, + label TEXT NOT NULL, + UNIQUE (tenant_id, code) +); + +--changeset panel:app-005f-destajo-periods endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_periods' +CREATE TABLE destajo_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE, + tenant_id INTEGER NOT NULL DEFAULT 0, + UNIQUE (tenant_id, period_end) +); + +--changeset panel:app-005g-destajo-jobs endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_jobs' +CREATE TABLE destajo_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL REFERENCES projects(id), + worker_id INTEGER NOT NULL REFERENCES workers(id), + concepto TEXT NOT NULL, + unit_code TEXT NOT NULL, + qty_total_estimated REAL NOT NULL, + unit_price REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')), + tenant_id INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +--changeset panel:app-005h-destajo-cut-lines endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='destajo_cut_lines' +CREATE TABLE destajo_cut_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + period_id INTEGER NOT NULL REFERENCES destajo_periods(id) ON DELETE CASCADE, + job_id INTEGER NOT NULL REFERENCES destajo_jobs(id) ON DELETE CASCADE, + qty_planned REAL NOT NULL DEFAULT 0, + qty_actual REAL NOT NULL DEFAULT 0, + qty_extra REAL NOT NULL DEFAULT 0, + UNIQUE (period_id, job_id) +); + +--changeset panel:app-005i-loan-payments endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='loan_payments' +CREATE TABLE loan_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + loan_id INTEGER NOT NULL REFERENCES loans(id) ON DELETE CASCADE, + week_id INTEGER NOT NULL REFERENCES payroll_weeks(id), + amount REAL NOT NULL, + installment_n INTEGER NOT NULL DEFAULT 1, + label TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +--changeset panel:app-005j-loans-delivered endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='delivered' +ALTER TABLE loans ADD COLUMN delivered REAL NOT NULL DEFAULT 0; + +--changeset panel:app-005k-loans-commission-pct endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='commission_pct' +ALTER TABLE loans ADD COLUMN commission_pct REAL NOT NULL DEFAULT 0; + +--changeset panel:app-005l-loans-commission-amount endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='commission_amount' +ALTER TABLE loans ADD COLUMN commission_amount REAL NOT NULL DEFAULT 0; + +--changeset panel:app-005m-loans-plan endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='plan' +ALTER TABLE loans ADD COLUMN plan TEXT NOT NULL DEFAULT 'single'; + +--changeset panel:app-005n-loans-installments endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='installments_n' +ALTER TABLE loans ADD COLUMN installments_n INTEGER NOT NULL DEFAULT 1; + +--changeset panel:app-005o-loans-first-due endDelimiter:; +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM pragma_table_info('loans') WHERE name='first_due' +ALTER TABLE loans ADD COLUMN first_due TEXT; + +--changeset panel:app-005p-loans-migrate-delivered endDelimiter:; +UPDATE loans SET delivered = amount WHERE delivered = 0 AND amount > 0; diff --git a/tmp/pdfs/build_estado_cuenta.py b/tmp/pdfs/build_estado_cuenta.py new file mode 100755 index 0000000..52feef1 --- /dev/null +++ b/tmp/pdfs/build_estado_cuenta.py @@ -0,0 +1,275 @@ +from pathlib import Path + +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle +from reportlab.pdfgen import canvas +from reportlab.platypus import Paragraph +from pypdf import PdfReader + + +WORK = Path(__file__).resolve().parent +OUTPUT = WORK / "estado_cuenta_comtecsa_2026-08-22.pdf" + +PAGE_W, PAGE_H = letter +MARGIN = 42 +CONTENT_W = PAGE_W - (2 * MARGIN) + +INK = colors.HexColor("#172033") +MUTED = colors.HexColor("#64748B") +LINE = colors.HexColor("#D8E0EA") +NAVY = colors.HexColor("#163A5F") +BLUE = colors.HexColor("#2378A5") +PALE = colors.HexColor("#EEF5F9") +GREEN = colors.HexColor("#16805C") +GREEN_BG = colors.HexColor("#E8F7F1") +AMBER = colors.HexColor("#9A5C00") +AMBER_BG = colors.HexColor("#FFF3D6") +WHITE = colors.white + + +def money(value): + return "$" + f"{value:,.2f}" + + +def right_text(pdf, text, x, y, font="Helvetica", size=9, color=INK): + pdf.setFont(font, size) + pdf.setFillColor(color) + pdf.drawRightString(x, y, text) + + +def status_pill(pdf, text, x, y, width, fill, text_color): + pdf.setFillColor(fill) + pdf.roundRect(x, y, width, 16, 8, fill=1, stroke=0) + pdf.setFillColor(text_color) + pdf.setFont("Helvetica-Bold", 7.3) + pdf.drawCentredString(x + width / 2, y + 5, text) + + +def wrapped_paragraph(pdf, text, x, y, width, height, font_size=8.5, leading=11, color=INK): + style = ParagraphStyle( + name="body", + fontName="Helvetica", + fontSize=font_size, + leading=leading, + textColor=color, + alignment=TA_LEFT, + ) + para = Paragraph(text, style) + _, used_h = para.wrap(width, height) + para.drawOn(pdf, x, y + height - used_h) + + +def draw_summary_box(pdf, x, y, w, h, label, amount, detail, dark=False): + if dark: + fill = NAVY + border = NAVY + label_color = colors.HexColor("#CFE4F2") + amount_color = WHITE + detail_color = colors.HexColor("#CFE4F2") + else: + fill = WHITE + border = LINE + label_color = MUTED + amount_color = INK + detail_color = MUTED + + pdf.setFillColor(fill) + pdf.setStrokeColor(border) + pdf.setLineWidth(0.8) + pdf.roundRect(x, y, w, h, 8, fill=1, stroke=1) + pdf.setFillColor(label_color) + pdf.setFont("Helvetica-Bold", 7.2) + pdf.drawString(x + 11, y + h - 16, label.upper()) + pdf.setFillColor(amount_color) + pdf.setFont("Helvetica-Bold", 16) + pdf.drawString(x + 11, y + h - 36, amount) + pdf.setFillColor(detail_color) + pdf.setFont("Helvetica", 7.2) + pdf.drawString(x + 11, y + 9, detail) + + +def build_pdf(): + pdf = canvas.Canvas(str(OUTPUT), pagesize=letter) + pdf.setTitle("Estado de cuenta COMTECSA - 22 agosto 2026") + pdf.setAuthor("MRDEV") + pdf.setSubject("Conciliación de facturas pendientes y servicio por facturar") + + pdf.setFillColor(NAVY) + pdf.rect(0, PAGE_H - 8, PAGE_W * 0.56, 8, fill=1, stroke=0) + pdf.setFillColor(BLUE) + pdf.rect(PAGE_W * 0.56, PAGE_H - 8, PAGE_W * 0.30, 8, fill=1, stroke=0) + pdf.setFillColor(colors.HexColor("#70B68D")) + pdf.rect(PAGE_W * 0.86, PAGE_H - 8, PAGE_W * 0.14, 8, fill=1, stroke=0) + + pdf.setFillColor(NAVY) + pdf.roundRect(MARGIN, 704, 52, 52, 10, fill=1, stroke=0) + pdf.setFillColor(WHITE) + pdf.setFont("Helvetica-Bold", 15) + pdf.drawCentredString(MARGIN + 26, 724, "MR") + pdf.setFillColor(INK) + pdf.setFont("Helvetica-Bold", 15) + pdf.drawString(106, 738, "MRDEV") + pdf.setFillColor(MUTED) + pdf.setFont("Helvetica", 8.5) + + pdf.drawString(106, 717, "Proveedor de servicios") + + right_text(pdf, "ESTADO DE CUENTA", PAGE_W - MARGIN, 736, "Helvetica-Bold", 21, NAVY) + right_text(pdf, "Corte al 22 de agosto de 2026", PAGE_W - MARGIN, 717, "Helvetica", 8.5, MUTED) + right_text(pdf, "Referencia: EDC-COMTECSA-20260822", PAGE_W - MARGIN, 704, "Helvetica", 7.4, MUTED) + pdf.setStrokeColor(NAVY) + pdf.setLineWidth(1.4) + pdf.line(MARGIN, 686, PAGE_W - MARGIN, 686) + + pdf.setFillColor(MUTED) + pdf.setFont("Helvetica-Bold", 7) + pdf.drawString(MARGIN, 667, "CLIENTE") + pdf.drawString(398, 667, "MONEDA") + pdf.setFillColor(INK) + pdf.setFont("Helvetica-Bold", 11.2) + pdf.drawString(MARGIN, 650, "COMERCIALIZADORA DE TECNOLOGÍAS (COMTECSA)") + pdf.setFont("Helvetica-Bold", 10.5) + pdf.drawString(398, 650, "PESOS MEXICANOS") + pdf.setFillColor(MUTED) + pdf.setFont("Helvetica", 7.6) + pdf.drawString(MARGIN, 637, "Atención: Contabilidad / Cuentas por pagar") + pdf.drawString(398, 637, "Importes en MXN") + + gap = 9 + box_w = (CONTENT_W - 2 * gap) / 3 + card_y = 560 + draw_summary_box(pdf, MARGIN, card_y, box_w, 62, "Facturado pendiente", money(23200), "Facturas A 113 y A 114") + draw_summary_box(pdf, MARGIN + box_w + gap, card_y, box_w, 62, "Por facturar", money(11600), "Servicio del 1 al 15 de agosto") + draw_summary_box(pdf, MARGIN + 2 * (box_w + gap), card_y, box_w, 62, "Saldo total abierto", money(34800), "Tres periodos pendientes", dark=True) + + pdf.setFillColor(NAVY) + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString(MARGIN, 540, "Detalle de movimientos pendientes") + + table_top = 526 + columns = [("DOCUMENTO", 64), ("PERIODO DEL SERVICIO", 132), ("SITUACIÓN", 116), ("CARGO", 72), ("ABONOS", 68), ("SALDO", 76)] + xs = [MARGIN] + for _, width in columns: + xs.append(xs[-1] + width) + header_h = 28 + row_h = 44 + total_h = 32 + pdf.setFillColor(PALE) + pdf.rect(MARGIN, table_top - header_h, CONTENT_W, header_h, fill=1, stroke=0) + pdf.setStrokeColor(LINE) + pdf.setLineWidth(0.6) + pdf.line(MARGIN, table_top, PAGE_W - MARGIN, table_top) + pdf.line(MARGIN, table_top - header_h, PAGE_W - MARGIN, table_top - header_h) + for idx, (label, _) in enumerate(columns): + pdf.setFillColor(NAVY) + pdf.setFont("Helvetica-Bold", 6.6) + if idx >= 3: + pdf.drawRightString(xs[idx + 1] - 7, table_top - 18, label) + else: + pdf.drawString(xs[idx] + 7, table_top - 18, label) + + rows = [ + ("A 113", "01-15 jul 2026", "Servicio facturado", "Pendiente de pago", 11600, 0, 11600, "pending"), + ("A 114", "16-30 jul 2026", "Servicio facturado", "Pendiente de pago", 11600, 0, 11600, "pending"), + ("Por emitir", "01-15 ago 2026", "Servicio por facturar", "Por confirmar", 11600, 0, 11600, "unbilled"), + ] + y = table_top - header_h + for row in rows: + y_next = y - row_h + pdf.setStrokeColor(LINE) + pdf.line(MARGIN, y_next, PAGE_W - MARGIN, y_next) + pdf.setFillColor(NAVY) + pdf.setFont("Helvetica-Bold", 8.5) + pdf.drawString(xs[0] + 7, y_next + 26, row[0]) + pdf.setFillColor(INK) + pdf.setFont("Helvetica-Bold", 8.2) + pdf.drawString(xs[1] + 7, y_next + 27, row[1]) + pdf.setFillColor(MUTED) + pdf.setFont("Helvetica", 7) + pdf.drawString(xs[1] + 7, y_next + 14, row[2]) + if row[7] == "pending": + status_pill(pdf, row[3], xs[2] + 7, y_next + 14, 91, AMBER_BG, AMBER) + else: + status_pill(pdf, row[3], xs[2] + 7, y_next + 14, 76, PALE, NAVY) + right_text(pdf, money(row[4]), xs[4] - 7, y_next + 21, "Helvetica", 8.2, INK) + right_text(pdf, money(row[5]), xs[5] - 7, y_next + 21, "Helvetica", 8.2, INK) + right_text(pdf, money(row[6]), xs[6] - 7, y_next + 21, "Helvetica-Bold", 8.2, INK) + y = y_next + + pdf.setFillColor(colors.HexColor("#FBFCFD")) + pdf.rect(MARGIN, y - total_h, CONTENT_W, total_h, fill=1, stroke=0) + pdf.setStrokeColor(NAVY) + pdf.setLineWidth(1.2) + pdf.line(MARGIN, y - total_h, PAGE_W - MARGIN, y - total_h) + pdf.setFillColor(INK) + pdf.setFont("Helvetica-Bold", 8.4) + pdf.drawString(MARGIN + 7, y - 20, "TOTALES") + right_text(pdf, money(34800), xs[4] - 7, y - 20, "Helvetica-Bold", 8.4, INK) + right_text(pdf, money(0), xs[5] - 7, y - 20, "Helvetica-Bold", 8.4, INK) + right_text(pdf, money(34800), xs[6] - 7, y - 20, "Helvetica-Bold", 8.4, INK) + + pdf.setFillColor(NAVY) + pdf.setFont("Helvetica-Bold", 10) + pdf.drawString(MARGIN, 304, "Pago aplicado recientemente") + paid_y = 253 + pdf.setFillColor(GREEN_BG) + pdf.setStrokeColor(colors.HexColor("#BFE3D5")) + pdf.setLineWidth(0.8) + pdf.roundRect(MARGIN, paid_y, CONTENT_W, 40, 7, fill=1, stroke=1) + status_pill(pdf, "PAGADA", MARGIN + 10, paid_y + 12, 51, GREEN_BG, GREEN) + pdf.setFillColor(colors.HexColor("#0C5E43")) + pdf.setFont("Helvetica-Bold", 8.5) + pdf.drawString(MARGIN + 73, paid_y + 23, "Factura A 109") + pdf.setFillColor(MUTED) + pdf.setFont("Helvetica", 6.8) + pdf.drawString(MARGIN + 73, paid_y + 10, "Periodo 16-30 jun 2026") + for label, amount, x in [("CARGO", money(11600), 367), ("ABONO", money(11600), 457), ("SALDO", money(0), 554)]: + right_text(pdf, label, x, paid_y + 25, "Helvetica-Bold", 6.2, MUTED) + right_text(pdf, amount, x, paid_y + 11, "Helvetica-Bold", 7.8, colors.HexColor("#164E3C")) + + note_y = 177 + note_h = 58 + pdf.setFillColor(PALE) + pdf.roundRect(MARGIN, note_y, CONTENT_W, note_h, 6, fill=1, stroke=0) + pdf.setFillColor(BLUE) + pdf.rect(MARGIN, note_y, 4, note_h, fill=1, stroke=0) + note_text = ("Nota de conciliación. La factura A 109 se reconoce como pagada y no forma parte del saldo abierto. " + "El importe del periodo del 1 al 15 de agosto de 2026 corresponde a un servicio pendiente de facturación; " + "aún no cuenta con número de factura ni CFDI emitido.") + wrapped_paragraph(pdf, note_text, MARGIN + 13, note_y + 7, CONTENT_W - 25, note_h - 14, 8.1, 10.2, colors.HexColor("#334155")) + + req_y = 104 + req_h = 56 + pdf.setFillColor(WHITE) + pdf.setStrokeColor(LINE) + pdf.setLineWidth(0.8) + pdf.roundRect(MARGIN, req_y, CONTENT_W, req_h, 6, fill=1, stroke=1) + req_text = ("Solicitud. Agradecemos confirmar la fecha programada de pago de las facturas A 113 y A 114, " + "así como la procedencia de emitir la factura correspondiente al periodo del 1 al 15 de agosto de 2026.") + wrapped_paragraph(pdf, req_text, MARGIN + 12, req_y + 7, CONTENT_W - 24, req_h - 14, 8.2, 10.5, colors.HexColor("#334155")) + + pdf.setStrokeColor(LINE) + pdf.setLineWidth(0.6) + pdf.line(MARGIN, 70, PAGE_W - MARGIN, 70) + pdf.setFillColor(MUTED) + pdf.setFont("Helvetica", 6.8) + pdf.drawString(MARGIN, 55, "Documento informativo para conciliación de saldos; no sustituye los CFDI correspondientes.") + right_text(pdf, "Página 1 de 1", PAGE_W - MARGIN, 55, "Helvetica", 6.8, MUTED) + pdf.showPage() + pdf.save() + + reader = PdfReader(str(OUTPUT)) + if len(reader.pages) != 1: + raise RuntimeError(f"Se esperaba 1 pagina, se obtuvieron {len(reader.pages)}") + extracted = reader.pages[0].extract_text() or "" + required = ["MRDEV", "COMERCIALIZADORA DE TECNOLOGÍAS", "A 113", "A 114", "Por emitir", "$34,800.00", "A 109"] + missing = [item for item in required if item not in extracted] + if missing: + raise RuntimeError(f"Faltan textos requeridos en el PDF: {missing}") + print(OUTPUT) + + +if __name__ == "__main__": + build_pdf() diff --git a/web-panel/assets/css/desktop-v2.css b/web-panel/assets/css/desktop-v2.css index 738eb15..160a28e 100644 --- a/web-panel/assets/css/desktop-v2.css +++ b/web-panel/assets/css/desktop-v2.css @@ -495,6 +495,9 @@ html, body, #__nuxt { .form-field .p-component, .form-field .p-inputtext, .form-field .p-inputnumber { width: 100%; } +.form-field .p-toggleswitch { + width: auto; +} .toggle-field, .risk-option { min-height: 34px; diff --git a/web-panel/components/CompanyDocuments.vue b/web-panel/components/CompanyDocuments.vue new file mode 100644 index 0000000..647995a --- /dev/null +++ b/web-panel/components/CompanyDocuments.vue @@ -0,0 +1,152 @@ + + + diff --git a/web-panel/components/ProjectDocuments.vue b/web-panel/components/ProjectDocuments.vue index e006a19..792dc8b 100644 --- a/web-panel/components/ProjectDocuments.vue +++ b/web-panel/components/ProjectDocuments.vue @@ -17,17 +17,25 @@ - +
-
- {{ item.label }} +
+ {{ item.label }}
- -
Al elegir el archivo se sube solo. PDF, Excel o imagen.
+
Elige el tipo y el archivo se sube solo. Permisos y papeles extra no bloquean la obra.
@@ -92,8 +100,9 @@ + + diff --git a/web-panel/components/padron/WorkerDocuments.vue b/web-panel/components/padron/WorkerDocuments.vue index 9428a6c..af30bd4 100644 --- a/web-panel/components/padron/WorkerDocuments.vue +++ b/web-panel/components/padron/WorkerDocuments.vue @@ -34,10 +34,10 @@
- +
+ {{ formError }}
@@ -169,6 +219,7 @@ + + diff --git a/web-panel/types/padron.ts b/web-panel/types/padron.ts index c7cef12..5677499 100644 --- a/web-panel/types/padron.ts +++ b/web-panel/types/padron.ts @@ -58,6 +58,7 @@ export type DocumentTypePolicy = { freshness_days?: number | null; requires_issued_at?: boolean | number; requires_expires_at?: boolean | number; + category?: string; }; export type ChecklistItem = DocumentTypePolicy & { @@ -68,6 +69,40 @@ export type ChecklistItem = DocumentTypePolicy & { expires_at?: string | null; }; +export const DOCUMENT_CATEGORY_LABEL: Record = { + identidad: "Identidad", + laboral: "Laboral", + sst: "Seguridad", + oficio: "Oficio", + contrato: "Contrato", + permisos: "Permisos", + ambiental: "Ambiental", + imss: "IMSS", + tecnico: "Técnico", + repse: "REPSE", + fiscal: "Opiniones fiscales", + legal: "Legal", + otro: "Otros", +}; + +const CATEGORY_ORDER = Object.keys(DOCUMENT_CATEGORY_LABEL); + +export function groupedDocumentOptions( + items: Array<{ code: string; label: string; category?: string }>, +) { + const buckets = new Map(); + for (const item of items) { + const cat = item.category && DOCUMENT_CATEGORY_LABEL[item.category] ? item.category : "otro"; + const list = buckets.get(cat) ?? []; + list.push({ code: item.code, label: item.label }); + buckets.set(cat, list); + } + return CATEGORY_ORDER.filter((cat) => buckets.has(cat)).map((cat) => ({ + label: DOCUMENT_CATEGORY_LABEL[cat], + items: buckets.get(cat) ?? [], + })); +} + export type ImssStats = { with_imss: number; without_imss: number;