Saltar al contenido

# programacion-orientada-a-objetos-en-python

Programación Orientada a Objetos en Python

Volver a la portada del curso
Lectura (Markdown)

Planificación del proyecto final

Sección 6 · Proyecto Final

Planificación del proyecto final

El proyecto final reúne todo el curso en un solo sistema: PyShop, la tienda en línea que has construido por partes. Antes de escribir la primera clase conviene planear: qué debe hacer el sistema, qué clase se encarga de cada cosa y en qué orden se construye. Un buen plan evita el error más caro de la POO: descubrir a la mitad que una responsabilidad quedó en la clase equivocada.

Requisitos de PyShop

  • La tienda registra productos en un catálogo y los encuentra por SKU, un número que se asigna solo desde 100.
  • Hay productos físicos, con peso y stock, y productos digitales, que nunca se agotan.
  • El precio siempre es mayor que cero; el stock solo cambia al reponer o al vender.
  • Cada tipo de producto calcula su envío: 49 + 15 × peso × cantidad para los físicos y 0 para los digitales.
  • Un cliente arma un carrito, puede aplicar un descuento (porcentaje o monto fijo con compra mínima) y confirma el pedido.
  • Confirmar es atómico: o se surte el pedido completo o no se toca el stock.
  • Los errores son específicos y se pueden atrapar todos con una sola excepción raíz.

Responsabilidades por clase

La regla práctica: cada dato vive en la clase que mejor lo conoce, y cada clase hace una sola cosa bien.

  • Producto (abstracta): nombre, precio validado con @property, stock de solo lectura, SKU, reponer, retirar y subtotal. Declara costo_envio como método abstracto.
  • ProductoFisico y ProductoDigital: solo lo que las distingue: el peso, el cálculo del envío y si el stock se agota.
  • Descuento (abstracta): un contrato con aplicar(subtotal), que devuelve el monto a descontar. DescuentoPorcentaje y DescuentoFijo lo implementan.
  • Linea: un producto y una cantidad, inmutable (@dataclass(frozen=True)).
  • Carrito: tiene líneas y, opcionalmente, un descuento. Calcula subtotal, envío y total, y confirma el pedido. No pregunta de qué tipo es cada producto: delega en sus métodos.
  • Tienda: el catálogo (diccionario SKU → producto), registrar y buscar.
  • Excepciones: ErrorTienda como raíz; PrecioInvalidoError, StockInsuficienteError y ProductoNoEncontradoError debajo.

Diagrama de clases

Una flecha <|-- indica herencia («es un»); un rombo <>--, composición («tiene»).

Producto (ABC)  <|--  ProductoFisico   (peso_kg)
                <|--  ProductoDigital  (sin stock real)
Descuento (ABC) <|--  DescuentoPorcentaje
                <|--  DescuentoFijo
Tienda   <>-- 0..*  Producto   (catálogo por SKU)
Carrito  <>-- 0..*  Linea      (lista de líneas)
Carrito   --> 0..1  Descuento
Linea     -->    1  Producto

ErrorTienda (Exception)
  <|-- PrecioInvalidoError       (+ ValueError)
  <|-- StockInsuficienteError    (faltante)
  <|-- ProductoNoEncontradoError (+ KeyError)

Orden de construcción

Se construye de abajo hacia arriba: primero lo que no depende de nada y al final lo que usa todo lo demás. Prueba cada pieza antes de pasar a la siguiente.

  1. Excepciones. No dependen de nada y las usan todas las demás clases.
  2. Producto y sus subclases. Comprueba el setter del precio, el stock de solo lectura y el envío de cada tipo.
  3. Descuentos. Son independientes del carrito; se prueban con un subtotal suelto.
  4. Linea y Carrito. Primero los cálculos y al final confirmar.
  5. Tienda. El catálogo y la búsqueda con su error propio.
  6. Un pedido de punta a punta que use todas las clases juntas.

Probar con assert

No hace falta ningún framework para empezar: assert condición no hace nada si la condición es verdadera y lanza AssertionError si es falsa. Escribe las pruebas con cifras que puedas calcular a mano. Con 2 tazas de 149, 1 teclado de 899 y 1 ebook de 299, el subtotal es 1496 y el envío 61 + 67 + 0 = 128:

car = Carrito("Ana")
car.agregar(taza, 2)
car.agregar(teclado, 1)
car.agregar(ebook, 1)
assert len(car) == 4
assert car.subtotal() == 1496.0
assert car.total() == 1624.0

try:
    taza.precio = -10
except PrecioInvalidoError:
    pass
else:
    raise AssertionError("aceptó un precio negativo")
assert taza.precio == 149.0

Las pruebas de errores tienen dos partes: que el error ocurra y que el objeto quede intacto. Esta segunda parte es la que revela si confirmar es realmente atómico.

Dinero: float, Decimal o centavos

Los float son binarios y no representan exactamente 0.1: en Python, 0.1 + 0.2 da 0.30000000000000004. En una tienda, esos errores se acumulan en totales que no cuadran por un centavo. Hay dos soluciones estándar:

from decimal import Decimal

print(Decimal("0.1") + Decimal("0.2"))  # 0.3
centavos = 2 * 14900 + 89900 + 29900
print(centavos)                         # 149600

decimal.Decimal hace aritmética decimal exacta; créalo siempre desde texto, porque Decimal(0.1) hereda el error del float. La alternativa es guardar centavos como enteros y dividir entre 100 solo al mostrar. La solución de referencia usa float con round(..., 2) para mantener el código corto, y el cambio a Decimal queda como extra del proyecto.

Trampas comunes

  • Validar el stock línea por línea: si el mismo producto aparece en dos líneas, cada una pasa la validación por separado. Suma primero las cantidades por producto.
  • Descontar stock mientras se valida: si falla el tercer producto, los dos primeros ya se descontaron.
  • Llenar el carrito de if isinstance(...): si necesitas saber el tipo, falta un método polimórfico.
  • Asignar self._precio directamente en __init__: se salta la validación del setter.

Con este plan, cada clase tiene un propósito claro y cada paso se puede probar por separado. El siguiente paso es construirlo.