fix: subir documentos sin fecha de emisión ni vencimiento

La vigencia de 3 meses para alta o reingreso cuenta desde la fecha de
carga. Quien recibe el expediente revisa si el documento es válido.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-08 20:47:16 +00:00
parent 9ea4706954
commit e89b51bf43
No known key found for this signature in database
8 changed files with 155 additions and 85 deletions

View file

@ -82,18 +82,10 @@ export function evaluateDocumentValidity(
const issuedAt = doc.issued_at; const issuedAt = doc.issued_at;
const expiresAt = doc.expires_at; const expiresAt = doc.expires_at;
const uploadedAt = doc.uploaded_at;
const needFresh = freshnessRequired(ctx); const needFresh = freshnessRequired(ctx);
if (policy.validity_mode === "expiry") { if (policy.validity_mode === "expiry") {
if (policy.requires_expires_at && !expiresAt) {
return {
present: true,
valid: false,
validity_status: "missing_dates",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
const exp = parseDay(expiresAt); const exp = parseDay(expiresAt);
const todayDate = parseDay(today); const todayDate = parseDay(today);
if (exp && todayDate && exp < todayDate) { if (exp && todayDate && exp < todayDate) {
@ -115,20 +107,11 @@ export function evaluateDocumentValidity(
} }
if (policy.validity_mode === "freshness") { if (policy.validity_mode === "freshness") {
if (policy.requires_issued_at && !issuedAt) { if (needFresh) {
return { const uploaded = parseDay(uploadedAt);
present: true,
valid: false,
validity_status: "missing_dates",
issued_at: issuedAt,
expires_at: expiresAt,
};
}
if (needFresh && issuedAt) {
const issued = parseDay(issuedAt);
const days = policy.freshness_days ?? 90; const days = policy.freshness_days ?? 90;
const todayForFreshness = parseDay(today) ?? new Date(); const todayForFreshness = parseDay(today) ?? new Date();
if (issued && daysBetween(issued, todayForFreshness) > days) { if (uploaded && daysBetween(uploaded, todayForFreshness) > days) {
return { return {
present: true, present: true,
valid: false, valid: false,
@ -170,11 +153,11 @@ export type CatalogDocType = {
/** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */ /** Núcleo = required 1. El resto es catálogo: se sube si se tiene, no cuenta como faltante. */
export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [ export const DOCUMENT_TYPE_SEED: CatalogDocType[] = [
{ code: "foto", label: "Foto", required: 1, category: "identidad", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, { code: "foto", label: "Foto", required: 1, category: "identidad", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 }, { code: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, { code: "curp", label: "Constancia CURP", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, { code: "nss", label: "Constancia NSS", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, { code: "rfc", label: "Constancia RFC", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, requires_expires_at: 0 }, { code: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "alta_imss", label: "Alta IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, { code: "alta_imss", label: "Alta IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "baja_imss", label: "Baja IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, { code: "baja_imss", label: "Baja IMSS", required: 0, category: "imss", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
{ code: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 }, { code: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },

View file

@ -15,7 +15,7 @@ const curpPolicy: DocTypePolicy = {
required: true, required: true,
validity_mode: "freshness", validity_mode: "freshness",
freshness_days: 90, freshness_days: 90,
requires_issued_at: true, requires_issued_at: false,
requires_expires_at: false, requires_expires_at: false,
}; };
@ -26,7 +26,7 @@ const inePolicy: DocTypePolicy = {
validity_mode: "expiry", validity_mode: "expiry",
freshness_days: null, freshness_days: null,
requires_issued_at: false, requires_issued_at: false,
requires_expires_at: true, requires_expires_at: false,
}; };
Deno.test("freshnessRequired: sin alta exige frescor", () => { Deno.test("freshnessRequired: sin alta exige frescor", () => {
@ -69,7 +69,7 @@ Deno.test("freshnessRequired: rehire con alta posterior no exige frescor", () =>
); );
}); });
Deno.test("CURP stale when freshness required", () => { Deno.test("CURP stale when freshness required and upload is older than 90 days", () => {
const ctx: WorkerImssContext = { const ctx: WorkerImssContext = {
imss_status: "sin_alta", imss_status: "sin_alta",
last_rehire_at: null, last_rehire_at: null,
@ -85,6 +85,22 @@ Deno.test("CURP stale when freshness required", () => {
assertEquals(result.valid, false); assertEquals(result.valid, false);
}); });
Deno.test("CURP recent upload is fresh even if issued_at is old", () => {
const ctx: WorkerImssContext = {
imss_status: "sin_alta",
last_rehire_at: null,
current_alta_at: null,
};
const result = evaluateDocumentValidity(
curpPolicy,
{ type_code: "curp", issued_at: "2024-01-01", expires_at: null, uploaded_at: "2026-08-01" },
ctx,
"2026-08-20",
);
assertEquals(result.validity_status, "ok");
assertEquals(result.valid, true);
});
Deno.test("CURP old but ok when already alta", () => { Deno.test("CURP old but ok when already alta", () => {
const ctx: WorkerImssContext = { const ctx: WorkerImssContext = {
imss_status: "alta", imss_status: "alta",
@ -117,13 +133,26 @@ Deno.test("INE expired always invalid", () => {
assertEquals(result.valid, false); assertEquals(result.valid, false);
}); });
Deno.test("missing issued_at for freshness", () => { Deno.test("freshness without issued_at uses uploaded_at", () => {
const result = evaluateDocumentValidity( const result = evaluateDocumentValidity(
curpPolicy, curpPolicy,
{ type_code: "curp", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" }, { type_code: "curp", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" },
{ imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null }, { imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null },
"2026-08-20",
); );
assertEquals(result.validity_status, "missing_dates"); assertEquals(result.validity_status, "ok");
assertEquals(result.valid, true);
});
Deno.test("INE without expires_at is valid; reviewer checks the file", () => {
const result = evaluateDocumentValidity(
inePolicy,
{ type_code: "ine", issued_at: null, expires_at: null, uploaded_at: "2026-08-01" },
{ imss_status: "sin_alta", last_rehire_at: null, current_alta_at: null },
"2026-08-20",
);
assertEquals(result.validity_status, "ok");
assertEquals(result.valid, true);
}); });
Deno.test("núcleo de persona no se agranda", () => { Deno.test("núcleo de persona no se agranda", () => {

View file

@ -1174,11 +1174,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc
{}, {},
{ route }, { route },
); );
const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as { const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type);
validity_mode: string;
requires_issued_at: boolean;
requires_expires_at: boolean;
} | undefined;
if (!policy) { if (!policy) {
return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, { return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, {
route, route,
@ -1192,13 +1188,6 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc
const imssCompanyId = Number(form.get("imss_company_id") || 0) || null; const imssCompanyId = Number(form.get("imss_company_id") || 0) || null;
const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null; const imssAltaAt = String(form.get("imss_alta_at") || "").trim() || null;
const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null; const imssBajaAt = String(form.get("imss_baja_at") || "").trim() || null;
if (policy.requires_issued_at && !issuedAt) {
return respondApiError(c, "VALIDATION", `Indique la fecha de emisión del documento en ${route}`, { route, worker_id: id });
}
if (policy.requires_expires_at && !expiresAt) {
return respondApiError(c, "VALIDATION", `Indique la fecha de vigencia / vencimiento en ${route}`, { route, worker_id: id });
}
if (type === "alta_imss" && !imssCompanyId) { if (type === "alta_imss" && !imssCompanyId) {
return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id }); return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id });
} }

View file

@ -38,5 +38,6 @@
<include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/> <include file="changesets/029-schema-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/> <include file="changesets/030-rpc-work-program.sql" relativeToChangelogFile="true"/>
<include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/> <include file="changesets/031-project-doc-types-technical.sql" relativeToChangelogFile="true"/>
<include file="changesets/032-document-validity-from-upload.sql" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -0,0 +1,98 @@
--liquibase formatted sql
-- PANELS · core · vigencia documental desde la fecha de carga
--changeset panel:core-032a-doc-types-no-required-dates endDelimiter:; splitStatements:true
UPDATE core.document_types
SET requires_issued_at = false,
requires_expires_at = false;
--changeset panel:core-032b-fn-worker-eval-doc-validity splitStatements:false
CREATE OR REPLACE FUNCTION core._worker_eval_doc_validity(
p_policy jsonb,
p_doc jsonb,
p_ctx jsonb,
p_today date
)
RETURNS jsonb
LANGUAGE plpgsql
IMMUTABLE
AS $$
DECLARE
v_mode text := COALESCE(p_policy->>'validity_mode', 'none');
v_issued date;
v_expires date;
v_uploaded date;
v_need_fresh boolean;
v_days integer;
BEGIN
IF p_doc IS NULL THEN
RETURN jsonb_build_object(
'present', false,
'valid', false,
'validity_status', 'missing',
'issued_at', NULL,
'expires_at', NULL
);
END IF;
v_issued := NULLIF(p_doc->>'issued_at', '')::date;
v_expires := NULLIF(p_doc->>'expires_at', '')::date;
BEGIN
v_uploaded := COALESCE(
NULLIF(left(p_doc->>'uploaded_at', 10), '')::date,
NULL
);
EXCEPTION WHEN OTHERS THEN
v_uploaded := NULL;
END;
v_need_fresh := core._worker_freshness_required(p_ctx);
IF v_mode = 'expiry' THEN
IF v_expires IS NOT NULL AND v_expires < p_today THEN
RETURN jsonb_build_object(
'present', true,
'valid', false,
'validity_status', 'expired',
'issued_at', v_issued,
'expires_at', v_expires
);
END IF;
RETURN jsonb_build_object(
'present', true,
'valid', true,
'validity_status', 'ok',
'issued_at', v_issued,
'expires_at', v_expires
);
END IF;
IF v_mode = 'freshness' THEN
IF v_need_fresh AND v_uploaded IS NOT NULL THEN
v_days := COALESCE((p_policy->>'freshness_days')::integer, 90);
IF (p_today - v_uploaded) > v_days THEN
RETURN jsonb_build_object(
'present', true,
'valid', false,
'validity_status', 'stale_for_alta',
'issued_at', v_issued,
'expires_at', v_expires
);
END IF;
END IF;
RETURN jsonb_build_object(
'present', true,
'valid', true,
'validity_status', 'ok',
'issued_at', v_issued,
'expires_at', v_expires
);
END IF;
RETURN jsonb_build_object(
'present', true,
'valid', true,
'validity_status', 'ok',
'issued_at', v_issued,
'expires_at', v_expires
);
END;
$$;
--changeset panel:core-032c-grants endDelimiter:; splitStatements:true
GRANT EXECUTE ON FUNCTION core._worker_eval_doc_validity(jsonb, jsonb, jsonb, date) TO panels_core_app;

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="detail-section"> <div class="detail-section">
<Message v-if="freshnessRequired" severity="warn" :closable="false" class="mb-3"> <Message v-if="freshnessRequired" severity="warn" :closable="false" class="mb-3">
Reingreso o pendiente de alta IMSS: documentos CURP, NSS, RFC y domicilio deben tener emisión de máximo 3 meses. Reingreso o pendiente de alta IMSS: CURP, NSS, RFC y domicilio deben haberse subido en los últimos 3 meses. Quien recibe el expediente revisa si el documento en sí es vigente.
</Message> </Message>
<Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3"> <Message v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
Expediente listo para alta IMSS. Expediente listo para alta IMSS.
@ -60,12 +60,6 @@
class="w-full" class="w-full"
/> />
</FormField> </FormField>
<FormField v-if="selectedPolicy?.requires_issued_at || selectedPolicy?.validity_mode === 'freshness'" label="Fecha de emisión" class="mt-2">
<DatePicker v-model="issuedAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField>
<FormField v-if="selectedPolicy?.requires_expires_at || selectedPolicy?.validity_mode === 'expiry'" label="Vigencia / vencimiento" class="mt-2">
<DatePicker v-model="expiresAt" date-format="yy-mm-dd" show-icon class="w-full" />
</FormField>
<FileUpload <FileUpload
mode="basic" mode="basic"
:auto="true" :auto="true"
@ -73,7 +67,7 @@
accept=".jpg,.jpeg,.png,.pdf,.webp" accept=".jpg,.jpeg,.png,.pdf,.webp"
custom-upload custom-upload
class="mt-2" class="mt-2"
:disabled="uploading || !canUpload" :disabled="uploading"
@uploader="uploadDocument" @uploader="uploadDocument"
/> />
<div class="muted mt-2">{{ uploadHint }}</div> <div class="muted mt-2">{{ uploadHint }}</div>
@ -173,8 +167,6 @@ const documentType = ref(
); );
const uploading = ref(false); const uploading = ref(false);
const docsTab = ref("current"); const docsTab = ref("current");
const issuedAt = ref<Date | null>(null);
const expiresAt = ref<Date | null>(null);
const previewOpen = ref(false); const previewOpen = ref(false);
const previewPath = ref(""); const previewPath = ref("");
const previewName = ref(""); const previewName = ref("");
@ -212,17 +204,11 @@ const freshnessRequired = computed(() => !!props.freshnessRequired);
const imssReady = computed(() => !!props.imssReady); const imssReady = computed(() => !!props.imssReady);
const inProjectWithoutImss = computed(() => !!props.inProjectWithoutImss); const inProjectWithoutImss = computed(() => !!props.inProjectWithoutImss);
const canUpload = computed(() => {
if (selectedPolicy.value?.requires_issued_at && !issuedAt.value) return false;
if (selectedPolicy.value?.requires_expires_at && !expiresAt.value) return false;
return true;
});
const uploadHint = computed(() => { const uploadHint = computed(() => {
if (selectedPolicy.value?.requires_issued_at) return "Indica la fecha de emisión del documento (máx. 3 meses si aplica alta/reingreso)."; if (selectedPolicy.value?.validity_mode === "freshness") {
if (selectedPolicy.value?.requires_expires_at) return "Indica la vigencia impresa en la INE."; return "Solo sube el archivo. Si hay alta o reingreso pendiente, la vigencia de 3 meses cuenta desde la fecha de carga.";
if (selectedPolicy.value?.validity_mode === "expiry") return "Si el documento tiene vencimiento, indícalo. No es obligatorio para operar."; }
return "Elige el tipo y el archivo se sube solo. El catálogo extra no bloquea gafete ni alta IMSS."; return "Elige el tipo y el archivo se sube solo. Quien recibe el expediente revisa si el documento es válido. El catálogo extra no bloquea gafete ni alta IMSS.";
}); });
function typeLabel(code: string) { function typeLabel(code: string) {
@ -243,7 +229,7 @@ function validityLabel(status?: ValidityStatus | string) {
case "stale_for_alta": case "stale_for_alta":
return "Desactualizado para alta"; return "Desactualizado para alta";
case "missing_dates": case "missing_dates":
return "Sin fecha"; return "Por refrescar";
default: default:
return status || "—"; return status || "—";
} }
@ -268,29 +254,17 @@ function formatDate(value: string) {
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" }); return date.toLocaleDateString("es-MX", { day: "2-digit", month: "short", year: "numeric" });
} }
function toIsoDate(value: Date | null) {
if (!value) return "";
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
async function uploadDocument(event: { files: File[] }) { async function uploadDocument(event: { files: File[] }) {
const file = event.files?.[0]; const file = event.files?.[0];
if (!file || uploading.value || !canUpload.value) return; if (!file || uploading.value) return;
uploading.value = true; uploading.value = true;
try { try {
const body = new FormData(); const body = new FormData();
body.append("type", documentType.value); body.append("type", documentType.value);
body.append("file", file); body.append("file", file);
if (issuedAt.value) body.append("issued_at", toIsoDate(issuedAt.value));
if (expiresAt.value) body.append("expires_at", toIsoDate(expiresAt.value));
await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body }); await api(`/v1/workers/${props.workerId}/documents`, { method: "POST", body });
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 }); toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
docsTab.value = "current"; docsTab.value = "current";
issuedAt.value = null;
expiresAt.value = null;
emit("changed"); emit("changed");
} catch (error: unknown) { } catch (error: unknown) {
toast.add({ toast.add({
@ -312,8 +286,4 @@ function openDocument(row: DocumentRow) {
watch(() => props.initialType, (value) => { watch(() => props.initialType, (value) => {
if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value; if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;
}); });
watch(documentType, () => {
issuedAt.value = null;
expiresAt.value = null;
});
</script> </script>

View file

@ -406,7 +406,7 @@ function buildHomeAlerts(input: {
key: "home_frescos", key: "home_frescos",
severity: "warn", severity: "warn",
title: `${freshness} con documentos por refrescar`, title: `${freshness} con documentos por refrescar`,
detail: "Reingreso o alta pendiente: constancias con más de 3 meses.", detail: "Reingreso o alta pendiente: constancias subidas hace más de 3 meses.",
route: "/padron", route: "/padron",
filter: "freshness", filter: "freshness",
}); });

View file

@ -265,7 +265,7 @@ function baja(worker: Worker) {
function reactivate(worker: Worker) { function reactivate(worker: Worker) {
confirm.require({ confirm.require({
header: "Reactivar persona", header: "Reactivar persona",
message: `¿Reactivar a ${worker.first_name} ${worker.last_name_p}? Se pedirán documentos frescos (máx. 3 meses) para una nueva alta IMSS.`, message: `¿Reactivar a ${worker.first_name} ${worker.last_name_p}? Se pedirán documentos subidos en los últimos 3 meses para una nueva alta IMSS.`,
icon: "pi pi-user-plus", icon: "pi pi-user-plus",
acceptLabel: "Reactivar", acceptLabel: "Reactivar",
rejectLabel: "Cancelar", rejectLabel: "Cancelar",
@ -274,7 +274,7 @@ function reactivate(worker: Worker) {
toast.add({ toast.add({
severity: "info", severity: "info",
summary: "Reingreso", summary: "Reingreso",
detail: "Actualiza documentos IMSS (máx. 3 meses) antes del alta.", detail: "Vuelve a subir CURP, NSS, RFC y domicilio (máx. 3 meses desde la carga) antes del alta.",
life: 4000, life: 4000,
}); });
await loadWorkers(worker.id); await loadWorkers(worker.id);
@ -310,7 +310,7 @@ function syncStatusMessages() {
key: "docs_frescos", key: "docs_frescos",
severity: "warn" as const, severity: "warn" as const,
title: `${freshnessCount.value} con documentos por refrescar`, title: `${freshnessCount.value} con documentos por refrescar`,
detail: "Reingreso o pendiente de alta: CURP, NSS, RFC o domicilio con más de 3 meses.", detail: "Reingreso o pendiente de alta: CURP, NSS, RFC o domicilio subidos hace más de 3 meses.",
route: "/padron", route: "/padron",
filter: "freshness", filter: "freshness",
}); });