diff --git a/api/document_validity.ts b/api/document_validity.ts
index 8ecfc39..4dbeddb 100644
--- a/api/document_validity.ts
+++ b/api/document_validity.ts
@@ -82,18 +82,10 @@ export function evaluateDocumentValidity(
const issuedAt = doc.issued_at;
const expiresAt = doc.expires_at;
+ const uploadedAt = doc.uploaded_at;
const needFresh = freshnessRequired(ctx);
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 todayDate = parseDay(today);
if (exp && todayDate && exp < todayDate) {
@@ -115,20 +107,11 @@ export function evaluateDocumentValidity(
}
if (policy.validity_mode === "freshness") {
- if (policy.requires_issued_at && !issuedAt) {
- return {
- present: true,
- valid: false,
- validity_status: "missing_dates",
- issued_at: issuedAt,
- expires_at: expiresAt,
- };
- }
- if (needFresh && issuedAt) {
- const issued = parseDay(issuedAt);
+ if (needFresh) {
+ const uploaded = parseDay(uploadedAt);
const days = policy.freshness_days ?? 90;
const todayForFreshness = parseDay(today) ?? new Date();
- if (issued && daysBetween(issued, todayForFreshness) > days) {
+ if (uploaded && daysBetween(uploaded, todayForFreshness) > days) {
return {
present: true,
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. */
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: "ine", label: "INE", required: 1, category: "identidad", validity_mode: "expiry", freshness_days: null, requires_issued_at: 0, requires_expires_at: 1 },
- { code: "curp", label: "Constancia CURP", 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: 1, 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: "domicilio", label: "Comprobante de domicilio", required: 1, category: "identidad", validity_mode: "freshness", freshness_days: 90, requires_issued_at: 1, 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: 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: 0, 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: 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: "contrato", label: "Contrato laboral", required: 0, category: "laboral", validity_mode: "none", freshness_days: null, requires_issued_at: 0, requires_expires_at: 0 },
diff --git a/api/document_validity_test.ts b/api/document_validity_test.ts
index 216bcff..b2649f7 100644
--- a/api/document_validity_test.ts
+++ b/api/document_validity_test.ts
@@ -15,7 +15,7 @@ const curpPolicy: DocTypePolicy = {
required: true,
validity_mode: "freshness",
freshness_days: 90,
- requires_issued_at: true,
+ requires_issued_at: false,
requires_expires_at: false,
};
@@ -26,7 +26,7 @@ const inePolicy: DocTypePolicy = {
validity_mode: "expiry",
freshness_days: null,
requires_issued_at: false,
- requires_expires_at: true,
+ requires_expires_at: false,
};
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 = {
imss_status: "sin_alta",
last_rehire_at: null,
@@ -85,6 +85,22 @@ Deno.test("CURP stale when freshness required", () => {
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", () => {
const ctx: WorkerImssContext = {
imss_status: "alta",
@@ -117,13 +133,26 @@ Deno.test("INE expired always invalid", () => {
assertEquals(result.valid, false);
});
-Deno.test("missing issued_at for freshness", () => {
+Deno.test("freshness without issued_at uses uploaded_at", () => {
const result = evaluateDocumentValidity(
curpPolicy,
{ 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 },
+ "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", () => {
diff --git a/api/main.ts b/api/main.ts
index cc70b27..32619da 100644
--- a/api/main.ts
+++ b/api/main.ts
@@ -1213,11 +1213,7 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc
{},
{ route },
);
- const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type) as {
- validity_mode: string;
- requires_issued_at: boolean;
- requires_expires_at: boolean;
- } | undefined;
+ const policy = (catEnv.data?.document_types ?? []).find((t) => String(t.code) === type);
if (!policy) {
return respondApiError(c, "VALIDATION", `Tipo de documento no válido en ${route}: ${type}`, {
route,
@@ -1231,13 +1227,6 @@ app.post("/v1/workers/:id/documents", ...requireCoreAuth, requirePermission("doc
const imssCompanyId = Number(form.get("imss_company_id") || 0) || null;
const imssAltaAt = String(form.get("imss_alta_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) {
return respondApiError(c, "VALIDATION", `Seleccione la empresa patrón del alta IMSS en ${route}`, { route, worker_id: id });
}
diff --git a/db/core/changelog-master.xml b/db/core/changelog-master.xml
index 57082cd..a00bad9 100644
--- a/db/core/changelog-master.xml
+++ b/db/core/changelog-master.xml
@@ -40,6 +40,7 @@
+
diff --git a/db/core/changesets/032-document-validity-from-upload.sql b/db/core/changesets/032-document-validity-from-upload.sql
new file mode 100644
index 0000000..6fcecd8
--- /dev/null
+++ b/db/core/changesets/032-document-validity-from-upload.sql
@@ -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;
diff --git a/web-panel/components/padron/WorkerDocuments.vue b/web-panel/components/padron/WorkerDocuments.vue
index 4d06eab..0de7098 100644
--- a/web-panel/components/padron/WorkerDocuments.vue
+++ b/web-panel/components/padron/WorkerDocuments.vue
@@ -1,7 +1,7 @@
- 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.
Expediente listo para alta IMSS.
@@ -60,12 +60,6 @@
class="w-full"
/>
-
-
-
-
-
-
{{ uploadHint }}
@@ -173,8 +167,6 @@ const documentType = ref(
);
const uploading = ref(false);
const docsTab = ref("current");
-const issuedAt = ref(null);
-const expiresAt = ref(null);
const previewOpen = ref(false);
const previewPath = ref("");
const previewName = ref("");
@@ -212,17 +204,11 @@ const freshnessRequired = computed(() => !!props.freshnessRequired);
const imssReady = computed(() => !!props.imssReady);
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(() => {
- 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?.requires_expires_at) return "Indica la vigencia impresa en la INE.";
- 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.";
+ if (selectedPolicy.value?.validity_mode === "freshness") {
+ return "Solo sube el archivo. Si hay alta o reingreso pendiente, la vigencia de 3 meses cuenta desde la fecha de carga.";
+ }
+ 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) {
@@ -243,7 +229,7 @@ function validityLabel(status?: ValidityStatus | string) {
case "stale_for_alta":
return "Desactualizado para alta";
case "missing_dates":
- return "Sin fecha";
+ return "Por refrescar";
default:
return status || "—";
}
@@ -268,29 +254,17 @@ function formatDate(value: string) {
if (Number.isNaN(date.getTime())) return value;
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[] }) {
const file = event.files?.[0];
- if (!file || uploading.value || !canUpload.value) return;
+ if (!file || uploading.value) return;
uploading.value = true;
try {
const body = new FormData();
body.append("type", documentType.value);
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 });
toast.add({ severity: "success", summary: "Documento cifrado y guardado", life: 2200 });
docsTab.value = "current";
- issuedAt.value = null;
- expiresAt.value = null;
emit("changed");
} catch (error: unknown) {
toast.add({
@@ -312,8 +286,4 @@ function openDocument(row: DocumentRow) {
watch(() => props.initialType, (value) => {
if (value && value !== "alta_imss" && value !== "baja_imss") documentType.value = value;
});
-watch(documentType, () => {
- issuedAt.value = null;
- expiresAt.value = null;
-});
diff --git a/web-panel/pages/index.vue b/web-panel/pages/index.vue
index c80d6bf..bee47c8 100644
--- a/web-panel/pages/index.vue
+++ b/web-panel/pages/index.vue
@@ -406,7 +406,7 @@ function buildHomeAlerts(input: {
key: "home_frescos",
severity: "warn",
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",
filter: "freshness",
});
diff --git a/web-panel/pages/padron/index.vue b/web-panel/pages/padron/index.vue
index dcbc8bb..5ff91e3 100644
--- a/web-panel/pages/padron/index.vue
+++ b/web-panel/pages/padron/index.vue
@@ -265,7 +265,7 @@ function baja(worker: Worker) {
function reactivate(worker: Worker) {
confirm.require({
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",
acceptLabel: "Reactivar",
rejectLabel: "Cancelar",
@@ -274,7 +274,7 @@ function reactivate(worker: Worker) {
toast.add({
severity: "info",
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,
});
await loadWorkers(worker.id);
@@ -310,7 +310,7 @@ function syncStatusMessages() {
key: "docs_frescos",
severity: "warn" as const,
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",
filter: "freshness",
});