candidate-presentation.js

Overview

Este módulo implementa la capa de presentación de Match para la fase M2.3a. Consume candidatos ya evaluados por evaluateCandidatePair() y generateCandidatesForRequirement() sin mutarlos, sin tocar Mongo y sin calcular score, porcentaje ni rank numérico .

Su propósito es doble. Primero, traduce el contrato de candidato a un presentationState determinista. Segundo, ordena los candidatos presentables con reglas puras y predecibles, usando offerId como desempate técnico .

[!IMPORTANT] candidate.status y presentationState son conceptos distintos. Un candidato puede seguir siendo status: "blocked" y, aun así, mostrarse como budget_warning .

[!NOTE] Este archivo no lee señales de preferencia preferred para ordenar. Tampoco usa monto, distancia geográfica, amenidades, completitud ni timestamps .

How it works

flowchart TD
    A[Candidate evaluado] --> B[getCandidatePresentationState]
    A --> C[isApprovedBudgetWarningCandidate]
    A --> D[isApprovedAlternativeCandidate]

    E[APPROVED_BUDGET_WARNING_REASONS] --> C
    F[APPROVED_PROPERTY_TYPE_ALTERNATIVES] --> D
    G[isEligiblePair] --> C
    G --> D

    C --> B
    D --> B

    B --> H{hidden}
    H -->|yes| I[Excluded from output]
    H -->|no| J[Kept for presentation]

    J --> K[orderCandidatesForPresentation]
    K --> L[eligible]
    K --> M[pending]
    K --> N[budget_warning]
    K --> O[alternative]

    M --> P[count unresolved declared constraints]
    P --> Q[offerId asc]
    L --> Q
    N --> Q
    O --> Q

    Q --> R[Ordered candidates]

La entrada es un arreglo de candidatos ya evaluados. El módulo deriva el estado de presentación con reglas cerradas. Después excluye hidden y ordena el resto por prioridad de estado. Dentro de pending, usa la cantidad de criterios declarados aún sin resolver. En los demás grupos, usa offerId ascendente como único desempate .

Key components

{
    "title": "isApprovedBudgetWarningCandidate",
    "content": "Valida si un candidato bloqueado puede mostrarse como advertencia de presupuesto. Exige un solo blocker de precio, razones aprobadas y elegibilidad del par.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-presentation.js",
    "badges": [
        "public"
    ]
}
{
    "title": "isApprovedAlternativeCandidate",
    "content": "Valida si un candidato bloqueado puede mostrarse como alternativa aprobada por tipo de inmueble. Usa una tabla sim\u00e9trica de propertyType y exige elegibilidad del par.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-presentation.js",
    "badges": [
        "public"
    ]
}
{
    "title": "getCandidatePresentationState",
    "content": "Deriva el estado visible del candidato. Convierte status de dominio en eligible, pending, budget_warning, alternative o hidden sin modificar el candidato original.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-presentation.js",
    "badges": [
        "public"
    ]
}
{
    "title": "isPrimaryMatchCandidate",
    "content": "Indica si el candidato cuenta como coincidencia principal. Solo eligible y pending consumen cupo principal; budget_warning y alternative quedan como secundarias.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-presentation.js",
    "badges": [
        "public"
    ]
}
{
    "title": "isCandidatePresentable",
    "content": "Indica si el candidato puede llegar a un consumidor final. Equivale a verificar que su estado de presentaci\u00f3n no sea hidden.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-presentation.js",
    "badges": [
        "public"
    ]
}
{
    "title": "orderCandidatesForPresentation",
    "content": "Ordena candidatos presentables de forma determinista. Respeta el orden de estados, resuelve pending por evidencia faltante y usa offerId ascendente como desempate final.",
    "type": "function",
    "filePath": "services/api/src/modules/match/candidate-presentation.js",
    "badges": [
        "public"
    ]
}

Data & fields

[
    {
        "name": "candidate.status",
        "type": "string",
        "required": true,
        "description": "Estado de dominio leído por la capa de presentación. Los valores observados aquí son eligible, pending y blocked."
    },
    {
        "name": "candidate.blockers",
        "type": "Array<object>",
        "description": "Solo importa para budget_warning y alternative. En ambos casos debe contener exactamente un blocker."
    },
    {
        "name": "candidate.eligibility.requirement.eligible",
        "type": "boolean",
        "required": true,
        "description": "Debe ser true para que una excepción de presentación sea válida."
    },
    {
        "name": "candidate.eligibility.offer.eligible",
        "type": "boolean",
        "required": true,
        "description": "Debe ser true para que una excepción de presentación sea válida."
    },
    {
        "name": "candidate.pairCompatibility.compatible",
        "type": "boolean",
        "required": true,
        "description": "Debe ser true para que una excepción de presentación sea válida."
    },
    {
        "name": "candidate.pendingRequired",
        "type": "Array<object>",
        "description": "Se cuenta solo cuando el candidato está pending. Se suma con pendingGates para el orden interno."
    },
    {
        "name": "candidate.pendingGates",
        "type": "Array<object>",
        "description": "Se cuenta solo cuando el candidato está pending. Se suma con pendingRequired para el orden interno."
    },
    {
        "name": "candidate.offerId",
        "type": "string",
        "description": "Desempate técnico final. Se compara como cadena ascendente."
    }
]
[
    {
        "name": "blocker.field",
        "type": "string",
        "required": true,
        "description": "Debe ser price para budget_warning o propertyType para alternative."
    },
    {
        "name": "blocker.reason",
        "type": "string",
        "required": true,
        "description": "Debe ser ABOVE_BUDGET_MAX o BELOW_BUDGET_MIN para budget_warning."
    },
    {
        "name": "blocker.requirementValue",
        "type": "string",
        "required": true,
        "description": "Valor del Requirement usado para validar la alternativa aprobada."
    },
    {
        "name": "blocker.offerValue",
        "type": "string",
        "required": true,
        "description": "Valor del Offer usado para validar la alternativa aprobada."
    }
]

Constants

  • APPROVED_BUDGET_WARNING_REASONS — lista inmutable con ABOVE_BUDGET_MAX y BELOW_BUDGET_MIN. Estas son las únicas razones aceptadas para budget_warning .
  • APPROVED_PROPERTY_TYPE_ALTERNATIVES — tabla inmutable y simétrica. Acepta apartment ↔ house como alternativa presentable .
  • PRESENTATION_STATE_ORDER — orden de prioridad. El orden es eligible, pending, budget_warning, alternative .

Behaviors

  • isApprovedBudgetWarningCandidate(candidate) — devuelve true solo si el candidato está blocked, tiene un solo blocker de precio y pertenece a una pareja elegible .
  • isApprovedAlternativeCandidate(candidate) — devuelve true solo si el candidato está blocked, tiene un solo blocker de propertyType y el par está aprobado por la tabla de alternativas .
  • getCandidatePresentationState(candidate) — devuelve eligible, pending, budget_warning, alternative o hidden, en ese orden de decisión .
  • isPrimaryMatchCandidate(candidate) — devuelve true solo para eligible y pending. Las otras dos presentaciones son visibles, pero secundarias .
  • isCandidatePresentable(candidate) — devuelve true cuando el estado de presentación no es hidden .
  • orderCandidatesForPresentation(candidates) — filtra hidden, agrupa por estado y ordena de forma estable y determinista .

Internal helper behavior

  • isEligiblePair(candidate) — comprueba tres condiciones juntas:
    • eligibility.requirement.eligible === true
    • eligibility.offer.eligible === true
    • pairCompatibility.compatible === true
  • countUnresolvedDeclaredConstraints(candidate) — suma pendingRequired.length y pendingGates.length.
  • compareOfferIdAscending(a, b) — compara offerId como cadena. Si falta, usa cadena vacía.

Estas funciones no se exponen fuera del módulo, pero sostienen el orden final .

Workflows

1. Recibir candidatos evaluados | El módulo asume que el candidato ya trae status, blockers, eligibility, pairCompatibility y buckets de pendientes.
2. Derivar el estado | `getCandidatePresentationState` clasifica el candidato sin tocar `candidate.status`.
3. Filtrar ocultos | `hidden` no sale del resultado final.
4. Ordenar por estado | Primero van `eligible`, luego `pending`, luego `budget_warning`, y al final `alternative`.
5. Resolver pending | Dentro de `pending`, los candidatos con menos evidencia faltante van primero.
6. Aplicar desempate final | En los demás grupos, el orden final usa `offerId` ascendente.

Gotchas & edge cases

[!WARNING] candidate.status y presentationState pueden divergir deliberadamente. Un candidato puede seguir siendo blocked y mostrarse como budget_warning .

[!WARNING] CURRENCY_MISMATCH y PERIODICITY_MISMATCH nunca se convierten en budget_warning. Esos casos quedan hidden, porque no significan “fuera de presupuesto” .

[!CAUTION] Las excepciones de budget_warning y alternative exigen exactamente un blocker. Si hay más de uno, el candidato se oculta .

[!NOTE] Si faltan eligibility o pairCompatibility, la validación falla cerrado. El candidato no califica para ninguna excepción presentable .

[!IMPORTANT] Este módulo no reimplementa ni recalcula la lógica de Match. Solo consume el contrato de candidato ya producido por la capa anterior .

services/api/src/modules/match/candidate-generation.js produce el contrato que esta capa consume. Su salida incluye status, eligibility, pairCompatibility, signals, blockers, pendingRequired, pendingGates, unknowns y notApplicable .

services/api/src/scripts/previewMatchForRequirement.js usa getCandidatePresentationState y orderCandidatesForPresentation para anotar el preview de QA. Ese script conserva candidatos blocked para explicabilidad, pero no recalcula la lógica de presentación .

La cobertura de pruebas vive en services/api/test/match-m2.3a-candidate-presentation.test.js. Ese archivo valida estados base, excepciones de presupuesto, alternativas de tipo, orden determinista y garantías estructurales, como la ausencia de campos de score o rank .

[!TIP] La relación más importante de este archivo es con la fase de generación previa. Aquí se decide cómo presentar el candidato, no cómo calcularlo .