panels-origin/api/excel.ts
Cursor Agent b58791aea4
refactor(core): finish RPC migration for budget, excel import, and docs
- Replace remaining budget.ts prepare() with fn_budget_replace and fn_project_update
- Migrate excel.ts findExisting, document store, assign, and catalogs to RPC
- Add 015-rpc-documents.sql for project/company/worker document metadata
- Document RPC envelope conventions in db/README.md
- Add http_errors_test and front api-response composables

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 00:01:57 +00:00

527 lines
17 KiB
TypeScript

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 } from "./db.ts";
import { resolveCompany } from "./companies.ts";
import { companyDocKey, projectDocKey, putObject, workerDocKey } from "./storage.ts";
import { callCoreFn, RpcCallError } from "./rpc.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<string, string> = {
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<string, string> = {
"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<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
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<string, string> }[] {
const sheet = wb.Sheets[name];
if (!sheet) return [];
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, { defval: "" });
return rows.map((r, i) => ({ row: i + 2, data: normalizeRow(r) }));
}
function parseWage(r: Record<string, string>): 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<string, string>, 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<string, string>) {
return IMPORT_COLUMNS.every((c) => !r[c]);
}
function errorMessages(errors: Record<string, string>): 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) {
const env = await callCoreFn<{
worker?: { id: number; first_name: string; last_name_p: string; curp: string; rfc: string; nss: string } | null;
found?: boolean;
}>(db, "core.fn_worker_find_existing", { curp, rfc, nss });
if (!env.ok || !env.data?.found) return undefined;
return env.data.worker;
}
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 { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`;
await putObject(workerDocKey(workerId, storage), cipher);
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 env = await callCoreFn(db, "core.fn_worker_document_store", {
worker_id: workerId,
type_code: type,
original_name: filename,
mime,
size_bytes: bytes.byteLength,
sha256: hash,
iv,
storage_name: storage,
uploaded_by_id: userId,
issued_at: issuedAt,
expires_at: expiresAt,
imss_company_id: imssCompanyId,
imss_alta_at: imssAltaAt,
imss_baja_at: imssBajaAt,
});
if (!env.ok) throw new RpcCallError(env);
await refreshPipeline(db, workerId);
}
export async function storeProjectDocument(
db: Db,
projectId: number,
type: string,
filename: string,
mime: string,
bytes: Uint8Array,
userId: number | null,
) {
const { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`;
await putObject(projectDocKey(projectId, storage), cipher);
const env = await callCoreFn(db, "core.fn_project_document_store", {
project_id: projectId,
type_code: type,
original_name: filename,
mime,
size_bytes: bytes.byteLength,
sha256: hash,
iv,
storage_name: storage,
uploaded_by_id: userId,
uploaded_by_name: "",
});
if (!env.ok) throw new RpcCallError(env);
}
export async function storeCompanyDocument(
db: Db,
companyId: number,
type: string,
filename: string,
mime: string,
bytes: Uint8Array,
userId: number | null,
) {
const { iv, cipher } = await encryptBytes(bytes);
const hash = await sha256Hex(bytes);
const storage = `${crypto.randomUUID()}.enc`;
await putObject(companyDocKey(companyId, storage), cipher);
const env = await callCoreFn(db, "core.fn_company_document_store", {
company_id: companyId,
type_code: type,
original_name: filename,
mime,
size_bytes: bytes.byteLength,
sha256: hash,
iv,
storage_name: storage,
uploaded_by_id: userId,
uploaded_by_name: "",
});
if (!env.ok) throw new RpcCallError(env);
}
async function fetchPhoto(url: string): Promise<Uint8Array | null> {
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;
}
}
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[] }[];
};
type ImportBatchResult = {
inserted: number;
existed: number;
errors: number;
existed_rows: ImportReport["existed_rows"];
error_rows: ImportReport["error_rows"];
};
function workerToImportRow(
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
sheet: string,
row: number,
projectId: number | null,
) {
return {
sheet,
row,
first_name: input.first_name,
middle_name: input.middle_name,
last_name_p: input.last_name_p,
last_name_m: input.last_name_m,
curp: input.curp,
rfc: input.rfc,
nss: input.nss,
phone: input.phone,
email: input.email,
address: input.address,
blood_type: input.blood_type,
hire_type: input.hire_type,
company_id: input.company_id,
position: input.position,
risk_code: input.risk_code,
work_type: input.work_type,
daily_wage: input.daily_wage,
needs_badge: input.needs_badge,
status: input.status,
project_id: input.status === "activo" ? projectId : null,
tenant_id: input.tenant_id ?? 1,
};
}
async function upsertWorker(
db: Db,
input: ReturnType<typeof normalizeWorker> & { company_id: number; tenant_id?: number | null },
projectId: number | null,
) {
const env = await callCoreFn<ImportBatchResult>(db, "core.fn_workers_import_batch", {
tenant_id: input.tenant_id ?? 1,
project_id: projectId,
rows: [workerToImportRow(input, "IMPORT", 1, projectId)],
});
if (!env.ok) throw new RpcCallError(env);
const data = env.data!;
const errorRow = data.error_rows?.[0];
if (errorRow?.messages?.length) throw new Error(errorRow.messages[0]);
const existing = await findExisting(db, input.curp, input.rfc, input.nss);
if (!existing) throw new Error("No se pudo guardar el trabajador");
await refreshPipeline(db, existing.id, input.tenant_id ?? null);
return {
id: existing.id,
action: (data.inserted ?? 0) > 0 ? "inserted" as const : "existed" as const,
matched: data.existed_rows?.[0]?.matched ?? null,
};
}
async function assign(db: Db, workerId: number, projectId: number) {
const env = await callCoreFn(db, "core.fn_worker_assign", {
worker_id: workerId,
project_id: projectId,
});
if (!env.ok) throw new RpcCallError(env);
}
export async function importExcel(
db: Db,
bytes: Uint8Array,
projectId: number | null,
userId: number | null,
): Promise<ImportReport> {
const wb = XLSX.read(bytes, { type: "array" });
const catEnv = await callCoreFn<{ risks?: { code: string }[] }>(db, "core.fn_catalogs", {});
if (!catEnv.ok) throw new RpcCallError(catEnv);
const risks = new Set((catEnv.data?.risks ?? []).map((r) => r.code));
const report: ImportReport = {
inserted: 0,
existed: 0,
errors: 0,
photos: 0,
existed_rows: [],
error_rows: [],
};
const seenCurp = new Map<string, number>();
const seenRfc = new Map<string, number>();
const seenNss = new Map<string, number>();
const jobs: { sheet: string; fallback: "activo" | "baja"; rows: { row: number; data: Record<string, string> }[] }[] = [];
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;
}
const batchRows: ReturnType<typeof workerToImportRow>[] = [];
const photoJobs: { curp: string; rfc: string; nss: string; url: string; tenant_id: number }[] = [];
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,
};
batchRows.push(workerToImportRow(n, job.sheet, row, n.status === "activo" ? projectId : null));
const url = data["URL FOTO"];
if (url) {
photoJobs.push({ curp: n.curp, rfc: n.rfc, nss: n.nss, url, tenant_id: n.tenant_id });
}
}
}
if (batchRows.length > 0) {
const env = await callCoreFn<ImportBatchResult>(db, "core.fn_workers_import_batch", {
project_id: projectId,
rows: batchRows,
});
if (!env.ok) throw new RpcCallError(env);
const data = env.data!;
report.inserted = data.inserted ?? 0;
report.existed = data.existed ?? 0;
report.errors += data.errors ?? 0;
report.existed_rows.push(...(data.existed_rows ?? []));
report.error_rows.push(...(data.error_rows ?? []));
for (const row of batchRows) {
const worker = await findExisting(db, row.curp, row.rfc, row.nss);
if (worker) await refreshPipeline(db, worker.id, row.tenant_id ?? null);
}
}
for (const photo of photoJobs) {
const worker = await findExisting(db, photo.curp, photo.rfc, photo.nss);
if (!worker) continue;
const photoBytes = await fetchPhoto(photo.url);
if (photoBytes) {
await storeDocument(db, worker.id, "foto", "foto-import.jpg", "image/jpeg", photoBytes, userId);
report.photos++;
}
}
return report;
}
export { findExisting, upsertWorker, assign };