Aplicación en Streamlit para administrar el taller: clientes, catálogo de
conceptos, notas de servicio, cotizaciones, diagnósticos con escáner y un
tablero con el histórico. Los datos viven en SQLite (taller.db); el Excel
original es únicamente la carga inicial y la app nunca lo escribe.
Construida a la medida de un taller mecánico real, para reemplazar 62 notas de
servicio y 58 clientes que hasta entonces vivían en una hoja de Excel — sin
perder ni un peso del histórico ($381,146.50 exactos) ni la trazabilidad de
las notas en papel que el taller sigue archivando. El razonamiento técnico
completo, decisión por decisión, está en ARCHITECTURE.md.
Pruébala en un comando (requiere Docker):
git clone https://github.com/KCBADev/ServBaut.git && cd ServBaut
docker compose up -d --buildAbre http://localhost:8501 — la app crea su propia base y un administrador
provisional sola, sin ningún paso manual. La contraseña queda en
primera-clave.txt, dentro del volumen de datos (nunca en pantalla ni en un
registro): docker compose exec app cat /datos/primera-clave.txt.
| Notas de servicio | Diagnósticos con escáner |
|---|---|
![]() |
![]() |
(Datos de muestra generados a propósito para estas capturas — nunca los reales del taller.)
- 332 pruebas automatizadas en tres suites independientes — esquema, capa de datos y pantallas — corriendo sobre bases temporales, nunca sobre los datos reales del taller.
- La orden de trabajo se imprime con Chromium sin cabeza, no con una librería de PDF de Python, para reproducir al pixel un diseño HTML/CSS con flexbox, grid y tipografías propias.
- El dinero se guarda en centavos como entero, nunca en punto flotante, para que el histórico cuadre exactamente al peso.
- Los totales los recalculan triggers de SQLite, no la aplicación: no pueden desincronizarse ni editando la base por fuera de la app.
- Dashboard — ingresos por mes, KPIs, ingresos por categoría, mix Producto/Servicio, marcas más atendidas y top de clientes. Un solo filtro de fechas gobierna todo el tablero.
- Cotizaciones — el mismo formulario que «Crear nota» (cliente, vehículo, partidas, IVA), pero sin folio de servicio: es un presupuesto, no un trabajo realizado, así que no cuenta en el dashboard ni en los reportes. El cliente y el vehículo sí quedan registrados; cuando el cliente acepta, se convierte en nota real con un clic, sin volver a capturar nada.
- Crear nota / Notas de servicio — capturar una nota nueva (cliente, vehículo y partidas desde el catálogo o como concepto libre), consultarla con su detalle, editarla (cabecera, cantidades, precios, agregar y quitar renglones), eliminarla y descargar la orden de trabajo en PDF, con el diseño del taller, lista para imprimir o mandar al cliente.
- Diagnósticos con escáner — el reporte de códigos de falla (DTC) que hoy se entrega en papel: mismo cliente y vehículo que una nota, la lista de códigos agrupada por sistema (con su gravedad) y el resumen y recomendaciones, capturados a mano tras leer el escáner, y descargables en PDF con el mismo diseño del taller.
- Clientes — alta con aviso de nombre duplicado, búsqueda que ignora acentos y mayúsculas, y edición.
- Catálogo — alta, edición, búsqueda, filtros por tipo y categoría, y activar/desactivar conceptos.
Requiere Python 3.12. En Windows el comando python choca con el acceso directo
de Microsoft Store, así que se usa el lanzador py:
py -3.12 -m venv .venv
.venv\Scripts\python.exe -m pip install -r requirements.txt
.venv\Scripts\python.exe -m playwright install chromiumEl último paso baja el navegador que imprime la orden de trabajo. Son unos 150 MB y solo se descarga una vez.
Tipografía de la orden impresa. La plantilla usa Archivo, que ya viene
incluida en assets/fonts/ (SIL Open Font License, que
permite redistribuirla). Es la versión variable: un solo archivo cubre los
cuatro grosores que pide el diseño, y nota_pdf.py la incrusta en el documento
justo antes de imprimir. Si el archivo llegara a faltar, la hoja cae a una
tipografía de reserva y se ve bien igual.
1. Cargar el histórico (una sola vez):
.venv\Scripts\python.exe cargar_datos.pyCrea taller.db, migra el Excel, deriva el catálogo y crea el usuario
administrador. Anota las credenciales que imprime al final: la contraseña no
se vuelve a mostrar. Al terminar valida que la suma del histórico cuadre.
El script es idempotente: correrlo de nuevo no duplica ni pisa nada, solo agrega lo que falte. Para reimportar desde cero (borra el histórico, conserva los usuarios):
.venv\Scripts\python.exe cargar_datos.py --reiniciarSi clonaste este repositorio, el Excel del taller no viene incluido: trae
nombres y teléfonos de clientes reales y no se publica. En su lugar hay
ejemplo_taller.xlsx, con la misma estructura y datos inventados, que el
cargador encuentra solo. Lo regeneras con:
.venv\Scripts\python.exe generar_ejemplo.py2. Levantar la app:
.venv\Scripts\streamlit.exe run app.pyAbre http://localhost:8501 y entra con las credenciales del paso anterior.
Si saltas el paso 1 —por ejemplo, en un servidor recién levantado, sin
Excel que cargar— la app ya no se limita a mostrar un error: crea la base
vacía sola, con el esquema al día, y da de alta un administrador con una
contraseña provisional. Esa contraseña queda escrita en
primera-clave.txt, junto a taller.db, nunca en pantalla ni en ningún
registro; la cuenta pide cambiarla en el primer inicio de sesión.
El botón "Descargar respaldo de la base" en Configuración sirve para un respaldo puntual, pero depende de que alguien se acuerde de darle clic, y se queda en el mismo disco que la base: si falla la máquina, se pierden los dos.
respaldos.py es la otra mitad: crea un respaldo comprimido con fecha,
lo verifica de verdad (lo descomprime, revisa que SQLite lo lea sin
corrupción y que los totales cuadren) y rota los viejos (7 diarios + 4
semanales, por omisión).
.venv\Scripts\python.exe respaldos.pyPensado para una tarea programada (Task Scheduler, cron, systemd timer), no
para correr desde la app. No saca los respaldos de la computadora por su
cuenta: eso es trabajo de algo como rclone hacia Drive/OneDrive, corrido
después, sobre la carpeta que este script deja lista (TALLER_RESPALDOS, o
respaldos/ junto a la base por omisión).
Scripts independientes de la app. Guardan CSV y PNG en analisis/salidas/
(carpeta ignorada por git: se regenera corriéndolos).
.venv\Scripts\python.exe analisis\estacionalidad.py
.venv\Scripts\python.exe analisis\rentabilidad_categorias.py
.venv\Scripts\python.exe analisis\retorno_clientes.py
.venv\Scripts\python.exe analisis\ticket_por_marca.pyCada uno avisa cuando los datos no alcanzan para sostener una conclusión — por ejemplo, que con 4 clientes recurrentes no se puede calcular una tasa de retorno confiable, o que sin datos de costo se está midiendo facturación y no rentabilidad.
.venv\Scripts\python.exe pruebas_esquema.py # 53 — garantías de la base
.venv\Scripts\python.exe pruebas_datos.py # 167 — capa de acceso a datos
.venv\Scripts\python.exe pruebas_app.py # 112 — pantallas, con AppTestLas tres corren sobre una base temporal — pruebas_app.py sobre una copia
consistente de taller.db (Connection.backup(), WAL incluido) — y ninguna
toca el archivo real.
app.py Entrada de Streamlit: login y navegación
config.py Configuración por entorno (rutas, límites)
db.py Capa de acceso a datos — todo el SQL vive aquí
auth.py Hash y verificación de contraseñas
arranque.py Arranque autónomo: crea la base y el primer admin
migraciones.py Versionado del esquema (PRAGMA user_version)
nota_pdf.py Genera la nota de servicio imprimible en PDF
exportar.py Vuelca la base a CSV, Excel, SQL y al formato de la hoja del taller
styles.py Paleta y hoja de estilo de toda la interfaz
esquema.sql Definición de la base
cargar_datos.py Carga inicial desde el Excel (idempotente)
generar_ejemplo.py Genera ejemplo_taller.xlsx con datos inventados
restablecer_clave.py Restablece la contraseña de un usuario desde la terminal
respaldos.py Respaldos automáticos comprimidos y verificados
paginas/ Una pantalla por módulo
paginas/descargas.py Envoltorios cacheados de los archivos descargables
analisis/ Scripts de análisis, independientes de la app
historico/ Migraciones ya aplicadas; no hace falta correrlas
explorar_excel.py Inspección del Excel de origen (Paso 0)
verificar_datos.py Verificación de integridad del Excel (Paso 0)
pruebas_*.py Suites de pruebas
Dockerfile, compose.yaml, docker/ Imagen y orquestación para desplegar
El dinero se guarda en centavos como entero. La especificación exige que el
histórico sume exactamente $381,146.50, y las sumas en punto flotante se
desvían. Con enteros la aritmética es exacta y el CHECK de
total = cantidad × precio no necesita tolerancias. Las columnas se llaman
_centavos para que nadie las confunda con pesos; db.py es la única frontera
donde se convierte.
Las PKs originales se conservan. id_cliente sigue siendo el consecutivo de
llegada al taller (1–62, con huecos) e id_nota mantiene el formato N-001.
Reasignarlas rompería la trazabilidad con las notas en papel. La única que se
regenera es la de partidas: el ID_SA del Excel era un contador global con 23
nulos y sin significado. En su lugar hay un id_partida autoincremental y una
columna linea con el consecutivo 1..n dentro de cada nota, que es lo que se
imprime. Al quitar un renglón las líneas se renumeran para que la nota impresa
nunca salga con «1, 2, 4, 5» y parezca que falta algo; se hace en dos pasos
porque UNIQUE (id_nota, linea) se valida fila por fila y una reasignación
directa chocaría a media operación.
El histórico es inmutable. Cada partida guarda su propia copia de la descripción, la categoría y el precio con que se cobró, además del FK opcional al catálogo. Por eso cambiar un precio del catálogo no altera ninguna nota pasada — hay una prueba que lo verifica.
La mano de obra no está en el catálogo. Cruza 8 categorías con precios de
$150 a $8,490, así que un precio de catálogo no significaría nada. Se captura
como concepto libre con precio manual en cada nota. Sacarla resolvió además toda
la ambigüedad del catálogo: sin ella ningún concepto cruza categorías, y con la
llave (descripción, tipo) quedan 90 conceptos limpios.
Los totales los mantienen triggers, no la app. notas.total_centavos se
recalcula en la base con cada INSERT, UPDATE y DELETE de partidas. Así la
igualdad que la especificación marca como obligatoria no puede desincronizarse,
ni aunque alguien modifique la base por fuera de Streamlit.
Un CHECK codifica una regla real del taller: un concepto de tipo Servicio
siempre lleva acción y un Producto nunca. Se verificó en las 246 filas del
histórico sin una sola excepción.
Contraseñas con PBKDF2-HMAC-SHA256, 600,000 iteraciones y salt único por usuario. Se eligió sobre bcrypt porque es librería estándar y no requiere compilar nada en Windows.
Nomenclatura del vehículo: Marca, Año y Tipo. La interfaz y la orden
impresa usan «Tipo» para el modelo específico de esa marca (p. ej. Chevrolet
Malibú) y «Año» para el año, siguiendo la forma en que el taller ya llena su
información. La columna interna de la base se sigue llamando modelo — es un
detalle de implementación que no cambia nada visible, y renombrarla habría
significado tocar el esquema, las migraciones y todo db.py sin ganar nada
para el usuario.
Cotizaciones vive en tablas separadas de notas. Un presupuesto usa
cotizaciones / cotizacion_partidas, con sus propios triggers de subtotal e
IVA, en vez de una nota con una bandera "es cotización". Así una cotización
jamás puede colarse en el dashboard ni en los reportes de facturación por un
filtro que alguien olvidó agregar. El cliente y el vehículo sí se guardan en
sus tablas normales — es lo que permite que, al convertirla, «Crear nota» ya
los tenga en sus listas. eliminar_nota revierte a Pendiente cualquier
cotización que apunte a la nota borrada, en vez de bloquear el borrado o dejar
una referencia rota.
El botón de respaldo tenía un bug de corrección, no solo de conveniencia.
Leía el archivo .db tal cual con Path.read_bytes() — pero en modo WAL las
escrituras más recientes pueden seguir viviendo solo en taller.db-wal sin
haberse fusionado, sobre todo con otra sesión conectada al mismo tiempo. El
respaldo descargado podía venir sin las últimas notas confirmadas, en
silencio. db.respaldar() usa Connection.backup() en su lugar: copia el
estado lógico de la base, WAL incluido, sin necesidad de un checkpoint manual.
El versionado del esquema solo automatiza hacia adelante, a propósito.
migraciones.py trae ocho migraciones históricas (v2–v8) y las deja
deliberadamente manuales: existió una sola base a la que aplicárselas y ya
pasó por todas. Automatizar reconstrucciones de tablas que nunca se van a
volver a ejecutar era riesgo sin beneficio. De la v9 en adelante sí se
automatizan solas, porque ahí sí puede haber varias bases (la del taller, la
de un servidor) que haya que poner al día sin entrar a mano a cada una.
El límite de intentos de acceso no puede delatar qué usuarios existen.
auth.autenticar ya usaba un mensaje único para no distinguir "no existe el
usuario" de "contraseña incorrecta". Mover el límite de intentos de
session_state a una tabla persistente estuvo a punto de reabrir esa misma
fuga por otra puerta: si solo se contaran los intentos de usuarios reales, el
tiempo de espera delataría cuáles sí existen. Se cuenta igual para cualquier
nombre tecleado, exista o no — verificado con una prueba dedicada.
La base vive fuera de la carpeta del proyecto. Antes estaba junto al código en C:, hasta que ese disco se quedó con apenas 115 MB libres y una escritura falló a medio guardar una nota.
Dónde vive exactamente lo decide config.py leyendo el entorno, en este
orden:
| Variable | Qué es |
|---|---|
TALLER_DB |
Ruta completa al archivo. Es la que usa el servidor. |
TALLER_DATOS |
Carpeta; el archivo se llama taller.db dentro. |
| (nada) | D:\TallerBautista\taller.db en Windows si esa carpeta existe; si no, datos/taller.db dentro del proyecto. |
En la computadora del taller no hay que configurar nada: la tercera fila la deja donde siempre estuvo. La carpeta se crea sola la primera vez que algo escribe en la base.
Otras variables, todas opcionales: TALLER_RESPALDOS (carpeta de respaldos),
TALLER_TMP (carpeta de temporales), TALLER_MINUTOS_INACTIVIDAD,
TALLER_HORAS_SESION, TALLER_MAX_INTENTOS, TALLER_ADMIN_USUARIO y
TALLER_ADMIN_PASSWORD.
La app también corre en Docker, para un servidor accesible desde cualquier red (no solo el Wi-Fi del taller):
docker compose up -d --buildCon eso: construye la imagen (Chromium incluido, vía la imagen oficial de
Playwright — no hace falta ningún paso de instalación aparte), crea un
volumen para los datos y levanta la app en http://localhost:8501. La
primera vez que arranca sin una base existente, la crea sola y da de alta un
administrador con una contraseña provisional (ver «Si saltas el paso 1»,
arriba) — igual que corriendo la app suelta.
Variables que sí importa definir antes del primer arranque, como
TALLER_ADMIN_PASSWORD (para no depender de leer primera-clave.txt dentro
del volumen) o TALLER_MINUTOS_INACTIVIDAD, se ponen en un archivo .env
junto a compose.yaml — Compose lo carga solo — o como secretos del
servicio en el hosting que elijas.
Esto es la mitad del camino a "accesible desde cualquier red", no todo.
El contenedor expuesto tal cual sigue siendo HTTP sin cifrar en el puerto
8501: hace falta un servidor donde correrlo (Oracle Cloud Always Free y
Azure for Students son gratuitos; ver la discusión del proyecto para el
porqué) y un proxy inverso delante con TLS (Caddy es sencillo: certificado
automático, solo hay que dejarlo pasar los websockets de
/_stcore/stream). El 8501 nunca debe exponerse directo a internet —
solo 80/443, con el proxy en medio.
compose.yaml fija shm_size: 512mb: Chromium revienta con los 64 MB de
/dev/shm que Docker da por omisión al imprimir el primer PDF. Esa es la
manera correcta de dárselos — nunca --no-sandbox, que desactiva un
aislamiento de seguridad de Chromium para evitar el síntoma en vez de
arreglar la causa.
.streamlit/config.toml tiene address = "0.0.0.0": el servidor escucha en
toda interfaz de red, para poder entrar desde un celular o una tablet en el
mismo Wi-Fi del taller (http://<IP local de la computadora>:8501), o desde
cualquier interfaz dentro de un contenedor. Sigue siendo HTTP sin cifrar, así
que esto vale solo dentro de una red de confianza, o detrás de un proxy con
TLS — nunca el puerto 8501 hacia internet directo (por ejemplo con
port-forwarding en el router), porque los datos de clientes y el login
viajarían sin cifrar a la vista de cualquiera. Si el taller alguna vez no
necesita el acceso por Wi-Fi, lo más seguro es volver address a
"localhost".
taller.db está en el .gitignore y además vive fuera de la carpeta del
proyecto (ver la siguiente sección): contiene datos de clientes y hashes de
contraseñas, y no debe subirse a ningún repositorio.
Detectados al inspeccionar el Excel y respetados en la carga:
- La hoja
TC_TAcontiene dos tablas lado a lado separadas por una columna vacía. Leerla como una sola es un error; el cargador detecta los bloques. - El teléfono se lee como número y debe guardarse como texto. 16 de 58 clientes no tienen.
- La columna
Tipode las notas es en realidad el modelo del vehículo. Se renombró amodeloy se guarda como texto: hay un Chrysler 300 que como número se volvería300.0. - Hay 3 nombres de cliente y 4 descripciones con espacios sobrantes; se limpian al cargar.
- 2 clientes registrados nunca han traído vehículo, así que el KPI de "clientes atendidos" da 56 y no 58.
- El histórico tiene 11 meses de 2024 sin una sola nota. El registro se vuelve continuo hasta diciembre de 2024. El dashboard dibuja esos meses en cero en vez de interpolarlos, para no inventar un crecimiento que no existió.


