Skip to content

Plan Técnico — Módulo periodontogram

Status: plan técnico aprobado para implementación. Acompaña al plan de diseño previo. Issue tracker: (pendiente abrir issue principal). Fecha: 2026-05-25. Autor: Ramon Martinez + Claude. Phase B precondición: ninguna — periodontogram puede arrancar en paralelo a otros módulos abiertos.


1. Resumen ejecutivo

Nuevo módulo opcional, removable que añade diagnóstico y seguimiento periodontal SEPA dentro de la ficha del paciente. Vive como sub-tab dentro del modo Diagnóstico del ClinicalTab. Reutiliza SVG de dientes y TimelineSlider del odontograma. Persiste snapshots inmutables fechados con un solo draft activo por paciente.

AspectoDecisión
Nombre móduloperiodontogram
Backend pathbackend/app/modules/periodontogram/
Frontend layerbackend/app/modules/periodontogram/frontend/
API prefix/api/v1/periodontogram/
Permisos namespacedperiodontogram.read, periodontogram.write
Rama Alembicbranch_labels=("periodontogram",), anchor down_revision="0001"
installable / auto_install / removableTrue / False / True
depends["patients", "odontogram"]
Tablas creadasperiodontogram_snapshots, periodontogram_teeth, periodontogram_sites
Slot consumidopatient.diagnosis.subtabs (nuevo, expuesto por patients)
Slot internoreúsa useModuleSlots existente
Eventos consumidosodontogram.treatment.performed, patient.archived
Eventos publicadosperiodontogram.snapshot.closed
NotasCampo notes en snapshot (NO polimórficas — ver §13)

2. Auditoría arquitectónica previa

Verificado contra el código actual:

ComponenteArchivoHallazgo
BaseModule contratobackend/app/core/plugins/base.pyMétodos abstractos get_models, get_router; opcionales get_event_handlers, get_permissions, get_tools, get_agents, lifecycle hooks install/uninstall/post_upgrade.
Manifest fieldsbackend/app/core/plugins/manifest.py + ejemplos schedules/odontogramname, version, summary, author, license, category, depends, installable, auto_install, removable, role_permissions, frontend{layer_path, navigation}.
Patrón removablebackend/app/modules/schedules/__init__.pyinstallable=True, auto_install=True, removable=True. Nuestro caso: auto_install=False.
Migración inicial removablebackend/app/modules/schedules/migrations/versions/sch_0001_initial.pyrevision="sch_0001", down_revision="0001", branch_labels=("schedules",).
Lifecycle hooksbackend/app/modules/schedules/lifecycle.pyFunciones módulo-level con logging. BaseModule también acepta métodos en la subclase.
Slot registryfrontend/app/composables/useModuleSlots.tsregisterSlot(name, entry), useModuleSlots().resolve(name, ctx). Permission gating built-in.
ClinicalTab modosbackend/app/modules/patients/frontend/components/patient/ClinicalTab.vue:115Modos hard-coded en v-if. Diagnosis renderiza <DiagnosisMode> (componente de odontogram).
DiagnosisModebackend/app/modules/odontogram/frontend/components/clinical/DiagnosisMode.vueVive en odontogram. Patients NO depende de odontogram (importación cruzada vía slots/layer).
TimelineSliderbackend/app/modules/odontogram/frontend/components/odontogram/TimelineSlider.vueProps: dates, currentDate. Reusable sin tocar. Auto-import vía Nuxt layer.
ToothSVGPathsbackend/app/modules/odontogram/frontend/components/odontogram/ToothSVGPaths.tsVista lateral con 8 posiciones + quadrant transforms (getToothTransform). Reusable sin tocar.
clinical_notes matrixbackend/app/modules/clinical_notes/models.pyremovable=False. Añadir periodontogram_snapshot a su CHECK constraint requeriría cambio en un módulo core desde uno removable → rechazado. Usamos notes Text en snapshot.
Cross-module FKmanifest.depends enforcement vía manifest_validatorFK solo a patients/odontogram/clinics/users (todos en deps o core).

3. Estructura de archivos a crear

backend/app/modules/periodontogram/
├── __init__.py                     # PeriodontogramModule(BaseModule)
├── CLAUDE.md                       # Doc agente
├── CHANGELOG.md
├── constants.py                    # SITE_CODES, FDI_PERMANENT, scales
├── models.py                       # PeriodontogramSnapshot, …Tooth, …Site
├── schemas.py                      # Pydantic in/out
├── service.py                      # PeriodontogramService
├── router.py                       # Endpoints REST
├── events.py                       # Handlers de eventos externos
├── indices.py                      # Cálculo BoP, PI, CAL, bolsas≥5mm
├── lifecycle.py                    # install/uninstall hooks (logging)
├── migrations/
│   └── versions/
│       └── perio_0001_initial.py   # branch_labels=("periodontogram",)
└── frontend/
    ├── nuxt.config.ts              # capa Nuxt
    ├── i18n/
    │   └── locales/
    │       ├── en.json
    │       └── es.json
    ├── composables/
    │   ├── usePeriodontogram.ts    # estado + API
    │   ├── usePeriodontogramSession.ts  # draft management + autosave
    │   └── usePerioHeatmap.ts      # color tokens según sondaje
    ├── plugins/
    │   └── slots.client.ts         # registra patient.diagnosis.subtabs entry
    ├── components/
    │   ├── PeriodontogramView.vue          # entry-point del slot
    │   ├── PeriodontogramChart.vue         # orquestador 4 filas + tablas
    │   ├── PerioArchBlock.vue              # bloque superior o inferior (tabla + 2 filas dientes)
    │   ├── PerioMetricsTable.vue           # 9 filas SEPA
    │   ├── PerioToothRow.vue               # 1 fila de dientes (vestibular o palatino)
    │   ├── PerioToothLateral.vue           # 1 diente lateral (SVG flippable)
    │   ├── PerioSiteMarker.vue             # marcador 1 sitio con color heatmap
    │   ├── PerioSiteInputPopover.vue       # popover de edición por sitio
    │   ├── PerioIndicesBanner.vue          # BoP/PI/CAL banner
    │   ├── PerioSessionActions.vue         # botonera draft/close
    │   ├── PerioHistoryBanner.vue          # banner "viendo histórico"
    │   └── PerioEmptyState.vue             # CTA crear primera sesión
    └── types.ts                            # tipos compartidos

backend/tests/modules/periodontogram/
├── conftest.py
├── test_uninstall_roundtrip.py     # round-trip Alembic
├── test_snapshot_lifecycle.py      # draft → closed
├── test_indices_calc.py            # BoP/PI/CAL/bolsas
├── test_acoplamiento_odontogram.py # pre-relleno desde tooth_records
├── test_permissions.py             # RBAC por rol
└── test_api_validation.py          # rangos sondaje, FDI, etc.

docs/
├── adr/
│   └── 0014-periodontogram-snapshot-model.md   # inmutable vs event-sourcing
├── modules/
│   └── periodontogram.md
├── technical/
│   ├── periodontogram-plan.md      # este archivo
│   └── periodontogram/
│       ├── overview.md
│       ├── events.md
│       └── permissions.md
├── user-manual/
│   ├── es/periodontogram/
│   │   ├── index.md
│   │   └── screens/
│   │       └── periodontograma-view.md
│   └── en/periodontogram/
│       ├── index.md
│       └── screens/
│           └── periodontogram-view.md
└── screenshots/periodontogram/

# Archivos a tocar en módulos existentes:
backend/app/modules/patients/frontend/components/patient/ClinicalTab.vue   # expone slot
backend/app/modules/patients/CLAUDE.md                                       # documenta nuevo slot
frontend/app/config/permissions.ts                                           # añade periodontogram.*

4. Modelo de datos — schema SQL exacto

4.1 periodontogram_snapshots

Una fila por sesión de exploración (draft o cerrada).

sql
CREATE TABLE periodontogram_snapshots (
    id              UUID            PRIMARY KEY DEFAULT gen_random_uuid(),
    clinic_id       UUID            NOT NULL REFERENCES clinics(id),
    patient_id      UUID            NOT NULL REFERENCES patients(id),
    status          VARCHAR(10)     NOT NULL,                  -- 'draft' | 'closed'
    recorded_at     TIMESTAMPTZ     NOT NULL DEFAULT now(),    -- fecha clínica de la sesión
    recorded_by     UUID            NOT NULL REFERENCES users(id),
    closed_at       TIMESTAMPTZ,                               -- NULL mientras draft
    closed_by       UUID            REFERENCES users(id),
    notes           TEXT,                                      -- nota libre de la sesión (MVP, NO polimórfica)
    -- snapshot de índices computados al cerrar (frozen para queries rápidas)
    indices         JSONB,                                     -- { bop_pct, pi_pct, cal_mean_mm, deep_pockets_count }
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ     NOT NULL DEFAULT now(),

    CONSTRAINT ck_perio_snap_status      CHECK (status IN ('draft', 'closed')),
    CONSTRAINT ck_perio_snap_closed_pair CHECK (
        (status = 'draft'  AND closed_at IS NULL AND closed_by IS NULL) OR
        (status = 'closed' AND closed_at IS NOT NULL AND closed_by IS NOT NULL)
    )
);

CREATE INDEX ix_perio_snap_clinic         ON periodontogram_snapshots (clinic_id);
CREATE INDEX ix_perio_snap_patient        ON periodontogram_snapshots (patient_id);
CREATE INDEX ix_perio_snap_patient_status ON periodontogram_snapshots (patient_id, status);
CREATE INDEX ix_perio_snap_patient_closed_at
       ON periodontogram_snapshots (patient_id, closed_at DESC)
       WHERE status = 'closed';

-- Unicidad blanda: máximo un draft por paciente.
CREATE UNIQUE INDEX uq_perio_snap_one_draft_per_patient
       ON periodontogram_snapshots (patient_id)
       WHERE status = 'draft';

4.2 periodontogram_teeth

Una fila por diente presente en el snapshot. Si la columna is_present=false, las filas hijas de sitios no aplican (se omite su escritura).

sql
CREATE TABLE periodontogram_teeth (
    id                      UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    snapshot_id             UUID        NOT NULL REFERENCES periodontogram_snapshots(id) ON DELETE CASCADE,
    tooth_number            INTEGER     NOT NULL,            -- FDI permanente 11..48
    is_present              BOOLEAN     NOT NULL DEFAULT true,
    is_implant              BOOLEAN     NOT NULL DEFAULT false,
    mobility                INTEGER,                          -- 0..3 (Miller)
    prognosis               VARCHAR(10),                      -- 'good' | 'fair' | 'poor' | 'hopeless'
    furcation_buccal        VARCHAR(4),                       -- '0' | 'I' | 'II' | 'III'
    furcation_lingual       VARCHAR(4),
    keratinized_gingiva_mm  INTEGER,                          -- anchura encía queratinizada
    created_at              TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at              TIMESTAMPTZ NOT NULL DEFAULT now(),

    CONSTRAINT ck_perio_tooth_fdi
        CHECK (tooth_number BETWEEN 11 AND 48
               AND (tooth_number % 10) BETWEEN 1 AND 8
               AND (tooth_number / 10) BETWEEN 1 AND 4),
    CONSTRAINT ck_perio_tooth_mobility    CHECK (mobility IS NULL OR mobility BETWEEN 0 AND 3),
    CONSTRAINT ck_perio_tooth_prognosis   CHECK (prognosis IS NULL OR prognosis IN ('good','fair','poor','hopeless')),
    CONSTRAINT ck_perio_tooth_furcation_b CHECK (furcation_buccal  IS NULL OR furcation_buccal  IN ('0','I','II','III')),
    CONSTRAINT ck_perio_tooth_furcation_l CHECK (furcation_lingual IS NULL OR furcation_lingual IN ('0','I','II','III')),
    CONSTRAINT ck_perio_tooth_kg_range    CHECK (keratinized_gingiva_mm IS NULL OR keratinized_gingiva_mm BETWEEN 0 AND 20),

    UNIQUE (snapshot_id, tooth_number)
);

CREATE INDEX ix_perio_teeth_snapshot ON periodontogram_teeth (snapshot_id);

4.3 periodontogram_sites

Una fila por sitio (6 por diente). Se crea lazy: solo cuando el clínico introduce el primer valor en ese sitio.

sql
CREATE TABLE periodontogram_sites (
    id                    UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    snapshot_id           UUID        NOT NULL REFERENCES periodontogram_snapshots(id) ON DELETE CASCADE,
    tooth_id              UUID        NOT NULL REFERENCES periodontogram_teeth(id) ON DELETE CASCADE,
    tooth_number          INTEGER     NOT NULL,                -- denormalizado para queries directas
    site_code             VARCHAR(2)  NOT NULL,                -- 'MV'|'V'|'DV'|'ML'|'L'|'DL'
    probing_depth_mm      INTEGER,                             -- 0..15
    gingival_margin_mm    INTEGER,                             -- -5..10 (negativo = hiperplasia)
    bleeding_on_probing   BOOLEAN     NOT NULL DEFAULT false,
    plaque                BOOLEAN     NOT NULL DEFAULT false,
    suppuration           BOOLEAN     NOT NULL DEFAULT false,
    created_at            TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at            TIMESTAMPTZ NOT NULL DEFAULT now(),

    CONSTRAINT ck_perio_site_code     CHECK (site_code IN ('MV','V','DV','ML','L','DL')),
    CONSTRAINT ck_perio_site_pd_range CHECK (probing_depth_mm   IS NULL OR probing_depth_mm   BETWEEN 0 AND 15),
    CONSTRAINT ck_perio_site_gm_range CHECK (gingival_margin_mm IS NULL OR gingival_margin_mm BETWEEN -5 AND 10),

    UNIQUE (snapshot_id, tooth_number, site_code)
);

CREATE INDEX ix_perio_sites_snapshot ON periodontogram_sites (snapshot_id);
CREATE INDEX ix_perio_sites_tooth    ON periodontogram_sites (tooth_id);
-- Index para agregados rápidos (BoP %, bolsas ≥5mm).
CREATE INDEX ix_perio_sites_pd_bop   ON periodontogram_sites (snapshot_id, probing_depth_mm, bleeding_on_probing);

4.4 Sin FK a odontogram.tooth_records

Aunque periodontogram declare depends=["patients","odontogram"], no creamos FK a tablas del odontograma. Solo guardamos tooth_number (entero FDI). Razones:

  • El acoplamiento odontograma→periodontograma es por lectura, no integridad referencial.
  • Permite que el odontograma cambie su modelo interno sin migración del periodontograma.
  • Aunque odontograma hoy es removable=False, no asumimos integridad estructural.

5. Migración Alembic — esqueleto

python
# backend/app/modules/periodontogram/migrations/versions/perio_0001_initial.py

"""periodontogram module — initial schema.

Revision ID: perio_0001
Revises: 0001
Create Date: 2026-05-25
"""

from collections.abc import Sequence

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

revision: str = "perio_0001"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = ("periodontogram",)
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
    op.create_table(
        "periodontogram_snapshots",
        sa.Column("id", sa.UUID(), nullable=False),
        sa.Column("clinic_id", sa.UUID(), nullable=False),
        sa.Column("patient_id", sa.UUID(), nullable=False),
        sa.Column("status", sa.String(length=10), nullable=False),
        sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
        sa.Column("recorded_by", sa.UUID(), nullable=False),
        sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
        sa.Column("closed_by", sa.UUID(), nullable=True),
        sa.Column("notes", sa.Text(), nullable=True),
        sa.Column("indices", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
        sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
        sa.CheckConstraint("status IN ('draft','closed')", name="ck_perio_snap_status"),
        sa.CheckConstraint(
            "(status='draft' AND closed_at IS NULL AND closed_by IS NULL) "
            "OR (status='closed' AND closed_at IS NOT NULL AND closed_by IS NOT NULL)",
            name="ck_perio_snap_closed_pair",
        ),
        sa.ForeignKeyConstraint(["clinic_id"], ["clinics.id"]),
        sa.ForeignKeyConstraint(["patient_id"], ["patients.id"]),
        sa.ForeignKeyConstraint(["recorded_by"], ["users.id"]),
        sa.ForeignKeyConstraint(["closed_by"], ["users.id"]),
        sa.PrimaryKeyConstraint("id"),
    )
    op.create_index("ix_perio_snap_clinic", "periodontogram_snapshots", ["clinic_id"])
    op.create_index("ix_perio_snap_patient", "periodontogram_snapshots", ["patient_id"])
    op.create_index(
        "ix_perio_snap_patient_status",
        "periodontogram_snapshots",
        ["patient_id", "status"],
    )
    op.create_index(
        "ix_perio_snap_patient_closed_at",
        "periodontogram_snapshots",
        ["patient_id", sa.text("closed_at DESC")],
        postgresql_where=sa.text("status = 'closed'"),
    )
    op.create_index(
        "uq_perio_snap_one_draft_per_patient",
        "periodontogram_snapshots",
        ["patient_id"],
        unique=True,
        postgresql_where=sa.text("status = 'draft'"),
    )

    op.create_table(
        "periodontogram_teeth",
        # ... ver §4.2 para detalle de columnas/constraints
    )
    op.create_table(
        "periodontogram_sites",
        # ... ver §4.3 para detalle de columnas/constraints
    )


def downgrade() -> None:
    op.drop_table("periodontogram_sites")
    op.drop_table("periodontogram_teeth")
    op.drop_table("periodontogram_snapshots")

6. Backend — módulo y servicio

6.1 __init__.py

python
class PeriodontogramModule(BaseModule):
    manifest = {
        "name": "periodontogram",
        "version": "0.1.0",
        "summary": "Periodontogram SEPA — diagnóstico y seguimiento periodontal.",
        "author": "DentalPin Core Team",
        "license": "BSL-1.1",
        "category": "official",
        "depends": ["patients", "odontogram"],
        "installable": True,
        "auto_install": False,            # política módulos opcionales (memoria)
        "removable": True,
        "role_permissions": {
            "admin":     ["*"],
            "dentist":   ["*"],
            "hygienist": ["read", "write"],
            "assistant": ["read"],
            "receptionist": [],
        },
        "frontend": {"layer_path": "frontend"},
    }

    def get_models(self) -> list:
        return [PeriodontogramSnapshot, PeriodontogramTooth, PeriodontogramSite]

    def get_router(self) -> APIRouter:
        return router

    def get_permissions(self) -> list[str]:
        return ["read", "write"]

    def get_event_handlers(self) -> dict:
        return {
            "odontogram.treatment.performed": on_odontogram_treatment_performed,
            "patient.archived":               on_patient_archived,
        }

6.2 service.py — interfaz pública

python
class PeriodontogramService:
    @staticmethod
    async def list_snapshots(db, clinic_id, patient_id) -> list[Snapshot]: ...

    @staticmethod
    async def get_snapshot(db, clinic_id, snapshot_id) -> Snapshot: ...

    @staticmethod
    async def get_or_create_draft(db, clinic_id, patient_id, user_id) -> Snapshot:
        """
        Idempotente. Si ya existe un draft para el paciente, lo devuelve.
        Si no, crea snapshot draft pre-rellenando is_present / is_implant
        leyendo odontogram.ToothRecord + Treatment (servicio público).
        """

    @staticmethod
    async def update_tooth(db, ctx, snapshot_id, tooth_number, payload) -> Tooth: ...

    @staticmethod
    async def update_site(db, ctx, snapshot_id, tooth_number, site_code, payload) -> Site: ...

    @staticmethod
    async def close_snapshot(db, ctx, snapshot_id) -> Snapshot:
        """
        Calcula índices, los persiste en snapshot.indices, marca status='closed',
        publica evento periodontogram.snapshot.closed.
        """

    @staticmethod
    async def discard_draft(db, ctx, snapshot_id) -> None: ...

    @staticmethod
    async def get_timeline(db, clinic_id, patient_id) -> list[TimelineEntry]:
        """Devuelve snapshots cerrados con fecha + change_count (= sitios poblados)."""

    @staticmethod
    async def compute_indices(db, snapshot_id) -> dict:
        """Re-calcula índices (uso interno + recomputar histórico si fuera necesario)."""

Multi-tenancy: TODOS los queries filtran por clinic_id (regla CLAUDE.md). El parámetro viene del ClinicContext.

6.3 indices.py — fórmulas

python
def compute_bop_pct(sites: list[Site]) -> float:
    """% de sitios con sangrado. Solo sitios con probing_depth_mm NOT NULL cuentan."""
    measured = [s for s in sites if s.probing_depth_mm is not None]
    if not measured: return 0.0
    return 100.0 * sum(1 for s in measured if s.bleeding_on_probing) / len(measured)

def compute_pi_pct(sites: list[Site]) -> float:
    """% de sitios con placa. Mismo denominador que BoP."""
    measured = [s for s in sites if s.probing_depth_mm is not None]
    if not measured: return 0.0
    return 100.0 * sum(1 for s in measured if s.plaque) / len(measured)

def compute_cal_mean_mm(sites: list[Site]) -> float:
    """CAL medio. CAL = probing_depth + gingival_margin. Ignora sitios incompletos."""
    cals = [
        s.probing_depth_mm + s.gingival_margin_mm
        for s in sites
        if s.probing_depth_mm is not None and s.gingival_margin_mm is not None
    ]
    return sum(cals) / len(cals) if cals else 0.0

def count_deep_pockets(sites: list[Site], threshold: int = 5) -> int:
    """Número de DIENTES con al menos un sitio con probing_depth_mm ≥ threshold."""
    teeth_with_deep = {
        s.tooth_number for s in sites
        if s.probing_depth_mm is not None and s.probing_depth_mm >= threshold
    }
    return len(teeth_with_deep)

6.4 events.py

python
async def on_odontogram_treatment_performed(data: dict) -> None:
    """
    Cuando se performa un tratamiento que cambia el estado físico del diente
    (implant, extraction, crown, bridge), si el paciente tiene un draft activo,
    invalidamos el caché frontend disparando un re-fetch. No mutamos el draft
    automáticamente — el clínico decide si refrescar los flags.
    """
    # MVP: solo logging. Caché front se invalida vía polling cada 30s.

async def on_patient_archived(data: dict) -> None:
    """Si se archiva un paciente con drafts, los descartamos automáticamente."""

7. API REST — endpoints

Base path: /api/v1/periodontogram/. Todos exigen get_clinic_context + permiso correspondiente. Todos devuelven ApiResponse[T] o PaginatedApiResponse[T] salvo 204.

MétodoPathPermisoBodyResponseStatus
GET/patients/{patient_id}/snapshotsperiodontogram.readPaginatedApiResponse[SnapshotSummary]200
GET/patients/{patient_id}/timelineperiodontogram.readApiResponse[TimelineResponse]200
GET/patients/{patient_id}/draftperiodontogram.readApiResponse[SnapshotDetail | null]200
POST/patients/{patient_id}/draftperiodontogram.write{ recorded_at? }ApiResponse[SnapshotDetail]201 (or 200 si ya existía)
GET/snapshots/{snapshot_id}periodontogram.readApiResponse[SnapshotDetail]200
PATCH/snapshots/{snapshot_id}/teeth/{tooth_number}periodontogram.writeToothPatchApiResponse[Tooth]200
PATCH/snapshots/{snapshot_id}/teeth/{tooth_number}/sites/{site_code}periodontogram.writeSitePatchApiResponse[Site]200
POST/snapshots/{snapshot_id}/closeperiodontogram.write{ notes? }ApiResponse[SnapshotDetail]200
DELETE/snapshots/{snapshot_id}periodontogram.write204 (solo si status=draft)
GET/snapshots/{snapshot_id}/indicesperiodontogram.readApiResponse[IndicesResponse]200

7.1 Shapes principales (Pydantic)

python
class SiteValue(BaseModel):
    site_code: Literal["MV","V","DV","ML","L","DL"]
    probing_depth_mm: int | None = Field(None, ge=0, le=15)
    gingival_margin_mm: int | None = Field(None, ge=-5, le=10)
    bleeding_on_probing: bool = False
    plaque: bool = False
    suppuration: bool = False

class ToothValue(BaseModel):
    tooth_number: int = Field(..., ge=11, le=48)
    is_present: bool = True
    is_implant: bool = False
    mobility: int | None = Field(None, ge=0, le=3)
    prognosis: Literal["good","fair","poor","hopeless"] | None = None
    furcation_buccal:  Literal["0","I","II","III"] | None = None
    furcation_lingual: Literal["0","I","II","III"] | None = None
    keratinized_gingiva_mm: int | None = Field(None, ge=0, le=20)
    sites: list[SiteValue] = Field(default_factory=list)

class SnapshotDetail(BaseModel):
    id: UUID
    patient_id: UUID
    status: Literal["draft","closed"]
    recorded_at: datetime
    recorded_by: UUID
    closed_at: datetime | None
    closed_by: UUID | None
    notes: str | None
    indices: IndicesResponse | None
    teeth: list[ToothValue]

class IndicesResponse(BaseModel):
    bop_pct: float
    pi_pct: float
    cal_mean_mm: float
    deep_pockets_count: int     # nº de dientes con bolsas ≥5mm

class TimelineEntry(BaseModel):
    snapshot_id: UUID
    date: date                  # alineado con TimelineSlider (YYYY-MM-DD)
    change_count: int           # nº de sitios con datos (proxy de"actividad")

class TimelineResponse(BaseModel):
    dates: list[TimelineEntry]
    draft: SnapshotSummary | None    # draft activo si existe

7.2 Reglas de validación cruzada

  • PATCH /sites/... falla con 409 si snapshot.status='closed'.
  • PATCH /teeth/... falla con 409 si snapshot.status='closed'.
  • POST /draft es idempotente: si hay draft activo, retorna el existente con 200.
  • POST /close falla con 422 si no hay ningún sitio con probing_depth_mm (avisa al cliente; el frontend valida soft antes).
  • DELETE /snapshots/{id} solo permite borrar drafts.

8. Frontend — componentes Vue

8.1 Slot point nuevo en patients

Exponer en ClinicalTab.vue (módulo patients) un slot patient.diagnosis.subtabs con contrato:

ts
type DiagnosisSubtabCtx = { patientId: string; readonly?: boolean }

Refactor mínimo en ClinicalTab.vue:115-156:

vue
<DiagnosisModeContainer
  v-else-if="currentMode === 'diagnosis'"
  :patient-id="patientId"
  :readonly="readonly"
  @create-plan="handleCreatePlan"
  @continue-plan="handleContinuePlan"
/>

Nuevo componente DiagnosisModeContainer.vue en patients/frontend/components/patient/:

vue
<script setup lang="ts">
const { resolve } = useModuleSlots()
const props = defineProps<{ patientId: string; readonly?: boolean }>()
const emit = defineEmits<{ ... }>()

const subtabs = computed(() =>
  resolve<DiagnosisSubtabCtx>('patient.diagnosis.subtabs', {
    patientId: props.patientId,
    readonly: props.readonly,
  })
)

const activeKey = ref<'odontogram' | string>('odontogram')

// Sync con URL ?diagnosisView=
const route = useRoute()
onMounted(() => {
  const v = route.query.diagnosisView as string
  if (v && (v === 'odontogram' || subtabs.value.some(s => s.id === v))) {
    activeKey.value = v
  }
})
watch(activeKey, (v) => {
  useRouter().replace({ query: { ...route.query, diagnosisView: v } })
})
</script>

<template>
  <!-- Sin sub-tabs si no hay módulos opcionales: render directo -->
  <DiagnosisMode
    v-if="subtabs.length === 0"
    :patient-id="patientId"
    :readonly="readonly"
    @create-plan="..."
    @continue-plan="..."
  />
  <!-- Con sub-tabs: bar + slot activo -->
  <div v-else class="space-y-3">
    <UTabs
      v-model="activeKey"
      :items="[{ slot: 'odontogram', label: t('clinical.diagnosis.odontogram') },
               ...subtabs.map(s => ({ slot: s.id, label: t(s.labelKey ?? s.id) }))]"
    >
      <template #odontogram>
        <DiagnosisMode
          :patient-id="patientId"
          :readonly="readonly"
          @create-plan="..."
          @continue-plan="..."
        />
      </template>
      <template
        v-for="entry in subtabs"
        :key="entry.id"
        #[entry.id]
      >
        <component
          :is="entry.component"
          :patient-id="patientId"
          :readonly="readonly"
        />
      </template>
    </UTabs>
  </div>
</template>

Importante: si el módulo periodontogram no está instalado, el slot está vacío y la UI es exactamente la actual — sin tab bar, sin churn visual. Esto preserva la promesa de uninstall limpio.

8.2 Registro del slot en periodontogram

ts
// backend/app/modules/periodontogram/frontend/plugins/slots.client.ts
import { defineAsyncComponent } from 'vue'
import { registerSlot } from '~~/app/composables/useModuleSlots'

export default defineNuxtPlugin(() => {
  registerSlot('patient.diagnosis.subtabs', {
    id: 'periodontogram',
    component: defineAsyncComponent(
      () => import('../components/PeriodontogramView.vue')
    ),
    order: 20,
    permission: 'periodontogram.read',
    labelKey: 'periodontogram.tab.label',
  })
})

8.3 Componentes principales

ComponenteResponsabilidadProps principales
PeriodontogramView.vueEntry-point del slot. Decide entre EmptyState / Chart. Carga timeline.patientId, readonly?
PeriodontogramChart.vueOrquestador. Render banner + slider + 2 arch blocks + acciones.patientId, snapshot (current view), isDraft, readonly?
PerioArchBlock.vueBloque superior o inferior: tabla métricas + 2 filas de dientes.archSide: 'upper' | 'lower', teeth: ToothValue[], sites: SiteValue[], readonly
PerioMetricsTable.vue9 filas SEPA. Cada celda input numérico inline o popover trigger.teeth, sites, archSide, readonly, @update
PerioToothRow.vue1 fila de dientes con flip CSS según face.teeth, face: 'vestibular' | 'palatal' | 'lingual', archSide
PerioToothLateral.vue1 diente lateral SVG + 3 marcadores de sitio.tooth, sites: SiteValue[3], face, quadrant
PerioSiteMarker.vueCírculo coloreado según heatmap.pd?, bop, plaque
PerioSiteInputPopover.vuePopover edición sitio: PD/GM/BoP/PI/Sup.tooth_number, site_code, current: SiteValue, @save
PerioIndicesBanner.vueBanner BoP/PI/CAL/bolsas.indices: IndicesResponse, meta: { date, by, status }
PerioSessionActions.vueBotonera sticky. Discard / Close.snapshot, @close, @discard
PerioHistoryBanner.vueBanner amarillo "Viendo histórico".date, @returnToCurrent
PerioEmptyState.vueCTA "Iniciar primera exploración".@start

8.4 Composables

ts
// usePeriodontogram.ts
export function usePeriodontogram(patientId: Ref<string>) {
  const api = useApi()
  const timeline = ref<TimelineEntry[]>([])
  const draft    = ref<SnapshotSummary | null>(null)
  const current  = ref<SnapshotDetail | null>(null)
  const viewingDate = ref<string | null>(null)  // null = vivo (draft o último closed)

  async function fetchTimeline() { ... }
  async function fetchSnapshot(snapshotId: string) { ... }
  async function loadDraftOrLatest() { ... }
  function setViewingDate(date: string | null) { ... }

  return { timeline, draft, current, viewingDate, fetchTimeline, fetchSnapshot, loadDraftOrLatest, setViewingDate }
}

// usePeriodontogramSession.ts — gestión draft + autosave
export function usePeriodontogramSession(snapshotId: Ref<string | null>) {
  const api = useApi()
  const dirty = ref(false)
  const saving = ref(false)

  const enqueueToothPatch    = useDebounceFn(async (toothNumber: number, patch) => { ... }, 600)
  const enqueueSitePatch     = useDebounceFn(async (toothNumber: number, siteCode: string, patch) => { ... }, 600)
  async function closeSession(notes?: string) { ... }
  async function discardDraft() { ... }

  return { dirty, saving, enqueueToothPatch, enqueueSitePatch, closeSession, discardDraft }
}

// usePerioHeatmap.ts — mapping color
export function pdColor(pd: number | null | undefined): string {
  if (pd == null) return 'neutral-300'
  if (pd <= 3)  return 'success-500'
  if (pd === 4) return 'warning-400'
  if (pd <= 6)  return 'warning-600'
  return 'error-500'
}

8.5 Permisos en frontend/app/config/permissions.ts

Añadir:

ts
export const PERMISSIONS = {
  // ... existentes
  periodontogram: {
    read:  'periodontogram.read',
    write: 'periodontogram.write',
  },
} as const

9. Acoplamiento con odontogram — sin FK

9.1 Pre-relleno al abrir draft

PeriodontogramService.get_or_create_draft:

python
# 1) Lee odontogram tooth_records del paciente vía servicio público.
from app.modules.odontogram.service import OdontogramService

odo = await OdontogramService.get_patient_odontogram(db, clinic_id, patient_id)

# 2) Para cada diente FDI permanente (11..48):
for tooth_number in PERMANENT_TEETH_FDI:
    tooth_record = odo.teeth.get(tooth_number)
    is_present = not (tooth_record and tooth_record.general_condition == 'missing')
    is_implant = bool(tooth_record and any(
        t.clinical_type == 'implant' and t.status == 'performed'
        for t in tooth_record.treatments
    ))
    db.add(PeriodontogramTooth(
        snapshot_id=snapshot.id,
        tooth_number=tooth_number,
        is_present=is_present,
        is_implant=is_implant,
    ))

9.2 Import permitido por depends

periodontogram.depends = ["patients","odontogram"] autoriza el from app.modules.odontogram.service import OdontogramService. CI (manifest_validator) lo respeta.

9.3 Eventos

Suscripción a odontogram.treatment.performed: en MVP solo logging. El cliente refresca el draft cada vez que se monta (no pulling).


10. Tests

10.1 Round-trip uninstall (obligatorio)

backend/tests/modules/periodontogram/test_uninstall_roundtrip.py — copia del patrón en test_uninstall_roundtrip.py adaptando:

python
PERIODONTOGRAM_TABLES = {
    "periodontogram_snapshots",
    "periodontogram_teeth",
    "periodontogram_sites",
}

def test_periodontogram_uninstall_roundtrip_is_branch_scoped() -> None:
    _alembic("upgrade", "heads")
    before = _list_tables()
    assert PERIODONTOGRAM_TABLES.issubset(before)
    baseline = before - PERIODONTOGRAM_TABLES

    _alembic("downgrade", "periodontogram@-1")
    after_down = _list_tables()
    assert PERIODONTOGRAM_TABLES.isdisjoint(after_down)
    assert baseline <= after_down

    _alembic("upgrade", "periodontogram@head")
    after_up = _list_tables()
    assert before <= after_up

10.2 Snapshot lifecycle

  • Crear draft idempotente (2 calls → 1 draft).
  • Solo 1 draft por paciente (constraint DB).
  • PATCH a tooth/site en closed devuelve 409.
  • Cerrar calcula índices y persiste en indices.
  • DELETE sobre closed devuelve 405/422.
  • Cerrar publica evento periodontogram.snapshot.closed.

10.3 Cálculo de índices

Casos canónicos con datos sintéticos:

  • 28 dientes presentes × 6 sitios = 168 sitios. 42 sitios con BoP → BoP %=25%.
  • CAL medio con valores conocidos.
  • deep_pockets_count: 5 dientes con al menos un sitio ≥5mm.

10.4 Acoplamiento odontograma

  • Paciente con diente 46 missing → draft nuevo trae is_present=false en 46.
  • Paciente con implante performed en 14 → is_implant=true en 14.
  • Odontograma no instalado / sin datos: is_present=true, is_implant=false por defecto.

10.5 Permisos

  • Hygienist puede crear+cerrar.
  • Assistant solo GET.
  • Receptionist 403 incluso en GET.

10.6 Validación API

  • probing_depth_mm > 15 → 422.
  • tooth_number = 51 (deciduo) → 422.
  • site_code = 'XY' → 422.
  • PATCH a snapshot de OTRA clínica → 404 (multi-tenancy).

10.7 Fixture / DB de tests aislada

Atención (memoria feedback_pytest_drops_db.md): los tests deben correr contra DB de tests, no dental_clinic. Reusar fixtures existentes db_session, client, auth_headers de conftest.py.


11. Trabajo paralelo / orden de PRs

Sugerencia de PRs secuenciales (cada uno verde antes del siguiente):

  1. PR-1 backend skeleton: módulo + manifest + migración + modelos + schemas + tests round-trip uninstall.
  2. PR-2 backend service + endpoints: get_or_create_draft, update_tooth, update_site, close, discard, list_snapshots, timeline. Tests lifecycle + API validation.
  3. PR-3 backend índices + acoplamiento: compute_indices, pre-relleno desde odontogram, evento periodontogram.snapshot.closed. Tests cálculo + acoplamiento.
  4. PR-4 frontend slot + EmptyState + skeleton: refactor ClinicalTabDiagnosisModeContainer, registro del slot, PerioEmptyState. No render de exploración aún.
  5. PR-5 frontend chart desktop: layout 4 filas SEPA, PerioMetricsTable, PerioToothLateral, heatmap, popover sitio, banner índices.
  6. PR-6 frontend sesión + slider: autosave, close/discard, TimelineSlider integrado, historyBanner.
  7. PR-7 mobile + i18n + docs: layout por cuadrante <1024px, locales es/en, user-manual MD, screenshots, ADR 0014.
  8. PR-8 polish: estados loading/error, accesibilidad teclado, smoke E2E en CI.

Cada PR debe pasar:

  • docker-compose exec backend python -m pytest -v
  • cd backend && ruff check . && ruff format --check .
  • cd frontend && npm run lint && npm run typecheck
  • python backend/scripts/generate_catalogs.py (sin diff).

12. Telemetría y observabilidad

MétricaCómo
Sessions creadas / cerradas / descartadasLogs estructurados en service.py (level=info).
Duración media de sesiónclosed_at - recorded_at (consulta ad-hoc).
Drafts huérfanos > 30 díasJob de housekeeping (fase 2).
Errores de validación APILog warning con clinic_id + patient_id.

No instrumentamos métricas Prometheus en MVP — DentalPin aún no tiene infra. Documentar en events.md como TODO.


13. Decisiones específicas con racional

13.1 Snapshots inmutables vs event sourcing

Decisión: snapshots inmutables (cada sesión = una unidad atómica).

Por qué no event sourcing (como hace odontogram con OdontogramHistory):

  • Clínicamente una exploración periodontal es un acto en bloque — se interpreta como una foto en un día concreto, no como un stream de cambios.
  • Comparar evolución requiere comparar fotos completas (2 snapshots). Reconstruir un estado intermedio entre exploraciones no tiene sentido clínico.
  • Simplifica queries: índices se calculan en el cierre y se guardan en JSONB para evitar recomputar.
  • Simplifica UX del slider: cada nodo es un snapshot real, no una reconstrucción.

Trade-off aceptado: imposible "editar" un snapshot después de cerrado. Para corregir, abrir nueva sesión. Documentado en ADR 0014.

13.2 Sin notas polimórficas en MVP

Decisión: campo notes TEXT en periodontogram_snapshots.

Por qué no clinical_notes polimórficas:

  • clinical_notes es removable=False (core). Si periodontogram (removable) añade un owner_type al CHECK constraint, contamina un módulo core con conocimiento de uno opcional.
  • Si se desinstala periodontogram, quedarían rows huérfanas con owner_type='periodontogram_snapshot' y owner inexistente — viola la invariante de uninstall limpio.
  • Las notas SEPA suelen ser una observación global por sesión, no múltiples comentarios datados → un Text basta.

Si en fase 2 se necesitan notas múltiples atadas a un snapshot/diente: re-evaluar y considerar:

  1. Tabla periodontogram_notes propia (queda aislada en la rama Alembic del módulo).
  2. Promover clinical_notes a aceptar owner_types declarados por módulos opcionales vía registry (cambio mayor en clinical_notes).

13.3 6 sitios × FDI permanente solo (no deciduos)

Decisión: solo dentición permanente.

Por qué: SEPA y la práctica clínica periodontal real se hacen sobre permanentes. Pacientes con dentición temporal/mixta no son target de un periodontograma SEPA. Si llega esa necesidad, ampliable sin migración (campos ya soportan FDI extendido si relajamos el CHECK).

13.4 auto_install=False

Decisión: módulo opcional, activación manual desde admin.

Por qué: memoria feedback_module_install.md — política de módulos no-core. Coherente con verifactu (también opcional).

13.5 Edición libre por celda (no auto-avance)

Decisión: click → popover → guardar. Sin foco automático al siguiente sitio.

Por qué: confirmado por el usuario en la fase de diseño. Auto-avance queda para fase 2 (junto con captura por voz).

13.6 Heatmap con tokens Nuxt UI existentes

Decisión: reutilizar tokens success-500 / warning-400 / warning-600 / error-500.

Por qué: coherencia visual con el resto de la app, no requiere extender el design system.


14. Riesgos identificados

RiesgoSeveridadMitigación
Flip CSS vertical sobre lateral SVG genera vista palatina anatómicamente imprecisaBajaAceptado en MVP (clínicos del piloto lo verán y darán feedback). ADR documenta. Fase 2 puede añadir paths palatinos reales en ToothSVGPaths.ts.
Densidad de datos en mobile (96 sitios visibles)MediaLayout por cuadrante <1024px (swipe). Validar con prototipo antes de PR-7.
Performance al renderizar 32 dientes × 6 marcadoresBajaSVG inline cacheado por ToothSVGPaths. Marcadores como divs absolutamente posicionados. Mínimo de re-renders vía markRaw y memo en useMemo.
Conflicto con futuro odontograma "diagnosis" sub-tabs (issue futuro de re-org)BajaEl slot patient.diagnosis.subtabs ya está pensado como pluggable — odontogram puede registrarse como sub-tab adicional en el futuro sin breaking change.
Driftearse del estándar SEPA por simplificaciónMediaCubrir todas las 9 métricas desde MVP. Antes de hacer público a piloto, validar layout con dentista.
Tests dropean DB realAltaMemoria documentada. Tests SIEMPRE contra DB de tests (TESTING=true). Conftest existente ya cubre, reusarlo.
Eventos del odontograma se publican en momentos no críticos y refrescan UI con lagBajaMVP: refresh manual al re-montar componente. Fase 2: WebSocket / SSE si hace falta.

15. ADR a crear

Crear docs/adr/0014-periodontogram-snapshot-model.md antes o durante PR-1:

Título: Periodontograma — snapshots inmutables fechados (no event sourcing). Contexto: una exploración periodontal SEPA es un acto en bloque clínicamente interpretado como una foto. Decisión: persistencia en 3 tablas relacionales (snapshot 1:N teeth 1:N sites) con estado draft|closed, único draft activo por paciente. Consecuencias: sin reconstrucción de estados intermedios; índices precomputados; UX del slider más directa; correcciones requieren nueva sesión.


16. Documentación obligatoria asociada (CLAUDE.md)

Cuando se mergee el módulo, se debe crear/actualizar:

  • backend/app/modules/periodontogram/CLAUDE.md (template docs/checklists/module-claude-template.md).
  • backend/app/modules/periodontogram/CHANGELOG.md (entry ## Unreleased## 0.1.0).
  • docs/technical/periodontogram/overview.md, events.md, permissions.md.
  • docs/modules/periodontogram.md.
  • docs/user-manual/{en,es}/periodontogram/index.md + screens/periodontograma-view.md.
  • docs/screenshots/periodontogram/*.png.
  • docs/modules-catalog.md, docs/events-catalog.md: auto-regenerar con python backend/scripts/generate_catalogs.py.
  • Actualizar backend/app/modules/patients/CLAUDE.md añadiendo el nuevo slot patient.diagnosis.subtabs al listado de slots estables.

17. Verificación end-to-end final

Comandos de validación al cerrar la implementación:

bash
# Backend
docker-compose exec backend python -m pytest backend/tests/modules/periodontogram/ -v
docker-compose exec backend alembic upgrade heads
docker-compose exec backend alembic downgrade periodontogram@-1   # uninstall scoped
docker-compose exec backend alembic upgrade periodontogram@head   # reinstall

# Lint + format
cd backend && ruff check . && ruff format --check .
cd frontend && npm run lint && npm run typecheck

# Catálogos
python backend/scripts/generate_catalogs.py  # debe quedar sin diff

# Smoke manual
# 1. Login demo
# 2. Activar módulo periodontogram desde admin
# 3. Ir a paciente → Clínica → Diagnóstico → tab Periodontograma
# 4. Iniciar sesión, rellenar 5 sitios, recargar página → datos persisten
# 5. Cerrar sesión → aparece en slider
# 6. Marcar diente missing en odontograma → nueva sesión perio lo refleja
# 7. Desinstalar módulo desde admin → tab desaparece, odontograma intacto

18. Próximo paso operativo

  1. Crear issue principal en GitHub: "feat(periodontogram): nuevo módulo SEPA opcional".
  2. Crear branch feat/periodontogram-module.
  3. Comenzar por PR-1 (backend skeleton). Mantener round-trip uninstall test verde desde la primera línea de código.