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

  1. H1 — Dominio: reutilizar con criterio
  2. H2 — Servicios: ServicioVentas, la pieza resuelta
  3. H3 — Persistencia: repositorios con guardado atómico
  4. H4 — Interfaz: un ejemplo completo, el resto por contrato
  5. H5 — Informe: esqueleto con salidas esperadas
  6. H6 — Pulido: logging, README, requirements
  7. Trabajar en pasos pequeños (y la mención honesta a git)
  8. Trampas de integración típicas
  9. 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 CuponInvalidoError

Si 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 venta

Tres cosas que hacen profesional este código y que debes conservar en el tuyo:

  • Las tres fases: validar → calcular → mutar. Si CuponInvalidoError salta 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 de aplicar_cupon sigue protegiéndonos.
  • El log cuenta la historia: cada rechazo es un WARNING con 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), 201

Mira 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.basicConfig a datos/papyrus.log, formato con fecha y nivel, como en 07-05) y verifica RF7: provoca un rechazo por stock y busca su WARNING en el fichero.
  • requirements.txt: pip freeze > requirements.txt desde 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):

  1. 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.
  2. 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.
  3. Interroga al log: papyrus.log cuenta 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).
  4. pdb en 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".
  5. 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 ServicioVentas sin 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 en reponer, 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 logging en cada módulo. Se configura una vez en el arranque; los módulos solo hacen getLogger("papyrus"). Dos basicConfig = 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.csv existen para que sepas qué esperar. Con datos inventados, un resultado raro no sabes si es bug o dato.

Ejercicios

  1. Hito de proyecto — reponer. Implementa ServicioVentas.reponer(titulo: str, unidades: int) -> Libro con las tres fases: valida que el libro existe y que unidades >= 1 (decide tú qué error lanza lo segundo y documéntalo), suma stock, persiste y registra en el log con nivel INFO. Verificación: reponer 5 Odiseas deja stock 9 en catalogo.json tras reiniciar.
  2. Hito de proyecto — cierre_de_caja. Implementa cierre_de_caja(fecha: str) -> float: total vendido en esa fecha según ventas.csv. Pista: reutiliza tu generador leer_ventas de M8 o csv.DictReader de M6. Verificación: tras vender 18.67 y 10.35 hoy, el cierre de hoy da 29.02 y el de ayer 0.0.
  3. 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 curl o navegador.

Soluciones

  1. Orientativa — el cuerpo sigue el patrón exacto de vender: fase 1, if titulo not in self._catalogo: raise LibroNoEncontradoError(titulo) y if unidades < 1: raise ValueError(...) (aquí ValueError es defendible: unidades negativas no son un caso de negocio sino un error de programación; si preferiste crear ReposicionInvalidaError, también es correcto — lo importante es que lo hayas decidido y anotado); fase 3, libro.stock += unidades, guardar, logger.info(...), return libro.
  2. Orientativa: filtra filas con fila["fecha"] == fecha, acumula float(fila["importe"]) y devuelve round(total, 2). Si el CSV no existe aún, devolver 0.0 es razonable (caja vacía) — otra decisión para DECISIONES.md. Trampa esperable: olvidar el float() y concatenar strings.
  3. Sin solución única. Autocomprobación del itinerario A: curl -X POST /api/libros con 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 los errorhandler.

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

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados