# 03 — Modelo de datos > `[INV-2026]` · PostgreSQL · multi-tenant · Fase 1 completa. > Convenciones y reglas de auditoría en [02 — Estándar de datos](02-estandar-de-datos.md). > DDL ejecutable en `db/ddl/`. ## 1. Mapa de la organización `cadena` no cuelga de `pais` (Marathon opera en cuatro países) y `pais` no cuelga de `cadena`. Se cruzan en `sucursal`, que es el punto donde una enseña toca un país. ``` core.tenant Grupo Innovasport ├── core.pais MX, EC, PE, CL, BO → moneda, huso, idioma ├── core.cadena Innovasport, Innvictus, Over Time, Rookie Kids, Marathon ├── core.region plaza / zona — jerarquía de reporte del analista └── core.sucursal ────────────► cadena + pais + region + canal ├── core.storefront dominio e-commerce o club shop → sucursal de fulfillment └── core.suscripcion_sucursal facturación por plaza ``` **Tenant = Grupo, no Tenant = Cadena.** El material se traspasa entre sucursales y el rol de analista reporta a través de las enseñas; con cadena-como-tenant todo reporte sería una consulta cross-tenant y la frontera de aislamiento no serviría para nada. La frontera de tenant queda reservada para lo que sí es otro cliente: venderle el sistema a otro grupo retail. ## 2. Inventario de tablas ### 2.1 `core` — tenancy, organización y seguridad | Tabla | Propósito | Notas de diseño | |---|---|---| | `tenant` | Grupo empresarial cliente | Raíz del aislamiento; RLS filtra por `tenant_id` | | `pais` | País de operación | `moneda_iso`, `zona_horaria`, `idioma`. Habilita MX + los 4 de Marathon | | `cadena` | Enseña comercial | Innovasport, Innvictus, Over Time, Rookie Kids, Marathon | | `region` | Plaza o zona | Auto-referencia (`region_padre_id`) para jerarquía de reporte | | `canal` | Canal de venta | `FISICA`, `ECOMMERCE`, `CLUB_SHOP` | | `sucursal` | Punto de venta o producción | `realiza_personalizacion`, `minutos_estandar_pieza`, `colaboradores_turno` | | `storefront` | Dominio digital | Incluye club shops white-label; apunta a la sucursal que surte | | `usuario` | Usuario del sistema | **Fila sembrada `usuario_id = 0` = SISTEMA**, destino de los FK de auditoría | | `rol` / `usuario_rol` | Perfiles | `CLIENTE`, `COLABORADOR`, `ANALISTA`, `ADMIN_TENANT`, `SISTEMA` | | `usuario_sucursal` | Adscripción del colaborador | Un analista puede abarcar muchas; un colaborador normalmente una | | `plan_suscripcion` | Planes comerciales | Escalonados por número de sucursales | | `suscripcion_sucursal` | Suscripción vigente por plaza | Fechada. Permite facturar el piloto de 10 y escalar sin tocar el esquema | | `auditoria_cambio` | Bitácora de cambios | Poblada por trigger; `JSONB` antes/después. **Append-only** | ### 2.2 `catalogo` — maestros de producto, licencia y material | Tabla | Propósito | Regla que soporta | |---|---|---| | `licencia` | Titular de licencia | Lleva `pais_id` + vigencia. `restringe_catalogo` implementa **RN1** | | `temporada` | Temporada deportiva | Delimita qué abecedario y jerseys aplican | | `tipo_personalizacion` | **AUTENTICO / OFICIAL / GENERICO** | Catálogo, no enum — el spec solo contemplaba dos | | `jugador` | Nombre y dorsal por licencia y temporada | Disponibilidad del tipo *Auténtico* | | `modelo_jersey` | Jersey personalizable | SKU, imagen base para previsualización | | `plantilla_personalizacion` | Geometría del estampado | `RECTO_ARRIBA`, `CURVO_ARRIBA`, `RECTO_ABAJO`; límites de caracteres, alto, curvatura | | `fuente_tipografica` | Tipografía oficial o genérica | Insumo del render de previsualización | | `caracter` | Glifo imprimible | Soporta acentos, `Ñ` y signos de interrogación | | `color` | Color de material | El cliente elige por ícono de color | | `proveedor` | Proveedor de material | Distingue el precortado externo del rollo | | `unidad_medida` | `METRO`, `PIEZA`, `PAQUETE` | Las dos lógicas de inventario conviven | | `tipo_material` | `VINIL_ROLLO`, `CARACTER_OFICIAL`, `KIT_AUTENTICO` | `requiere_lote`, `se_consume_por_caracter` | | `material` | Maestro de material | `codigo` es el identificador que pide el cliente: réplica vs oficial vs franquicia y temporada | | `material_caracter` | Qué caracteres entrega una pieza precortada | Un kit "30 Raúl Jiménez" son varias filas | | `abecedario_oficial` | Caracteres seleccionables bajo licencia restringida | **RN1** — Club América solo permite el abecedario oficial | | `factor_consumo_caracter` | Cuánto consume cada carácter | Una `A` consume más que una `I`. Fila con `caracter_id NULL` = promedio por defecto | | `configuracion_rendimiento` | Rendimiento teórico vs estándar | **8 teóricas / 6 estándar por metro.** Fechada, para auditar cuándo cambió | | `servicio_personalizacion` | Código de servicio del POS | El "8150" que hoy teclea el colaborador; precio por tipo de estampado | ### 2.3 `inventario` — el núcleo auditable | Tabla | Propósito | Notas de diseño | |---|---|---| | `ubicacion` | Ubicación dentro de la sucursal | `RECEPCION`, `IMPRESION`, `BODEGA`. Resuelve "llegó y se quedó en recibo" | | `estatus_lote` | `ABIERTO`, `CERRADO`, `MERMADO`, `CADUCADO`, `EN_TRANSITO` | — | | `lote_material` | Rollo o paquete individual | `cantidad_inicial`, `cantidad_restante`, `fecha_recepcion`, `fecha_ultimo_consumo` | | `existencia_material` | Saldo por sucursal / material / ubicación | Derivado del kardex. `version` para bloqueo optimista | | `tipo_movimiento` | Tipos de movimiento | `signo`, `afecta_disponible`, `afecta_reservado` | | `movimiento_inventario` | **Kardex — fuente de verdad** | `saldo_anterior` / `saldo_posterior`. **Append-only**, con `movimiento_reversa_id` | | `punto_reorden` | Mínimos y máximos | Dispara alerta antes de llegar a cero | | `alerta_inventario` | Alertas operativas | `STOCK_MINIMO`, `SIN_ROTACION`, `CADUCADO`, `DIFERENCIA_CONTEO` | | `traspaso` / `traspaso_detalle` | Movimiento entre sucursales | Cantidades solicitada / enviada / recibida por separado | | `conteo` / `conteo_detalle` | Inventario físico | Reemplaza el Forms + Excel mensual. Diferencia calculada y ajuste ligado al kardex | **Tipos de movimiento:** `ENTRADA_RECEPCION`, `SALIDA_CONSUMO`, `MERMA`, `AJUSTE_CONTEO`, `TRASPASO_SALIDA`, `TRASPASO_ENTRADA`, `BAJA_CADUCIDAD`, `RESERVA`, `LIBERACION_RESERVA`, `REVERSA`. ### 2.4 `orden` — captura, cola y trazabilidad | Tabla | Propósito | Notas de diseño | |---|---|---| | `estatus_orden` | Ciclo de vida | `BORRADOR` → `PENDIENTE_CLIENTE` → `CONFIRMADA` → `EN_COLA` → `EN_PROCESO` → `IMPRESA` → `TERMINADA` → `ENTREGADA` / `CANCELADA` | | `prioridad` | `NORMAL`, `URGENTE`, `PROGRAMADA` | `peso` ordena la cola | | `origen_orden` | `TIENDA_QR`, `TIENDA_COLABORADOR`, `ECOMMERCE`, `CLUB_SHOP` | — | | `orden_servicio` | Raíz del agregado | Separa `sucursal_venta_id` de `sucursal_produccion_id` | | `token_captura` | El QR del ticket | Se guarda **hash**, no el token; con expiración, contador de intentos e IP | | `orden_item` | Un jersey personalizado | Plantilla, tipo, jugador, texto, número, color, talla | | `orden_item_caracter` | Trazabilidad carácter por carácter | Material y lote resueltos, factor aplicado, consumo y el `movimiento_id` que lo descontó | | `aceptacion_cliente` | **Evidencia inmutable** | `snapshot_json` + `hash_snapshot` + previsualización + IP + sello de tiempo | | `orden_estatus_historico` | Transiciones | **Append-only**; `minutos_en_estatus_anterior` alimenta la estimación | | `asignacion_trabajo` | Quién trabajó qué y cuánto tardó | `minutos_reales` calibra el estimado con datos reales, no con los 15 min teóricos | | `tipo_incidencia` / `incidencia` | Errores y su responsable | `CLIENTE` / `COLABORADOR` / `SISTEMA` / `PROVEEDOR` + costo de reposición | ### 2.5 `integracion` — salida hacia SAP | Tabla | Propósito | |---|---| | `tipo_exportacion` | `SAP_MM_CONSUMO`, `SAP_MM_EXISTENCIAS`, `CONSUMO_ANALITICO` | | `exportacion` | Bitácora de archivos generados: periodo, ruta, `hash_archivo`, registros, quién y cuándo | El hash del archivo existe para poder demostrar que el Excel que se cargó a SAP es exactamente el que el sistema generó. ## 3. Flujos críticos sobre el modelo ### 3.1 Confirmación de orden y descuento (CU-001 + CU-002) Un solo `correlation_id` hilvana toda la operación: 1. El cliente escanea el QR → se valida `token_captura` por hash, vigencia e intentos. 2. Captura nombre y número → se normaliza a mayúsculas conservando puntuación. 3. Se explota el texto en `orden_item_caracter`, un renglón por carácter. 4. Para cada carácter se resuelve el material: - **GENERICO** → vinil por `licencia` + `color`; consumo = `factor_consumo_caracter` ÷ `personalizaciones_estandar` (6, no 8). - **OFICIAL** → pieza de `abecedario_oficial`; si la licencia es restringida (**RN1**), solo estos caracteres son seleccionables. - **AUTENTICO** → kit ligado a `jugador`; disponibilidad por jugador, no por carácter. 5. Se verifica `existencia_material.cantidad_disponible` por sucursal. Si falta algo → **FA1**: se alerta y se bloquea la confirmación. 6. Al confirmar se escribe `aceptacion_cliente` con el snapshot hasheado y se generan movimientos `RESERVA`. 7. Al imprimir, la reserva se convierte en `SALIDA_CONSUMO` + `MERMA`, se descuenta `lote_material.cantidad_restante` y se actualiza el saldo con bloqueo optimista. Cada `orden_item_caracter` queda apuntando a su `movimiento_id`: se puede responder "¿de qué rollo salió la `J` de esta orden?". ### 3.2 Estimación de tiempo de entrega Órdenes en `EN_COLA` + `EN_PROCESO` × `minutos_estandar_pieza` ÷ `colaboradores_turno`, ajustando el estándar con el promedio real de `asignacion_trabajo.minutos_reales` de esa sucursal. Los 15 minutos por pieza son la semilla; a partir del primer mes el sistema usa el dato observado. ### 3.3 Detección de material sin rotación Un `lote_material` con `fecha_ultimo_consumo` anterior a hoy menos `material.dias_sin_rotacion_caducidad` genera una `alerta_inventario` de tipo `SIN_ROTACION`. La regla es del **lote**, no del material: un rollo recién recibido no hereda la antigüedad del que ya estaba en el anaquel. ### 3.4 Reconciliación de existencias `existencia_material` siempre debe igualar la suma firmada del kardex: ```sql SELECT m.material_id, SUM(m.cantidad * tm.signo) AS saldo_calculado FROM inventario.movimiento_inventario m JOIN inventario.tipo_movimiento tm USING (tipo_movimiento_id) WHERE m.tenant_id = :tenant AND m.sucursal_id = :sucursal GROUP BY m.material_id; ``` Cualquier diferencia contra `existencia_material` es un defecto del sistema, no un descuadre de negocio. Conviene correrlo como verificación programada. ## 4. Volumetría y dimensionamiento Estimación con el grupo completo (~600 PDV) en temporada alta: | Tabla | Filas/año (orden de magnitud) | Tipo de PK | |---|---|---| | `orden_servicio` | 1–3 M | `bigint` | | `orden_item_caracter` | 15–40 M | `bigint` | | `movimiento_inventario` | 10–30 M | `bigint` | | `auditoria_cambio` | 20–50 M | `bigint` | | `existencia_material` | ~500 K | `bigint` | | `material` | decenas de miles | `int` | | `sucursal` | ~600 | `int` | | Catálogos | cientos | `smallint` | Las cuatro primeras son candidatas naturales a **particionado por rango de fecha** cuando el volumen lo justifique. El esquema ya lo permite porque son append-only y siempre llevan la fecha de registro. ## 5. Lo que queda explícitamente fuera de Fase 1 - Integración directa con SAP. Se entrega exportación Excel/CSV con bitácora y hash. - Cálculo de costeo y valuación de inventario (PEPS/promedio). `material.costo_unitario` existe para reportar consumo valorizado, no para contabilidad formal. - Motor de render de la previsualización. El modelo guarda la **geometría** (`plantilla_personalizacion`) y la **evidencia** (`aceptacion_cliente.preview_url`); producir la imagen es responsabilidad de la capa de aplicación. - Programación de turnos. `colaboradores_turno` es un parámetro por sucursal, no un calendario.