const CURP_RE = /^[A-Z][AEIOUX][A-Z]{2}\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])[HMX](?:AS|BC|BS|CC|CS|CH|CL|CM|DF|DG|GT|GR|HG|JC|MC|MN|MS|NT|NL|OC|PL|QT|QR|SP|SL|SR|TC|TS|TL|VZ|YN|ZS|NE)[B-DF-HJ-NP-TV-Z]{3}[A-Z\d]\d$/; const RFC_PF = /^[A-ZÑ&]{4}\d{6}[A-Z0-9]{3}$/; const RFC_PM = /^[A-ZÑ&]{3}\d{6}[A-Z0-9]{3}$/; export function normUpper(value: string | null | undefined): string { return (value ?? "").toString().trim().toUpperCase().replace(/\s+/g, ""); } export function normName(value: string | null | undefined): string { return (value ?? "").toString().trim().replace(/\s+/g, " "); } const NAME_PARTICLES = new Set([ "de", "del", "la", "las", "los", "el", "y", "e", "da", "das", "do", "dos", "di", "du", "le", "van", "von", ]); function capPiece(piece: string): string { if (!piece) return piece; return piece.replace(/(^|['’])(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toLocaleUpperCase("es-MX")); } /** Nombre propio: Héctor de la Cruz. No guarda TODO EN MAYÚSCULAS. */ export function titleCase(value: string | null | undefined): string { const raw = normName(value); if (!raw) return ""; return raw .toLocaleLowerCase("es-MX") .split(" ") .map((word, i) => { if (!word) return word; if (i > 0 && NAME_PARTICLES.has(word)) return word; return word.split("-").map(capPiece).join("-"); }) .join(" "); } /** Oración: primera letra y después de . ? ! */ export function sentenceCase(value: string | null | undefined): string { const raw = normName(value); if (!raw) return ""; return raw .toLocaleLowerCase("es-MX") .replace(/(^|[.!?]\s+)(\p{L})/gu, (_m, sep: string, ch: string) => sep + ch.toLocaleUpperCase("es-MX")); } export function looksAllCaps(value: string | null | undefined): boolean { const letters = [...(value ?? "")].filter((ch) => /\p{L}/u.test(ch)).join(""); if (letters.length < 2) return false; const up = letters.toLocaleUpperCase("es-MX"); const low = letters.toLocaleLowerCase("es-MX"); return letters === up && letters !== low; } export function validateCurp(raw: string): string | null { const curp = normUpper(raw); if (!CURP_RE.test(curp)) return "CURP inválida (18 caracteres, formato oficial)"; return null; } export function validateRfc(raw: string): string | null { const rfc = normUpper(raw).replace(/Ñ/g, "Ñ"); if (!RFC_PF.test(rfc) && !RFC_PM.test(rfc)) { return "RFC inválido (12 o 13 caracteres, formato SAT)"; } return null; } /** Dígito verificador IMSS (11 dígitos). */ export function validateNss(raw: string): string | null { const nss = (raw ?? "").toString().replace(/\D/g, ""); if (nss.length !== 11) return "NSS inválido (11 dígitos)"; const digits = nss.split("").map(Number); let sum = 0; for (let i = 0; i < 10; i++) { let n = digits[i] * (i % 2 === 1 ? 2 : 1); if (n > 9) n -= 9; sum += n; } const check = (10 - (sum % 10)) % 10; if (check !== digits[10]) return "NSS inválido (dígito verificador)"; return null; } export function formatNss(raw: string): string { return (raw ?? "").toString().replace(/\D/g, ""); } export type WorkerInput = { first_name?: string; middle_name?: string | null; last_name_p?: string; last_name_m?: string; curp?: string; rfc?: string; nss?: string; phone?: string; email?: string; address?: string; blood_type?: string | null; hire_type?: string; company_id?: number | null; position?: string; risk_code?: string; work_type?: string; daily_wage?: number; needs_badge?: boolean | number; status?: string; }; export function validateWorkerFields(body: WorkerInput): Record { const errors: Record = {}; if (!normName(body.first_name)) errors.first_name = "Nombre obligatorio"; if (!normName(body.last_name_p)) errors.last_name_p = "Apellido paterno obligatorio"; if (!normName(body.last_name_m)) errors.last_name_m = "Apellido materno obligatorio"; const curpErr = validateCurp(body.curp ?? ""); if (curpErr) errors.curp = curpErr; const rfcErr = validateRfc(body.rfc ?? ""); if (rfcErr) errors.rfc = rfcErr; const nssErr = validateNss(body.nss ?? ""); if (nssErr) errors.nss = nssErr; if (!normName(body.phone)) errors.phone = "Teléfono obligatorio"; if (!normName(body.email)) errors.email = "Correo obligatorio"; if (!normName(body.address)) errors.address = "Dirección obligatoria"; // Empresa / patrón se asigna solo con el alta IMSS; no es obligatoria en el padrón. if (!normName(body.position)) errors.position = "Cargo obligatorio"; if (!normName(body.risk_code)) errors.risk_code = "Riesgo obligatorio"; const wt = (body.work_type ?? "").toUpperCase(); if (!["N", "D"].includes(wt)) errors.work_type = "Tipo de trabajo N o D"; if (body.daily_wage === undefined || body.daily_wage === null || Number.isNaN(Number(body.daily_wage))) { errors.daily_wage = "Jornal obligatorio"; } return errors; } export function canonicalRiskCode(raw?: string) { const c = (raw ?? "").toLowerCase().trim(); if (c === "alto") return "rojo"; if (c === "medio") return "amarillo"; if (c === "bajo") return "verde"; return c; } export function pipelineLabelFor(status: string): string { const labels: Record = { incompleto: "Falta expediente", listo_gafete: "Listo gafete", impreso: "Gafete impreso", activo: "Activo en proyecto", baja: "Baja", }; return labels[status] ?? status; } export function normalizeWorker(body: WorkerInput) { return { first_name: titleCase(body.first_name), middle_name: titleCase(body.middle_name) || null, last_name_p: titleCase(body.last_name_p), last_name_m: titleCase(body.last_name_m), curp: normUpper(body.curp), rfc: normUpper(body.rfc), nss: formatNss(body.nss ?? ""), phone: normName(body.phone), email: (body.email ?? "").toString().trim().toLowerCase(), address: sentenceCase(body.address), blood_type: normUpper(body.blood_type) || null, hire_type: (body.hire_type ?? "").toUpperCase(), position: titleCase(body.position), risk_code: canonicalRiskCode(body.risk_code), work_type: (body.work_type ?? "").toUpperCase(), daily_wage: Number(body.daily_wage ?? 0), needs_badge: body.needs_badge === false || body.needs_badge === 0 ? 0 : 1, status: body.status === "baja" ? "baja" : "activo", }; } export function fullName(w: { first_name: string; middle_name?: string | null; last_name_p: string; last_name_m: string; }): string { return [w.first_name, w.middle_name, w.last_name_p, w.last_name_m] .filter(Boolean) .join(" "); } export function frontName(w: { first_name: string; last_name_p: string }): string { return `${w.first_name} ${w.last_name_p}`.trim(); }