PROYECTO_01

Skilled Proyectos Industriales

Skilled ERP

ERP de nóminas, empleados, inventario y herramientas para una empresa de ingeniería eléctrica.

TIPO
Full Stack · Seguridad
ESTADO
EN PRODUCCIÓN
DIAGRAMAS
4
CAPTURAS
12
  • React 18
  • Vite
  • TailwindCSS
  • Python 3.12
  • Flask 3
  • SQLAlchemy 2
  • PostgreSQL
  • Redis
  • Socket.IO
  • Docker
  • Nginx
  • Cloudflare R2

01 Qué es

API JSON en Flask, sin HTML, que sirve al SPA de Skilled ERP (React + Vite, hosteado en Vercel). Este repo expone /api/* y el canal de Socket.IO; el frontend vive en su propio repo. Cubre nóminas, empleados, proyectos, horas trabajadas, préstamos, inventario de materiales y herramientas, con notificaciones y bitácora en tiempo real.

  • Empleados: alta, baja y edición; campos laborales, personales, médicos y financieros con whitelist por rol. Notas internas tipo «chatter» en la ficha.
  • Nómina: prenómina semanal, descuentos, depósitos extra, préstamos con abonos y el ajuste Inbursa por periodo.
  • Horas: reportes semanales, registros diarios, ausencias, saldo de vacaciones y checada por RFID o QR.
  • Proyectos M:N: un trabajador puede estar en varios proyectos; expediente y credenciales se derivan solo de los proyectos activos.
  • Inventario: productos, almacenes y estantes con QR; movimientos con bloqueo anti-concurrencia; solicitudes con flujo PENDIENTE → APROBADA / RECHAZADA / ENTREGADA y entrega parcial; tomas físicas con ajuste automático; etiquetas Avery y órdenes de compra.
  • Herramientas: catálogo y unidades físicas rastreables por serie o QR; asignaciones a trabajadores, mantenimientos, incidencias y baja con autorización.
  • Panel de sistemas: estado de la infraestructura, tráfico y percentiles, sesiones activas, bloqueos de cuenta, eventos de seguridad y archivos huérfanos en R2.
  • Reportes: PDF (recibos, constancias, solicitudes, tomas, OC) y Excel (totales por proyecto, histórico), saneados contra inyección de fórmulas. Carga masiva de empleados y productos desde .xlsx.

02 Arquitectura

Todo entra por Cloudflare, baja por el túnel, pasa por Nginx y termina en Gunicorn. El servidor de origen no publica ningún puerto hacia afuera: el túnel es una conexión saliente y el firewall descarta cualquier intento de conexión entrante directa. En local ese mismo camino se levanta con docker compose up.

PiezaPara qué
Flask 3 + SQLAlchemy 2API con 18 blueprints y 216 endpoints; PyJWT, Flask-Limiter y Flask-Talisman
PostgreSQL (psycopg v3)Base de datos; migraciones con Alembic
RedisRate-limit, lockout escalado, anti-replay de TOTP y message_queue de Socket.IO. Obligatorio: create_app() aborta si no conecta
Socket.IOTiempo real: gevent en producción (WebSocket real), threading en desarrollo
Cloudflare R2 + ClamAVBucket público para el catálogo y privado para documentos y fotos, con disco como respaldo; todo lo que se sube se escanea
pandas + openpyxl · xhtml2pdfExcel y PDF sobre plantillas Jinja
Gunicorn → Nginx → Cloudflare Tunnel4 workers gevent × 1000 conexiones; túnel con protocol: http2, requerido para WebSockets estables

Los 4 workers de gevent son procesos distintos, así que todo lo que debe ser común (rate-limit, lockout, anti-replay de 2FA, eventos de Socket.IO) vive en Redis. Los archivos no se guardan tal cual: una imagen externa se valida contra SSRF, se comprueban sus magic bytes, se reescribe con Pillow a WebP y se sube a R2 con el SHA-256 de su contenido como llave. En inventario, dos movimientos sobre el mismo stock no se pisan porque la fila se toma con SELECT … FOR UPDATE antes de escribir.

03 Puesta en marcha

Requisitos: Python 3.12, PostgreSQL 14+, Redis y, por el camino recomendado, Docker con Docker Compose. Con Docker se levanta la API con su propio PostgreSQL, Redis y ClamAV usando el .env de la raíz. Es la única forma de probar en Windows el camino real de WebSockets con gevent y el antivirus, que en el venv nativo no arrancan.

> cp .env.example .env               # rellenar SECRET_KEY, DATABASE_URL, REDIS_URL…
> docker compose up --build          # api en http://localhost:5000
> docker compose logs -f api         # logs de la API
> docker compose exec api pytest     # tests dentro del contenedor
> python -m venv venv
> source venv/bin/activate           # Linux / Mac; en Windows: venv\Scripts\activate
> pip install -r requirements.txt
> flask db upgrade                   # crea la BD en Postgres antes
> python run.py                      # http://localhost:5000

Variables de entorno

.env.example documenta todas las variables con su comando de generación. Las que importan:

VariableNotas
SECRET_KEYObligatoria: la app no arranca sin ella
DATABASE_URLDriver psycopg v3: postgresql+psycopg://…
TOTP_ENCRYPTION_KEYClave Fernet para cifrar los secretos 2FA en la BD
REDIS_URLRate-limit, lockout, anti-replay TOTP y message queue de Socket.IO
CORS_ORIGINSDev: Vite (5173). Prod: dominios de Vercel o propio
RT_COOKIE_SAMESITELax same-origin (dev); None cross-origin (prod)
SOCKETIO_ASYNC_MODEthreading en dev (default); gevent en prod y en los contenedores
DB_POOL_SIZE / DB_MAX_OVERFLOWPool por proceso (10 + 10). Total = workers × (pool + overflow) ≤ max_connections
R2_* / R2_PRIVADO_*Bucket público del catálogo y privado de documentos. Si R2_PRIVADO_BUCKET va vacía, todo se queda en disco (uploads/)
CLAMAV_HOST / CLAMAV_SOCKETDemonio del antivirus. CLAMAV_FAIL_CLOSED=true en prod
IMG_MAX_DOWNLOAD_BYTES / IMG_MAX_PIXELSTopes de la descarga de imágenes externas
USE_X_ACCEL_REDIRECTtrue solo en prod con Nginx configurado
HSTS_PRELOADfalse salvo que estés seguro: es semi-irreversible

04 Estructura del código

> run.py                  # entry point; monkey-patch de gevent si SOCKETIO_ASYNC_MODE=gevent
> Dockerfile              # imagen de la API (builder + runtime)
> docker-compose.yml      # stack de desarrollo: api, db, redis, clamav
> docker-compose.prod.yml # stack del VPS
> nginx.config            # config de Nginx
> gunicorn.serviceee      # unit de systemd; se instala como nominas.service
> app/__init__.py         # create_app(): CORS, Talisman, Limiter, blueprints, Socket.IO
> app/extensions.py       # db, limiter, mail; IP real tras Cloudflare; EncryptedString
> app/realtime.py         # Socket.IO: handshake, salas, hooks ORM, emit_to_*
> app/observabilidad.py   # after_request: contadores, histograma y detalle en Redis
> app/models/             # SQLAlchemy, 12 módulos por dominio
> app/routes/             # 18 blueprints, cada uno como sub-paquete con _core.py
> app/utils/              # seguridad, archivos, R2, antivirus, imágenes, horas, nómina
> templates/              # Jinja solo para PDFs (xhtml2pdf)
> migrations/             # Alembic
> tests/                  # 36 módulos de pytest

create_app() arranca en orden fijo: config → espera a Redis → extensiones → CORS solo en /api/* → Talisman y CSP → registra los 18 blueprints (exentos de CSRF, protegidos por JWT) → error handlers globales → headers de seguridad y Cache-Control: no-store → tablas auxiliares → ProxyFix → Socket.IO al final, para ver los headers ya corregidos.

  • Cada blueprint es un sub-paquete: _core.py con el blueprint, los decoradores y los serializers, y un módulo por tema.
  • _api_helpers.py compartido: current_user(), is_admin(), require_admin(), require_roles() y el decorador api_transactional (rollback y log automático ante excepción).
  • Toda mutación relevante llama a log_action(...): escribe en audit_log con usuario, IP y acción, y el insert dispara el push bitacora:new.
  • Paginación estándar page / per_page con respuesta {items, total, pages}; trabajadores, préstamos y proyectos aceptan además sort / dir con whitelist de columnas.
  • Excel con _sanitize_rows (anti =fórmula) y estilos compartidos entre 5 paquetes. PDF: Jinja → xhtml2pdf.
  • models/ es SQLAlchemy puro, sin lógica de negocio; se re-exporta plano desde app/models/__init__.py.

05 Referencia de la API

216 rutas agrupadas por blueprint. Todas requieren JWT (Authorization: Bearer …) salvo login, refresh y /health. Respuestas de error estándar: 401 (JWT ausente o expirado), 403 (rol), 404, 422 (validación), 429 (rate-limit) y 419 (CSRF en flujos con cookie).

BlueprintPrefijoQué expone
api_auth/api/authLogin en dos pasos, refresh y logout, perfil propio, sesiones activas, 2FA y códigos de respaldo
api_trabajadores/api/trabajadoresCRUD con whitelist por rol, baja lógica, timeline, notas, credenciales, foto, documentos, importar y exportar Excel
api_proyectos/api/proyectosProyectos, participantes y coordinador; recalcula el expediente de los afectados
api_horas/api/horasReportes semanales, registros diarios (upsert idempotente en bulk), checada por QR y RFID, pantalla para celular del coordinador
api_prenomina/api/prenominaPreview en vivo, guardar y cerrar semana, descuentos, depósitos, viáticos, festivos, PDF, Excel y recibos por correo
api_prestamos · api_ajustes/api/prestamos · /api/ajustesPréstamos con abonos y liquidación; ajuste Inbursa por periodo
api_proyecto_total · api_historico/api/proyecto-total · /api/historicoAcumulados de nómina por proyecto y semanas cerradas, solo lectura y export
api_users/api/usersAdministración de cuentas: solo super_admin crea o elimina admins
api_dashboard · api_bitacora · api_metricas · api_notificaciones · api_search/api/…KPIs y alertas, audit log paginado, métricas, notificaciones y búsqueda global
inventario_api/api/v1Productos, almacenes y estantes con QR, movimientos con lock, solicitudes, tomas físicas, etiquetas y órdenes de compra
herramientas_api/api/v1Catálogo → unidades físicas → asignación, mantenimiento, incidencia y baja con autorización

06 Roles

RolAcceso
super_adminTodo. Único que administra otros admins
adminTodo excepto crear o eliminar otros admins
sistemasPanel de TI (/api/sistemas): infraestructura, sesiones, bloqueos, eventos de seguridad. Exige 2FA activo
inventarioMódulo de inventario completo, sin administración de usuarios
coordinadorSolo /horas y los campos médicos y de contacto de los trabajadores de sus proyectos
solicitante_materialSolo /inventario/mis-pedidos
userRol por defecto al crear una cuenta; sin acceso a los módulos administrativos

La autorización va por decoradores (require_admin, require_roles, _require_inventario…) más whitelist de campos editables por rol en trabajadores: el coordinador no toca salarios ni PII fiscal, y lo prohibido se ignora y regresa en warnings. El coordinador tiene «ownership»: solo ve y edita lo de sus propios proyectos.

07 Tiempo real

El connect valida el JWT y cada conexión se une a las salas user:{id} y role:{rol}; join:reporte agrega reporte:{id} para el kiosko de captura. El worker que hace un cambio lo publica en Redis y el resto reenvía el evento a sus sockets. El evento solo lleva {id, action}: el navegador vuelve a pedir el dato por REST, donde el permiso se valida otra vez. Los hooks ORM emiten solo si el commit fue exitoso.

EventoDisparaAudiencia
notif:newInsert de Notificacionuser:{id} destinatario
bitacora:newInsert de AuditLogRoles admin
abono:newInsert de AbonoPrestamo, manual o por prenóminaRoles admin
reporte:estado_cambioCambio de estado de ReporteSemanalSala reporte:{id}
reporte:registros_cambioCambios en RegistroDiarioHoras; sustituyó al polling del kioskoSala reporte:{id}
nota:changedPOST o DELETE en /trabajadores/<id>/notasAdmin y coordinador

Notificaciones in-app: REPORTE_CERRADO al cerrar un reporte de horas y PRENOMINA_CERRADA al cerrar una prenómina; el push va por notif:new y el polling queda como respaldo. Las leídas se eliminan a los 30 días en cada GET /resumen, sin cron externo. La sesión del SPA se renueva sola 60 s antes de expirar y, si aun así llega un 401, varias peticiones en vuelo comparten un único refresh.

08 Seguridad

El sistema pasó por una auditoría ofensiva completa (pentest, revisión de código e infraestructura): score 5.8 → 8.4/10, con 4 críticas y 7 altas cerradas en código.

Autenticación

  • JWT HS256 con iss=skilled-erp-api y aud=skilled-erp-spa. Access token corto y refresh rotativo en cookie httpOnly con detección de replay.
  • 2FA en dos pasos: la contraseña devuelve un stepToken de un solo uso (con jti quemado en Redis) y solo con él se puede verificar el TOTP. El secreto se guarda cifrado con Fernet.
  • CSRF: /api/auth/refresh y /api/auth/logout exigen X-Requested-With: XMLHttpRequest; el header fuerza preflight y bloquea POST cross-site desde un <form>.
  • Lockout escalado por IP y usuario en Redis (10 min → 24 h), anti-replay de TOTP durante 90 s y comparación constant-time en login.
  • Cambiar la contraseña invalida los JWT en uso mediante password_version.

Archivos e inputs

  • Documentos de trabajador: PDF, JPG, PNG o HEIC, ≤ 20 MB, validados por magic bytes y no por extensión, y escaneados con ClamAV (fail closed en producción).
  • Imágenes externas: el host se resuelve y se rechaza si cae en red interna (anti-SSRF, incluidos redirects), tope de bytes y de píxeles, y reescritura completa a WebP con Pillow.
  • imagen_url solo acepta https:// o paths /static/… locales; bloquea javascript:, data:, http:// y file:///.
  • Prenómina valida tipo enum, concepto de 1 a 250 caracteres, monto ≤ $999,999.99 y fecha_incidencia no futura.

Infraestructura

  • Rate-limit en dos capas: Nginx (api_general 30/s, api_auth 30/min) y Flask-Limiter por usuario e IP en Redis, con la IP real validada contra los CIDR oficiales de Cloudflare.
  • CSP estricta (default-src none), Talisman con HSTS, frame-ancestors: none, COOP y CORP; CORS restringido a orígenes conocidos y Cache-Control: no-store en todo /api/*.
  • Gunicorn con --forwarded-allow-ips=127.0.0.1 cierra el spoofing de CF-Connecting-IP.
  • Hardening de systemd: ProtectSystem=strict, NoNewPrivileges, CapabilityBoundingSet vacío y MemoryDenyWriteExecute. El .env va con chmod 640 y solo lo lee el usuario del servicio.
  • Cada respuesta pasa por un after_request que suma contadores en Redis y guarda el detalle de las lentas y las que fallan: de ahí salen los p50/p95/p99 del panel de sistemas.
Higiene continuaFrecuenciaCómo
pip-audit y npm auditMensualpip-audit -r requirements.txt · npm audit --production
Revisar bitácoraSemanalUI /bitacora o consulta a audit_log filtrando %fallido%
Backup de BD y archivosDiariopg_dump nominas | gzip > backup_$(date +%F).sql.gz
Rotar SECRET_KEYSemestralsecrets.token_urlsafe(64) y reinicio del servicio
Pentest externoAnual—

09 Producción

Cadena: Cloudflare Tunnel → Nginx (127.0.0.1:80) → Gunicorn (127.0.0.1:8000) → Flask. El unit instalado se llama nominas.service; el archivo del repo es gunicorn.serviceee.

> sudo cp gunicorn.serviceee /etc/systemd/system/nominas.service
> sudo systemctl daemon-reload && sudo systemctl enable --now nominas
> sudo cp nginx.config /etc/nginx/sites-available/skilled
> sudo ln -s /etc/nginx/sites-available/skilled /etc/nginx/sites-enabled/skilled
> sudo nginx -t && sudo systemctl reload nginx
  • Gunicorn: 4 workers GeventWebSocketWorker × 1000 conexiones. gthread no implementa el upgrade a WebSocket y eventlet es incompatible con psycopg3.
  • --max-requests 1000 con jitter recicla workers para evitar fugas de pandas, openpyxl y xhtml2pdf.
  • Nginx: rate-limit por IP como segunda capa, IP real de Cloudflare, anti-Slowloris y anti request smuggling, bloque dedicado para /socket.io/ con proxy_read_timeout 3600s, whitelist de métodos y bloqueo de paths escaneados (.env, wp-login.php…).
  • Los headers de seguridad y CORS los pone Flask; no se duplican en Nginx.
  • Frontend en Vercel con VITE_API_URL=https://api.<dominio>/api; el backend lleva CORS_ORIGINS con el dominio de Vercel, RT_COOKIE_SAMESITE=None, FLASK_ENV=production y USE_X_ACCEL_REDIRECT=true.
> curl https://api.<dominio>/health                      # {"status":"ok"}
> sudo journalctl -u nominas -n 20 --no-pager           # IPs reales, no 127.0.0.1
> sudo tail -n 20 /var/log/nginx/skilled_api.access.log  # upstream=127.0.0.1:8000 request_time=…

10 Base de datos

PostgreSQL vía SQLAlchemy y Alembic (flask db …). Los modelos están partidos por dominio en app/models/. Convenciones: PK id autoincremental salvo en los mapeos M:N, timestamps en UTC, montos en Numeric(10,2) y estados como cadenas en mayúsculas.

DominioTablas clave
Authusers, refresh_tokens, totp_backup_codes, audit_log
Empleadostrabajadores (~60 columnas), credenciales_plantas, documentos_trabajador, trabajador_notas
Proyectosproyectos, M:N con trabajadores vía proyecto_trabajador
Horasreportes_semanales, registros_diarios_horas, saldo_vacaciones, ausencias
Nómina y préstamosprenominas, descuentos_prenomina, depositos_extra, prestamos, abonos_prestamo
Ajuste Inbursaajuste_periodos, ajuste_trabajadores_periodo, ajuste_descuentos
Inventarioalmacenes, estantes, productos, stock_por_almacen, movimientos_inventario, tomas_inventario, solicitudes_material
Herramientasherramientas, herramienta_unidades, asignaciones_herramienta, mantenimientos_herramienta, incidencias_herramienta, solicitudes_baja_herramienta
Notificacionesnotificaciones (in-app, purga a 30 días)
> flask db migrate -m "descripción"    # nueva migración
> flask db upgrade                     # aplicar
> flask db current                     # revisión actual
> pytest tests/                        # correr tras cualquier cambio

DIAGRAMAS

01 04

01 El camino de una petición
HTTPS TÚNEL HTTP WSGI SQL Usuario navegador · PWA Cloudflare TLS · CDN · WAF Túnel CF saliente Nginx proxy · rate-limit Gunicorn Flask · 4 workers Datos PostgreSQL · Redis

▶ petición · ◀ respuesta y eventos en vivo por WebSocket · 6 roles: el permiso se valida en el frontend y se exige otra vez en cada endpoint

CAPTURAS

Inicio: accesos rápidos, indicadores de trabajadores y proyectos activos, y gráficas por proyecto y por puesto
Inicio y KPIs
Mi cuenta: información personal, seguridad de la cuenta y sesiones activas
Mi cuenta
Seguridad: cambio de contraseña, activación de 2FA, sesiones activas con opción de revocar y preferencias de apariencia
Seguridad y 2FA
Ficha del empleado con el avance del expediente: identidad, laboral, contacto y documentos pendientes
Ficha del empleado
Expediente: notas internas, datos personales, compensación, contacto de emergencia e IMSS
Expediente y notas
Listado de proyectos con estado, coordinador, participantes y fecha de creación
Proyectos
Resumen de pago de la semana: total a pagar, trabajadores aprobados y desglose por persona con recibos en PDF y Excel
Prenómina semanal
Desglose estadístico de nómina por proyecto: percepciones, deducciones y neto depositado por trabajador
Histórico de nóminas
Préstamos: monto, restante, progreso de abonos, descuento semanal y estado por trabajador
Préstamos
Catálogo de productos del inventario con alertas de stock bajo, importación desde Excel y vista de galería o tabla
Catálogo de inventario
Galería de equipo de protección con existencias, mínimos y acciones por producto
Galería de productos
Solicitudes de material: solicitante, proyecto, estado del flujo de aprobación y botón de entrega
Solicitudes de material

01 01