Pre-computed merge for change #17

This commit is contained in:
Cursor Origin 1970-01-01 00:00:00 +00:00
commit 71ce9ff191
8 changed files with 155 additions and 85 deletions

View file

@ -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 },

View file

@ -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", () => {

View file

@ -1174,11 +1174,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,
@ -1192,13 +1188,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 });
}

View file

@ -38,5 +38,6 @@
<include file="changesets/029-schema-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/032-document-validity-from-upload.sql" relativeToChangelogFile="true"/>
</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>
<div class="detail-section">
<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 v-else-if="imssReady" severity="info" :closable="false" class="mb-3">
Expediente listo para alta IMSS.
@ -60,12 +60,6 @@
class="w-full"
/>
</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
mode="basic"
:auto="true"
@ -73,7 +67,7 @@
accept=".jpg,.jpeg,.png,.pdf,.webp"
custom-upload
class="mt-2"
:disabled="uploading || !canUpload"
:disabled="uploading"
@uploader="uploadDocument"
/>
<div class="muted mt-2">{{ uploadHint }}</div>
@ -173,8 +167,6 @@ const documentType = ref(
);
const uploading = ref(false);
const docsTab = ref("current");
const issuedAt = ref<Date | null>(null);
const expiresAt = ref<Date | null>(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;
});
</script>

View file

@ -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",
});

View file

@ -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",
});