mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 15:33:17 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
## Summary
Migrates the `core` schema business logic from inline `db.prepare()` calls in the API to PostgreSQL RPC functions (`core.fn_*`) with a unified JSON envelope for errors and HTTP status mapping.
### Database (Liquibase changesets 006–017)
- **006** — RPC infra: `rpc_ok`, `rpc_err`, `rpc_created`, `rpc_from_exception`
- **007** — Catalogs and tenant settings
- **008** — Companies CRUD
- **009** — Projects, checklists, document lists
- **010** — Workers CRUD, pipeline, checklist, assign
- **011** — Budget CRUD + `fn_budget_replace`
- **012** — Badge jobs
- **013** — Payroll (settings, attendance, loans, weeks, destajo)
- **014** — Worker import batch + document store
- **015** — Project/company/worker document metadata RPCs
- **016–017** — Fixes: `needs_badge` default on worker create; Liquibase `splitStatements:false` on function changesets
### API
- `api/rpc.ts` — `callCoreFn()`, `RpcCallError` (jsonb payload fix: pass JS object, not `JSON.stringify`)
- `api/http_errors.ts` — `mapRpcToStatus()`, `respondRpc()`, `respondApiError()`, `onAppError()`
- Refactored: `main.ts`, `companies.ts`, `db.ts`, `budget.ts`, `excel.ts`, `payroll.ts`, `payroll_http.ts`
- Front helpers: `web-panel/composables/api-response.ts`, `web-saas/composables/api-response.ts`
### Envelope contract
DB functions return `{ ok, code, layer: "db", message, context, data, errors }`. The API adds `status` (HTTP code) via `respondRpc()` / `respondApiError()`.
### Out of scope
`iam`, `platform`, `saas.ts`, auth/sessions, S3, PDF generation, Excel parsing, and bootstrap scripts still use direct SQL where appropriate.
## Test plan
- [x] `deno check main.ts` — compila sin errores de tipos
- [x] `npm run build` — web-panel y web-saas compilan
- [x] `deno test` — 25 tests unitarios (http_errors, companies, budget, mx, document_validity)
- [x] Liquibase migrations `006`–`017` aplicadas en Postgres local (`--context-filter=dev`)
- [x] API levantada localmente; `/v1/health` OK
- [x] Smoke CRUD vía `scripts/crud-smoke-test.sh`: empresas, proyectos, trabajadores, catálogos (create/get/list/patch)
- [ ] Import Excel de trabajadores (flujo multipart + S3/local storage)
- [ ] Import presupuesto desde Excel
- [ ] Flujo nómina: asistencia → cerrar semana
- [ ] CI en el remoto (sin checks reportados aún)
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a href="https://cursor.com/agents/bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a href="https://cursor.com/background-agent?bcId=bc-06667c14-38e8-42a8-9ed9-6b1322f12ae7&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
527 lines
17 KiB
TypeScript
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 };
|