El plano está sobre la mesa; hoy se ponen los ladrillos. Esta lección te acompaña hito a hito, pero no te hace el trabajo: encontrarás esqueletos, contratos y puntos de control, y una única pieza resuelta por completo — el ServicioVentas — porque integra en treinta líneas cinco módulos del curso y merece verse entera y comentada. Del resto implementas tú, verificando cada hito contra su "demo" de 12-02 antes de pasar al siguiente. También encontrarás aquí lo que ningún tutorial cuenta: las trampas típicas de integración y un protocolo para cuando te atasques (que te atascarás — está previsto y no es una mala noticia).
Contenido
- H1 — Dominio: reutilizar con criterio
- H2 — Servicios:
ServicioVentas, la pieza resuelta - H3 — Persistencia: repositorios con guardado atómico
- H4 — Interfaz: un ejemplo completo, el resto por contrato
- H5 — Informe: esqueleto con salidas esperadas
- H6 — Pulido: logging, README, requirements
- Trabajar en pasos pequeños (y la mención honesta a git)
- Trampas de integración típicas
- Protocolo de atasco en 5 pasos
H1 — Dominio: reutilizar con criterio
El primer hito es un traslado con limpieza: traer las piezas de M5-M7 al paquete nuevo, revisándolas contra los contratos de 12-02. Checklist de qué se reutiliza tal cual y qué cambia:
| Pieza | Origen | ¿Cambia? |
|---|---|---|
dataclass Libro con precio_final(socio) y hay_stock() |
M5 (05-06) | Se añade el campo autor (RF1 lo exige) y type hints completos |
Jerarquía ErrorPapyrus y sus 4 hijas |
M7 (07-04) | Tal cual — ya era exactamente lo que RNF3 pide |
CUPONES y aplicar_cupon |
M7 | Tal cual |
Socio |
Nuevo | @dataclass(frozen=True) con codigo, nombre, alta — inmutable porque nadie debe mutar un socio a media venta |
IVA_LIBROS, DESCUENTO_SOCIO |
M1 | Tal cual (0.04 y 0.05); viven en modelos.py junto a quien las usa |
Punto de control H1 (en un REPL desde la raíz del proyecto):
>>> from papyrus.modelos import Libro
>>> Libro("La Odisea", "Homero", 12.50, 4).precio_final(socio=True)
12.35
>>> from papyrus.cupones import aplicar_cupon
>>> aplicar_cupon(100.0, "PAPYRUS10")
90.0
>>> aplicar_cupon(100.0, "NOEXISTE") # debe lanzar CuponInvalidoErrorSi el primer número no es 12.35, para y arréglalo ahora: todo el sistema se apoyará en esa fórmula (precio × 1.04 × 0.95, redondeo a 2 decimales).
H2 — Servicios: la pieza resuelta
ServicioVentas.vender es el corazón de Papyrus Online: valida contra el catálogo (M4/M5), lanza errores propios (M7), calcula el precio con socio y cupón (M1/M3), persiste (M6) y deja rastro en el log (M7). Por eso es la única pieza que damos completa — léela línea a línea, porque es el patrón que repetirás en todo lo demás:
# papyrus/servicios.py
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import date
from papyrus.cupones import aplicar_cupon
from papyrus.errores import (LibroNoEncontradoError, SocioInvalidoError,
StockInsuficienteError)
from papyrus.modelos import Libro, Socio
from papyrus.repositorios import (RegistroVentas, RepositorioCatalogo,
RepositorioSocios)
logger = logging.getLogger("papyrus") # el logger de M7, ya configurado en app
@dataclass(frozen=True)
class Venta:
"""Resultado inmutable de una venta: lo que se anota en ventas.csv."""
fecha: str
titulo: str
unidades: int
importe: float
class ServicioVentas:
"""Orquesta una venta completa: valida, calcula, persiste y registra."""
def __init__(self, repo_catalogo: RepositorioCatalogo,
repo_socios: RepositorioSocios,
registro: RegistroVentas) -> None:
# El servicio recibe los repositorios ya construidos (los tests de 12-04
# le pasarán repositorios sobre tmp_path: esta es la razón del diseño).
self._repo_catalogo = repo_catalogo
self._registro = registro
self._catalogo: dict[str, Libro] = repo_catalogo.cargar()
self._socios: dict[str, Socio] = repo_socios.cargar()
def vender(self, titulo: str, unidades: int,
codigo_socio: str | None = None,
cupon: str | None = None) -> Venta:
# ---- FASE 1: validar TODO antes de tocar NADA (regla de oro) ----
if titulo not in self._catalogo:
logger.warning("Venta rechazada: '%s' no está en el catálogo", titulo)
raise LibroNoEncontradoError(titulo)
libro = self._catalogo[titulo]
if libro.stock < unidades:
logger.warning("Venta rechazada: '%s' pedía %d y hay %d",
titulo, unidades, libro.stock)
raise StockInsuficienteError(titulo, unidades, libro.stock)
es_socio = codigo_socio is not None
if es_socio and codigo_socio not in self._socios:
logger.warning("Venta rechazada: socio '%s' desconocido", codigo_socio)
raise SocioInvalidoError(codigo_socio)
# ---- FASE 2: calcular (dominio puro, sin efectos) ----
precio = libro.precio_final(socio=es_socio) # IVA + descuento socio (M5)
if cupon is not None:
precio = aplicar_cupon(precio, cupon) # CuponInvalidoError si no existe
importe = round(precio * unidades, 2) # un único redondeo final
# ---- FASE 3: mutar y persistir (solo si TODO lo anterior pasó) ----
libro.stock -= unidades
self._repo_catalogo.guardar(self._catalogo) # decisión de 12-02: guardar por venta
venta = Venta(date.today().isoformat(), titulo, unidades, importe)
self._registro.anotar(venta)
logger.info("Venta: %d x '%s' → %.2f EUR (socio=%s, cupon=%s)",
unidades, titulo, importe, codigo_socio, cupon)
return ventaTres cosas que hacen profesional este código y que debes conservar en el tuyo:
- Las tres fases: validar → calcular → mutar. Si
CuponInvalidoErrorsalta en la fase 2, el stock aún no se ha tocado — RF3 (nada cambia si algo falla) se cumple por construcción, no por suerte. Fíjate en que el cupón se valida en fase 2 pero antes de la fase 3: la excepción deaplicar_cuponsigue protegiéndonos. - El log cuenta la historia: cada rechazo es un
WARNINGcon datos concretos (RF7). En 12-04 el log será tu testigo entre capas. - Depende de repositorios, no de rutas: el servicio no sabe dónde está
catalogo.json. Quien lo construye decide — y en los tests, "quien lo construye" usarátmp_path.
Punto de control H2 (script rápido): vender Fausto ×1 con LUIS-001 y PAPYRUS10 debe devolver una Venta con importe 18.67, y el stock de Fausto debe quedar en 9. Completa tú reponer() y cierre_de_caja() con el mismo patrón de fases (los tienes como ejercicio al final).
H3 — Persistencia: guardado atómico
Los repositorios traducen entre disco y objetos, sin una gota de negocio. Esqueleto de RepositorioCatalogo — implementa los # TODO:
# papyrus/repositorios.py
import csv, json, os
from dataclasses import asdict
from pathlib import Path
from papyrus.modelos import Libro
class RepositorioCatalogo:
def __init__(self, ruta: Path) -> None:
self._ruta = ruta
def cargar(self) -> dict[str, Libro]:
# TODO: leer JSON (M6) y construir {titulo: Libro(**datos)}.
# Recuerda: encoding="utf-8" siempre, y deja que FileNotFoundError
# se propague — un catálogo ausente es un error técnico, no de negocio.
...
def guardar(self, catalogo: dict[str, Libro]) -> None:
temporal = self._ruta.with_suffix(".json.tmp")
# TODO 1: volcar [asdict(libro) for libro in catalogo.values()]
# al fichero temporal con json.dump(..., indent=2, ensure_ascii=False).
# TODO 2: os.replace(temporal, self._ruta)
# os.replace es atómico: el catálogo real o es el viejo o es el
# nuevo COMPLETO, nunca un fichero a medias (la idea del
# TransaccionCatalogo de M8, en su versión mínima).
...RepositorioSocios es análogo (más simple: solo carga). RegistroVentas.anotar abre ventas.csv en modo "a" (append, M6), escribe la cabecera solo si el fichero no existía, y añade una fila por venta con csv.writer. Punto de control H3: vende desde un script, abre catalogo.json a mano y comprueba el stock; ejecuta el script otra vez y verifica que el CSV tiene dos filas y una sola cabecera.
H4 — Interfaz: un ejemplo completo, el resto por contrato
La recompensa del diseño: los endpoints son enanos. Ejemplo completo del más rico, POST /api/ventas (itinerario A), incluida la traducción de errores con errorhandler (10-03):
# app.py (fragmento)
from flask import Flask, jsonify, request
from papyrus.errores import (CuponInvalidoError, LibroNoEncontradoError,
SocioInvalidoError, StockInsuficienteError)
app = Flask(__name__)
servicio = crear_servicio() # función tuya que monta repos + servicio
@app.errorhandler(LibroNoEncontradoError)
def _no_encontrado(err): return jsonify(error=str(err)), 404
@app.errorhandler(StockInsuficienteError)
def _sin_stock(err): return jsonify(error=str(err)), 409
@app.errorhandler(SocioInvalidoError)
@app.errorhandler(CuponInvalidoError)
def _peticion_invalida(err): return jsonify(error=str(err)), 400
@app.post("/api/ventas")
def crear_venta():
datos = request.get_json(silent=True)
if not datos or "titulo" not in datos or "unidades" not in datos:
return jsonify(error="Faltan campos: titulo, unidades"), 400
venta = servicio.vender(datos["titulo"], int(datos["unidades"]),
datos.get("socio"), datos.get("cupon"))
return jsonify(fecha=venta.fecha, titulo=venta.titulo,
unidades=venta.unidades, importe=venta.importe), 201Mira lo que no hay: ni precios, ni stock, ni ficheros. El endpoint valida la forma de la petición (¿es JSON?, ¿están los campos?) y delega el fondo al servicio; los errorhandler traducen cada error de dominio a su código HTTP una sola vez para toda la app. El resto de rutas las implementas tú por contrato (tu hito 0.5): GET /api/libros (200), GET /api/libros/<titulo> (200/404), POST /api/libros (201/409 si duplicado), PUT (200/404), DELETE (204/404).
Itinerario B: el equivalente es una vista vender con un ModelForm (10-05) cuyo clean() llama al servicio dentro de try/except ErrorPapyrus as err: y convierte el error en form.add_error(None, str(err)). El catálogo vive en el ORM, pero la regla es idéntica: la vista traduce, el servicio resuelve. Punto de control H4: los criterios de RF5 de 12-01, probados con curl o con el navegador.
H5 — Informe: esqueleto con salidas esperadas
generar_informe es un guion de M11 empaquetado como función. Esqueleto:
# papyrus/informe.py
def generar_informe(ruta_csv: Path, salida_png: Path) -> ResumenMes:
ventas = pd.read_csv(ruta_csv, parse_dates=["fecha"])
# TODO 1: unidades e importe totales (ventas["unidades"].sum(), ...)
# TODO 2: top 3 títulos por unidades (groupby("titulo") + sum + nlargest)
# TODO 3: unidades por día de la semana (dt.day_name() + groupby, M11)
# TODO 4: gráfico de barras por mes con plt.savefig(salida_png) — y
# plt.close() después, o los tests acumularán figuras abiertas.
# TODO 5: devolver ResumenMes(...) con todo lo anterior.
...Punto de control H5 — ejecútalo sobre el ventas_2026.csv de M11 (cópialo a datos/); tus números deben clavar los canónicos:
| Métrica | Valor esperado |
|---|---|
| Filas del CSV | 487 |
| Unidades totales (ene-jun) | 520 |
| Mejor día de la semana | sábado (130 u) |
| Mejor mes | abril (168 u — Sant Jordi) |
| El PNG | existe en datos/ y se abre |
Si un número no cuadra, tienes un bug reproducible con datos conocidos — el mejor tipo de bug que existe.
H6 — Pulido
- Logging: configúralo una sola vez en el arranque (
logging.basicConfigadatos/papyrus.log, formato con fecha y nivel, como en 07-05) y verifica RF7: provoca un rechazo por stock y busca suWARNINGen el fichero. requirements.txt:pip freeze > requirements.txtdesde tu venv… y luego edítalo a mano dejando solo lo directo (flask o django, pandas, matplotlib, pytest). Un requirements de 40 líneas para 5 dependencias es ruido.- README: en 12-05 tienes la plantilla completa; de momento, que exista con instalación y arranque.
Trabajar en pasos pequeños (y la mención honesta a git)
La disciplina que más protege un proyecto es avanzar en pasos pequeños y verificados: una función, su prueba, su punto de control — y solo entonces la siguiente. Los profesionales materializan esto con git, haciendo un commit (una foto del proyecto) por cada paso: si algo se rompe, se compara con la última foto buena. Este curso no ha enseñado git y no vamos a fingir en tres líneas que lo ha hecho; dos caminos honestos:
- Si no conoces git: al cerrar cada hito, copia el proyecto a
versiones/hito-1/,versiones/hito-2/… Es rudimentario, pero cumple la función de "última foto buena". Y apúntate git como primera extensión post-curso (12-05): es, sin discusión, lo siguiente que debes aprender. - Si ya lo usas: un commit por punto de control, con mensaje que diga qué funciona ya ("H3: catálogo persiste con guardado atómico").
Trampas de integración típicas
Las piezas que funcionan solas fallan juntas. Esta tabla te ahorrará horas:
| Síntoma | Causa probable | Dónde se explicó |
|---|---|---|
ModuleNotFoundError: papyrus |
Ejecutas desde la carpeta equivocada; el paquete no está en el path | M3 (03-04) |
| Importes con céntimos bailando (18.68 vs 18.67) | Redondeas en cada capa; debe haber un redondeo final por importe | M1 (floats), H2 |
TypeError: '<' not supported between 'str' and 'int' |
El CSV entrega texto y nadie lo convirtió: la persistencia entrega tipos, era su trabajo | M6, 12-02 |
FileNotFoundError: datos/catalogo.json al lanzar desde otra carpeta |
Rutas relativas al directorio de ejecución; usa rutas ancladas al fichero (Path(__file__).parent) |
M6 (06-04) |
json.JSONDecodeError al arrancar |
Un guardado anterior murió a medias: tu guardar no era atómico de verdad |
M8 (08-04), H3 |
| Tests que pasan solos y fallan en suite | Estado compartido: todos los tests escriben en el mismo datos/; usa tmp_path |
M9, 12-04 |
Acentos rotos en el JSON (título) |
Falta ensure_ascii=False y encoding="utf-8" |
M6 (06-03) |
ImportError circular entre servicios y repositorios |
Una capa inferior importa de una superior: el diseño de 12-02 se ha invertido | M3, 12-02 |
Protocolo de atasco en 5 pasos
Atascarse forma parte del plan. Lo que distingue al profesional no es no atascarse: es tener protocolo (es el método de 09-04/09-05, aplicado al sistema entero):
- Reproduce en pequeño: aísla el fallo en un script de 5 líneas fuera de la web. Si
vender()falla, no lo depures a través de HTTP. - Lee el traceback de abajo arriba: la última línea dice qué; la primera línea tuya (no de Flask ni de pandas) dice dónde.
- Interroga al log:
papyrus.logcuenta qué pasó antes del fallo. Si el log no dice nada útil, esa es una mejora que hacer ya (y quedará hecha para el siguiente bug). pdben la frontera:breakpoint()justo donde una capa llama a la siguiente, e inspecciona qué entra y qué sale (09-05). El 80 % de los bugs de integración son "yo creía que me pasabas X y me pasabas Y".- Explícalo por escrito: dos frases en
DECISIONES.md: "espero A, ocurre B". La mitad de las veces, al escribir la segunda frase ves el error. Si no, descansa y vuelve: el cerebro sigue trabajando gratis.
Con 30-45 minutos honestos de protocolo es lícito buscar ayuda fuera (documentación, foros) — pero llegarás con el problema ya acotado, que es como se pregunta bien.
Errores Comunes y Consejos
- Copiar el
ServicioVentassin leerlo. Es la única pieza regalada del proyecto; su valor no es ahorrarte teclear, es enseñarte el patrón validar-calcular-mutar que debes replicar enreponer, en los endpoints y en el informe. - Avanzar dos hitos con puntos de control pendientes. "Ya lo comprobaré junto" multiplica el coste de cada bug: si H4 falla y H2-H3 no estaban verificados, el sospechoso puede estar en tres sitios en vez de en uno.
- Configurar
loggingen cada módulo. Se configura una vez en el arranque; los módulos solo hacengetLogger("papyrus"). DosbasicConfig= líneas duplicadas o ausentes en el log. - Perfeccionar HTML/CSS en H4. El itinerario B no pide una web bonita; pide una web correcta. El pulido visual es un agujero de tiempo sin requisito que lo respalde.
- No usar los datos canónicos. Los cuatro libros y
ventas_2026.csvexisten para que sepas qué esperar. Con datos inventados, un resultado raro no sabes si es bug o dato.
Ejercicios
- Hito de proyecto —
reponer. ImplementaServicioVentas.reponer(titulo: str, unidades: int) -> Librocon las tres fases: valida que el libro existe y queunidades >= 1(decide tú qué error lanza lo segundo y documéntalo), suma stock, persiste y registra en el log con nivelINFO. Verificación: reponer 5 Odiseas deja stock 9 encatalogo.jsontras reiniciar. - Hito de proyecto —
cierre_de_caja. Implementacierre_de_caja(fecha: str) -> float: total vendido en esa fecha segúnventas.csv. Pista: reutiliza tu generadorleer_ventasde M8 ocsv.DictReaderde M6. Verificación: tras vender 18.67 y 10.35 hoy, el cierre de hoy da 29.02 y el de ayer 0.0. - Hito de proyecto — tu H4 completo. Implementa el resto de tu interfaz por contrato (las 5 rutas restantes en A; listado, detalle y venta en B). Verificación: los criterios RF5 de 12-01, uno a uno, con
curlo navegador.
Soluciones
- Orientativa — el cuerpo sigue el patrón exacto de
vender: fase 1,if titulo not in self._catalogo: raise LibroNoEncontradoError(titulo)yif unidades < 1: raise ValueError(...)(aquíValueErrores defendible: unidades negativas no son un caso de negocio sino un error de programación; si preferiste crearReposicionInvalidaError, también es correcto — lo importante es que lo hayas decidido y anotado); fase 3,libro.stock += unidades,guardar,logger.info(...),return libro. - Orientativa: filtra filas con
fila["fecha"] == fecha, acumulafloat(fila["importe"])y devuelveround(total, 2). Si el CSV no existe aún, devolver0.0es razonable (caja vacía) — otra decisión paraDECISIONES.md. Trampa esperable: olvidar elfloat()y concatenar strings. - Sin solución única. Autocomprobación del itinerario A:
curl -X POST /api/libroscon un título ya existente debe devolver 409, no 500 ni 200 — si devuelve 500, tu endpoint no está capturando/traduciendo el error de dominio; revisa loserrorhandler.
Conclusión
Papyrus Online ya no es un plan: es código que vende, persiste, sirve e informa. Has recorrido los seis hitos con el patrón que importa — validar, calcular, mutar — visto en detalle en la única pieza resuelta, el ServicioVentas, y replicado por ti en todo lo demás. Te llevas además dos herramientas que valen para cualquier proyecto futuro: la tabla de trampas de integración (síntoma → causa) y el protocolo de atasco en cinco pasos. Pero "me funciona a mí, hoy, en mi máquina" no es un sistema terminado: es un sistema no verificado. La próxima lección convierte los criterios de aceptación de 12-01 en una suite de tests que demuestre — a ti y a cualquiera — que Papyrus Online hace lo que promete, y te enseña a depurar a través de las capas cuando algún test diga que no.
Curso de Programación en Python
Módulo 1: Introducción a Python
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
