panels-origin/api/excel.ts
Cursor Agent 493829d028
api: migrar todo el backend de SQLite a Postgres + Redis (fase 2-4e)
Fase 2 (driver):
- api/pg.ts: adaptador delgado sobre postgres.js (prepare/get/all/run,
  placeholders ? -> $n, withTenant con set_config para RLS), con parsers
  de tipo custom (numeric/date/timestamp(tz)/bigint) para que el resto
  del codigo heredado de SQLite (fechas/montos como string, ids como
  number) siga funcionando sin reescribir cada call-site a mano.
- api/platform_db.ts, api/iam_db.ts (nuevo), api/db.ts: pools separados
  por base/esquema (panels_platform, panels_product.iam,
  panels_product.core), owner pool para bootstrap/scripts/lookups
  administrativos que cruzan tenant a proposito.
- api/redis.ts: clientes iam/core separados (ACL panels_iam_redis /
  panels_core_redis).
- api/sessions.ts + auth.ts: sesiones ahora en Redis (cookie = id opaco,
  no HMAC autocontenido); revocacion real (logout, cambio de password).
- api/storage.ts (Fase 4c): documentos/PDFs via Contabo Object Storage
  (S3), con fallback a disco local si no hay credenciales S3 (dev).
- api/scope.ts: middleware withCoreScope/requireCoreAuth que abre la
  transaccion con app.tenant_id fijado (RLS) para cada request.
- api/cache.ts (Fase 4e): cache Redis con tenant_id obligatorio en la
  llave; aplicado a /v1/catalogs.

Fase 3 (reescritura SQL, ~80 endpoints en main.ts/companies.ts/budget.ts/
payroll.ts/payroll_http.ts/excel.ts/saas.ts/smtp.ts):
- Todo async/await, sintaxis Postgres (COALESCE, ~ regex, ON CONFLICT,
  now()/current_date, booleanos reales, RETURNING via lastInsertId()).
- IDOR cross-tenant cerrado: GET/PATCH /v1/projects/:id, /v1/workers/:id
  ya no dependen de que el handler recuerde el WHERE tenant_id -- Row
  Level Security lo hace estructuralmente (verificado con un segundo
  tenant real: 404 en vez de fuga de datos).
- API key ya no ve todos los tenants: ahora exige X-Tenant-Id explicito.

Fase 3b (tests): api/test_helpers.ts corre cada test en una transaccion
que siempre se revierte, contra el mismo baseline de Liquibase que
produccion (ya no un esquema SQLite escrito a mano). payroll_test.ts
reescrito con fixtures reales; 11/11 pasan contra Postgres.

Fase 4 (IAM/RBAC): iam.roles/permissions/role_permissions formalizados
(ver db/iam ya en fase 1); uploaded_by/created_by ahora son snapshot
desnormalizado (uploaded_by_id/name); seed() en runtime eliminado,
reemplazado por scripts/bootstrap-admin.ts (one-shot).

Fase 4d (zona horaria): nuevo endpoint /v1/configuracion (GET/PUT),
PAYROLL_TZ hardcodeado reemplazado por tenant_settings.timezone,
document_validity.ts ya no usa new Date() crudo.

Verificado end-to-end contra Postgres+Redis reales: login, sesiones,
catalogos con cache, alta de trabajador, subida/descarga de documento
cifrado, y el fix de IDOR probado con un segundo tenant real (403/404
en vez de fuga de datos).

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-02 20:47:45 +00:00

567 lines
19 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, 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<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) {
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<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;
}
}
async function upsertWorker(
db: Db,
input: ReturnType<typeof normalizeWorker> & { 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<ImportReport> {
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<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;
}
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 };