import * as XLSX from "xlsx"; import type { Db } from "./db.ts"; import { encryptBytes } from "./docs_crypto.ts"; import { sha256Hex } from "./crypto.ts"; import { canonicalRiskCode, normalizeWorker, validateWorkerFields, formatNss, normUpper, type WorkerInput } from "./mx.ts"; import { refreshPipeline, lastInsertId } from "./db.ts"; import { resolveCompany } from "./companies.ts"; import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts"; export const IMPORT_COLUMNS = [ "NOMBRE", "2 NOMBRE", "APELLIDO PATERNO", "APELLIDO MATERNO", "CURP", "RFC", "NSS", "TELEFONO", "CORREO", "DIRECCION", "TIPO SANGRE", "ALTA", "CARGO", "RIESGO", "TIPO TRABAJO", "JORNAL", "GAFETE", "ESTATUS", "URL FOTO", ] as const; const FIELD_LABEL: Record = { first_name: "Nombre", last_name_p: "Apellido paterno", last_name_m: "Apellido materno", curp: "CURP", rfc: "RFC", nss: "NSS", phone: "Teléfono", email: "Correo", address: "Dirección", hire_type: "Empresa", position: "Cargo", risk_code: "Riesgo", work_type: "Tipo de trabajo", daily_wage: "Jornal", }; const KEY_ALIAS: Record = { "SEGUNDO NOMBRE": "2 NOMBRE", "2NOMBRE": "2 NOMBRE", TELEFONO: "TELEFONO", TEL: "TELEFONO", EMAIL: "CORREO", MAIL: "CORREO", "TIPO DE SANGRE": "TIPO SANGRE", "TIPO TRABAJO": "TIPO TRABAJO", "TIPO DE TRABAJO": "TIPO TRABAJO", "JORNAL DIARIO": "JORNAL", SALARIO: "JORNAL", STATUS: "ESTATUS", ESTADO: "ESTATUS", FOTO: "URL FOTO", }; function normHeader(raw: string) { const k = raw .normalize("NFD") .replace(/\p{M}/gu, "") .toUpperCase() .replace(/\s+/g, " ") .trim(); return KEY_ALIAS[k] || k; } function normalizeRow(r: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(r)) out[normHeader(k)] = String(v ?? "").trim(); return out; } function sheetRows(wb: XLSX.WorkBook, name: string): { row: number; data: Record }[] { const sheet = wb.Sheets[name]; if (!sheet) return []; const rows = XLSX.utils.sheet_to_json>(sheet, { defval: "" }); return rows.map((r, i) => ({ row: i + 2, data: normalizeRow(r) })); } function parseWage(r: Record): number | undefined { if (!("JORNAL" in r)) return undefined; const raw = r["JORNAL"].replace(/[$\s]/g, "").replace(",", "."); if (!raw) return NaN; return Number(raw); } function yesNo(v: string) { return ["si", "sí", "yes", "1", "true"].includes(v.toLowerCase()); } function rowToInput(r: Record, fallbackStatus: "activo" | "baja"): WorkerInput { const est = (r["ESTATUS"] || fallbackStatus).toLowerCase(); const wage = parseWage(r); return { first_name: r["NOMBRE"], middle_name: r["2 NOMBRE"] || null, last_name_p: r["APELLIDO PATERNO"], last_name_m: r["APELLIDO MATERNO"], curp: r["CURP"], rfc: r["RFC"], nss: r["NSS"], phone: r["TELEFONO"], email: r["CORREO"], address: r["DIRECCION"], blood_type: r["TIPO SANGRE"] || null, hire_type: (r["ALTA"] || "ARCT2608").toUpperCase(), position: r["CARGO"], risk_code: canonicalRiskCode(r["RIESGO"] || ""), work_type: (r["TIPO TRABAJO"] || "N").toUpperCase(), daily_wage: wage === undefined ? 0 : wage, needs_badge: r["GAFETE"] ? yesNo(r["GAFETE"]) : true, status: est.startsWith("baja") ? "baja" : "activo", }; } function isEmptyRow(r: Record) { return IMPORT_COLUMNS.every((c) => !r[c]); } function errorMessages(errors: Record): string[] { return Object.entries(errors).map(([k, v]) => `${FIELD_LABEL[k] || k}: ${v}`); } export function buildImportTemplate(companies: { code: string; name: string }[] = []): Uint8Array { const wb = XLSX.utils.book_new(); const companyRows = companies.length ? companies.map((company) => ["ALTA", company.code, company.name]) : [ ["ALTA", "ARCT2608", "Empresa principal"], ["ALTA", "FISICA", "Persona física"], ["ALTA", "ARCOTEC", "Subempresa"], ]; const instructions = [ ["Plantilla de carga masiva — Panel de proyectos Arctec"], [""], ["1. Llene la hoja PERSONAL. No cambie los nombres de las columnas."], ["2. CURP, RFC y NSS deben ser únicos. Si ya existen en el sistema, esa fila no se inserta (queda como 'ya existía')."], ["3. Obligatorio: nombre, apellidos, CURP, RFC, NSS, teléfono, correo, dirección, alta, cargo, riesgo, tipo trabajo, jornal."], ["4. Opcional: segundo nombre, tipo de sangre, URL foto."], ["5. Valores permitidos: vea la hoja CATALOGOS."], ["6. ESTATUS: activo o baja. Si deja vacío, se toma activo."], ["7. GAFETE: SI o NO."], ["8. Suba este archivo en Padrón → Importar Excel."], ["9. También se aceptan las hojas ACTUALES e HISTORICO del Excel anterior."], ]; const wsI = XLSX.utils.aoa_to_sheet(instructions); wsI["!cols"] = [{ wch: 110 }]; XLSX.utils.book_append_sheet(wb, wsI, "INSTRUCCIONES"); const personal = [IMPORT_COLUMNS.slice() as string[]]; const wsP = XLSX.utils.aoa_to_sheet(personal); wsP["!cols"] = IMPORT_COLUMNS.map((c) => ({ wch: Math.max(14, c.length + 2) })); XLSX.utils.book_append_sheet(wb, wsP, "PERSONAL"); const catalogs = [ ["Campo", "Valor", "Notas"], ...companyRows, ["RIESGO", "alto", "Alto — barra gafete roja"], ["RIESGO", "medio", "Medio — barra amarilla"], ["RIESGO", "bajo", "Bajo — barra verde"], ["RIESGO", "rojo", "También se acepta el color de barra"], ["RIESGO", "amarillo", "Color de barra"], ["RIESGO", "azul", "Color de barra (se trata como bajo)"], ["RIESGO", "verde", "Color de barra"], ["RIESGO", "negro", "Color de barra (se trata como alto)"], ["RIESGO", "naranja", "Color de barra (se trata como alto)"], ["TIPO TRABAJO", "N", "Normal"], ["TIPO TRABAJO", "D", "Destajo"], ["GAFETE", "SI", "Se imprime gafete"], ["GAFETE", "NO", "No lleva gafete"], ["ESTATUS", "activo", "Alta en padrón"], ["ESTATUS", "baja", "Histórico"], ]; const wsC = XLSX.utils.aoa_to_sheet(catalogs); wsC["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 36 }]; XLSX.utils.book_append_sheet(wb, wsC, "CATALOGOS"); const out = XLSX.write(wb, { type: "array", bookType: "xlsx" }); return out instanceof Uint8Array ? out : new Uint8Array(out); } async function findExisting(db: Db, curp: string, rfc: string, nss: string) { return await db.prepare( `SELECT id, first_name, last_name_p, curp, rfc, nss FROM workers WHERE curp = ? OR rfc = ? OR nss = ? LIMIT 1`, ).get(curp, rfc, nss) as | { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | undefined; } export async function storeDocument( db: Db, workerId: number, type: string, filename: string, mime: string, bytes: Uint8Array, userId: number | null, meta: { issued_at?: string | null; expires_at?: string | null; imss_company_id?: number | null; imss_alta_at?: string | null; imss_baja_at?: string | null; } = {}, ) { const allowed = await db.prepare("SELECT code FROM 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`; await putObject(workerDocKey(workerId, storage), cipher); await db.prepare("UPDATE documents SET is_current = false WHERE worker_id = ? AND type_code = ?").run( workerId, type, ); const issuedAt = meta.issued_at || null; const expiresAt = meta.expires_at || null; const imssCompanyId = meta.imss_company_id || null; const today = new Date().toISOString().slice(0, 10); const imssAltaAt = meta.imss_alta_at || (type === "alta_imss" ? today : null); const imssBajaAt = meta.imss_baja_at || (type === "baja_imss" ? today : null); const movementDate = type === "baja_imss" ? imssBajaAt : imssAltaAt; await db.prepare( `INSERT INTO documents (worker_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, issued_at, expires_at, imss_company_id, imss_alta_at, uploaded_by_id, uploaded_by_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, ?, ?, ?, ?, '')`, ).run( workerId, type, filename, mime, bytes.byteLength, hash, iv, storage, issuedAt || movementDate, expiresAt, imssCompanyId, movementDate, userId, ); if (type === "alta_imss") { const companyId = imssCompanyId || (await db.prepare("SELECT company_id FROM workers WHERE id = ?").get(workerId) as { company_id: number } | undefined) ?.company_id || null; const company = companyId ? await db.prepare("SELECT id, code FROM companies WHERE id = ?").get(companyId) as | { id: number; code: string } | undefined : undefined; if (!company) throw new Error("Empresa patrón no válida"); await db.prepare( `UPDATE workers SET imss_status = 'alta', imss_company_id = ?, imss_alta_at = ?, imss_baja_at = NULL, company_id = ?, hire_type = ?, updated_at = now() WHERE id = ?`, ).run(company.id, imssAltaAt, company.id, company.code, workerId); } if (type === "baja_imss") { await db.prepare( `UPDATE workers SET imss_status = 'baja_imss', imss_baja_at = ?, imss_company_id = NULL, company_id = NULL, hire_type = '', updated_at = now() WHERE id = ?`, ).run(imssBajaAt, workerId); } await refreshPipeline(db, workerId); } export async function storeProjectDocument( db: Db, projectId: number, type: string, filename: string, mime: string, bytes: Uint8Array, userId: number | null, ) { const allowed = await db.prepare("SELECT code FROM project_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`; await putObject(projectDocKey(projectId, storage), cipher); await db.prepare("UPDATE project_documents SET is_current = false WHERE project_id = ? AND type_code = ?").run( projectId, type, ); await db.prepare( `INSERT INTO project_documents (project_id, type_code, original_name, mime, size_bytes, sha256, iv, storage_name, is_current, parse_status, uploaded_by_id, uploaded_by_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, 'manual', ?, '')`, ).run(projectId, type, filename, mime, bytes.byteLength, hash, iv, storage, userId); } export async function storeCompanyDocument( db: Db, companyId: number, type: string, filename: string, mime: string, bytes: Uint8Array, userId: number | null, ) { const allowed = await 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`; await putObject(companyDocKey(companyId, storage), cipher); await db.prepare("UPDATE company_documents SET is_current = false WHERE company_id = ? AND type_code = ?").run( companyId, type, ); await 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_id, uploaded_by_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, true, '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 { const res = await fetch(url); if (!res.ok) return null; return new Uint8Array(await res.arrayBuffer()); } catch { return null; } } async function upsertWorker( db: Db, input: ReturnType & { company_id: number; tenant_id?: number | null }, projectId: number | null, ) { const existing = await findExisting(db, input.curp, input.rfc, input.nss); if (existing) { await db.prepare( `UPDATE workers SET first_name=?, middle_name=?, last_name_p=?, last_name_m=?, curp=?, rfc=?, nss=?, phone=?, email=?, address=?, blood_type=?, hire_type=?, company_id=?, tenant_id=COALESCE(?, tenant_id), position=?, risk_code=?, work_type=?, daily_wage=?, needs_badge=?, status=?, updated_at=now() WHERE id=?`, ).run( input.first_name, input.middle_name, input.last_name_p, input.last_name_m, input.curp, input.rfc, input.nss, input.phone, input.email, input.address, input.blood_type, input.hire_type, input.company_id, input.tenant_id ?? null, input.position, input.risk_code, input.work_type, input.daily_wage, input.needs_badge, input.status, existing.id, ); if (projectId) await assign(db, existing.id, projectId); await refreshPipeline(db, existing.id, input.tenant_id ?? null); const matched = existing.curp === input.curp ? "CURP" : existing.rfc === input.rfc ? "RFC" : "NSS"; return { id: existing.id, action: "existed" as const, matched }; } await db.prepare( `INSERT INTO workers (first_name, middle_name, last_name_p, last_name_m, curp, rfc, nss, phone, email, address, blood_type, hire_type, company_id, position, risk_code, work_type, daily_wage, needs_badge, status, tenant_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, ).run( input.first_name, input.middle_name, input.last_name_p, input.last_name_m, input.curp, input.rfc, input.nss, input.phone, input.email, input.address, input.blood_type, input.hire_type, input.company_id, input.position, input.risk_code, input.work_type, input.daily_wage, input.needs_badge, input.status, input.tenant_id ?? null, ); const id = await lastInsertId(db); if (projectId) await assign(db, id, projectId); await refreshPipeline(db, id, input.tenant_id ?? null); return { id, action: "inserted" as const, matched: null as string | null }; } async function assign(db: Db, workerId: number, projectId: number) { await db.prepare( `INSERT INTO assignments (worker_id, project_id, active, start_date) VALUES (?, ?, true, current_date) ON CONFLICT(worker_id, project_id) DO UPDATE SET active=true, start_date=CASE WHEN assignments.active=false THEN excluded.start_date ELSE assignments.start_date END, end_date=NULL`, ).run(workerId, projectId); } export type ImportReport = { inserted: number; existed: number; errors: number; photos: number; existed_rows: { sheet: string; row: number; nombre: string; curp: string; matched: string }[]; error_rows: { sheet: string; row: number; nombre: string; curp: string; messages: string[] }[]; }; export async function importExcel( db: Db, bytes: Uint8Array, projectId: number | null, userId: number | null, ): Promise { const wb = XLSX.read(bytes, { type: "array" }); const risks = new Set( (await db.prepare("SELECT code FROM risk_levels").all() as { code: string }[]).map((r) => r.code), ); const report: ImportReport = { inserted: 0, existed: 0, errors: 0, photos: 0, existed_rows: [], error_rows: [], }; const seenCurp = new Map(); const seenRfc = new Map(); const seenNss = new Map(); const jobs: { sheet: string; fallback: "activo" | "baja"; rows: { row: number; data: Record }[] }[] = []; if (wb.SheetNames.includes("PERSONAL")) { jobs.push({ sheet: "PERSONAL", fallback: "activo", rows: sheetRows(wb, "PERSONAL") }); } if (wb.SheetNames.includes("ACTUALES")) { jobs.push({ sheet: "ACTUALES", fallback: "activo", rows: sheetRows(wb, "ACTUALES") }); } if (wb.SheetNames.includes("HISTORICO")) { jobs.push({ sheet: "HISTORICO", fallback: "baja", rows: sheetRows(wb, "HISTORICO") }); } if (!jobs.length) { report.error_rows.push({ sheet: "-", row: 0, nombre: "", curp: "", messages: ["El archivo no tiene hoja PERSONAL, ACTUALES ni HISTORICO. Descargue la plantilla."], }); report.errors = 1; return report; } for (const job of jobs) { for (const { row, data } of job.rows) { if (isEmptyRow(data)) continue; const nombre = [data["NOMBRE"], data["APELLIDO PATERNO"], data["APELLIDO MATERNO"]].filter(Boolean).join(" "); const input = rowToInput(data, job.fallback); const errors = validateWorkerFields(input); const company = await resolveCompany(db, input); if (!company) errors.hire_type = `Empresa no encontrada (${data["ALTA"] || "—"})`; if (input.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) { errors.email = "Correo inválido"; } if (input.risk_code && !risks.has(input.risk_code)) { errors.risk_code = `Riesgo inválido (${data["RIESGO"]}). Use alto, medio o bajo`; } const curp = normUpper(input.curp); const rfc = normUpper(input.rfc); const nss = formatNss(input.nss ?? ""); if (!errors.curp && seenCurp.has(curp)) errors.curp = `CURP duplicada en el archivo (fila ${seenCurp.get(curp)})`; if (!errors.rfc && seenRfc.has(rfc)) errors.rfc = `RFC duplicado en el archivo (fila ${seenRfc.get(rfc)})`; if (!errors.nss && seenNss.has(nss)) errors.nss = `NSS duplicado en el archivo (fila ${seenNss.get(nss)})`; if (Object.keys(errors).length) { report.error_rows.push({ sheet: job.sheet, row, nombre, curp: data["CURP"] || "", messages: errorMessages(errors), }); report.errors++; continue; } seenCurp.set(curp, row); seenRfc.set(rfc, row); seenNss.set(nss, row); const n = { ...normalizeWorker(input), hire_type: company!.code, company_id: company!.id, tenant_id: company!.tenant_id ?? 1, }; const res = await upsertWorker(db, n, n.status === "activo" ? projectId : null); if (res.action === "inserted") report.inserted++; else { report.existed++; report.existed_rows.push({ sheet: job.sheet, row, nombre, curp: n.curp, matched: res.matched || "CURP", }); } const url = data["URL FOTO"]; if (url) { const photo = await fetchPhoto(url); if (photo) { await storeDocument(db, res.id, "foto", "foto-import.jpg", "image/jpeg", photo, userId); report.photos++; } } } } return report; } export { findExisting, upsertWorker, assign };