P3 — Ventas

Publicación: semana 9 · Entrega: semana 12 · En parejas · Reglas generales de entrega: projects/README.md.

Hasta aquí Bodega sabía qué hay en los estantes y cuánto cuesta. En P3 aprende a vender: un cliente llega con varias cosas, la caja revisa que todo exista, que nada esté vencido y que alcance el stock, aplica las promociones, descuenta el stock y registra la boleta. Y si algo falla, por ejemplo, pidieron seis gaseosas y quedan cinco, la venta no se hace a medias: no se registra nada y el stock queda como estaba.

Los errores dejan de ser excepciones genéricas de la biblioteca y pasan a ser excepciones del dominio con nombre y datos. El inventario pasa a un Map por código, aparecen conjuntos (Set) de categorías y de clientes, órdenes con Comparable y Comparator, y una clase genérica propia que evita escribir dos veces lo mismo para productos y para ventas.

Es la entrega más larga junto con P5: dura tres semanas y usa lo de las tres.

Objetivos

  • Diseñar una jerarquía pequeña de excepciones del dominio, con mensajes y datos útiles, y decidir dónde se lanzan y dónde no se capturan.
  • Garantizar que una operación con varios pasos sea atómica: o se hace todo, o no cambia nada.
  • Escribir una clase genérica con un bounded type (<T extends Coded>) y usarla con dos tipos distintos.
  • Elegir la colección adecuada (List, Set, Map, y sus implementaciones) según el orden y las búsquedas que se necesitan.
  • Definir el orden natural de un tipo con Comparable y otros órdenes con Comparator, incluidos los empates.

Lo que necesitan de las semanas 9 a 11

Para las pruebas de excepciones, assertThrows devuelve la excepción, así que pueden revisar su mensaje y sus datos:

InsufficientStockException e =
        assertThrows(InsufficientStockException.class, () -> inv.removeStock("LEC-01", 8));
assertEquals(5, e.available());

Requisitos

Todo lo de P0 a P2 sigue igual y sus pruebas deben seguir pasando.

1. Excepciones del dominio

Cuatro clases nuevas. Cada una guarda sus datos en campos final con métodos de acceso, arma su mensaje en el constructor con super(...) (el mensaje exacto está en la tabla, porque la aplicación de P5 lo muestra al usuario y las pruebas lo comparan) y declara private static final long serialVersionUID = 1L;.

Clase Extiende Constructor y métodos de acceso Mensaje
ProductNotFoundException NoSuchElementException (String code); code() no existe el producto PAN-01
DuplicateProductException IllegalArgumentException (String code); code() ya existe el producto LEC-01
InsufficientStockException IllegalStateException (String code, int available, int requested); code(), available(), requested() stock insuficiente de LEC-01: hay 5, se pidió 8
ExpiredProductException IllegalStateException (String code, LocalDate expiresOn); code(), expiresOn() producto vencido: YOG-01 (venció el 2026-10-30)

La fecha del mensaje es la de LocalDate.toString() (2026-10-30).

Dónde se lanzan desde ahora:

  • Inventory.find, remove, addStock y removeStock con un código inexistente: ProductNotFoundException.
  • Inventory.add con un código repetido: DuplicateProductException.
  • Product.removeStock (y por lo tanto Inventory.removeStock) cuando no alcanza: InsufficientStockException, con el stock que había y la cantidad pedida. Vale también para las subclases.
  • Register.checkout (requisito 7): las tres que correspondan.

¿Por qué unchecked? En la semana 9 vimos que un saldo insuficiente es un buen candidato a excepción checked: es una situación normal del negocio que quien llama debe manejar. Aquí elegimos lo contrario a propósito, y la razón es de compatibilidad: desde P1, Inventory.removeStock lanza IllegalStateException, y el código de P1 y P2 (incluidas las pruebas públicas) lo llama sin try ni throws. Si la nueva excepción fuera checked, removeStock tendría que declararla con throws, y todo ese código dejaría de compilar. Al extender las excepciones que P1 ya lanzaba, cada excepción nueva es un caso particular de la anterior: la prueba de P1 assertThrows(IllegalStateException.class, ...) sigue pasando con una InsufficientStockException, y quien quiera el detalle captura la subclase. Además, para quien usa el inventario es posible evitarlas: puede preguntar contains y stock() antes. Comenten esta decisión en su README.

2. Coded y Repository<T>: una clase genérica

Productos y ventas tienen algo en común: se identifican por un código único, se guardan en el orden en que llegan, se buscan por código y no se pueden repetir. En lugar de escribir esa lógica dos veces, escríbanla una vez:

public interface Coded {
    String code();
}

Product implementa Coded (ya tiene code()), y también Sale (requisito 5).

public class Repository<T extends Coded> guarda elementos de tipo T en un LinkedHashMap<String, T> indexado por código, que conserva el orden de inserción:

Método Qué hace
Repository() Vacío.
void add(T item) Agrega al final. Código repetido: IllegalArgumentException; null: NullPointerException.
boolean contains(String code) Si hay un elemento con ese código.
T find(String code) El elemento. Si no existe: NoSuchElementException.
T remove(String code) Lo quita y lo devuelve. Si no existe: NoSuchElementException.
List<T> all() Todos, en orden de inserción (copia no modificable).
int size() Cuántos hay.

Repository no sabe nada de productos ni de ventas: sus excepciones son las generales. Quien lo usa y sí sabe de qué se trata (el inventario) lanza las del dominio.

3. Inventory por dentro: un mapa

Inventory deja el ArrayList y guarda sus productos en un Repository<Product> (o directamente en un LinkedHashMap<String, Product>). Buscar por código deja de recorrer la lista. Lo observable no cambia: el orden de products(), lowStock y los demás sigue siendo el de inserción, y todas las pruebas de P1 y P2 siguen pasando. Si alguna se rompe al cambiar la representación, ese es el error que el cambio introdujo.

Métodos nuevos:

Método Qué hace
Set<String> categories() Las categorías de los productos del inventario, sin repetidos y en orden alfabético (TreeSet; el orden de String.compareTo), en un conjunto no modificable. Vacío si no hay productos.
List<Product> productsIn(String category) Los productos de esa categoría, en orden de inserción. Lista vacía si no hay ninguno.
List<Product> productsSortedBy(Comparator<? super Product> order) Una lista nueva con todos los productos en ese orden. No cambia el orden del inventario.

¿Por qué Comparator<? super Product> y no Comparator<Product>? Piénsenlo con la regla PECS de la semana 10: un comparador de cualquier supertipo de Product también sabe comparar productos.

4. Orden de los productos

  • Product implements Comparable<Product>: el orden natural es por código (String.compareTo). Es consistente con equals: compareTo da 0 exactamente cuando los códigos son iguales. Declaren compareTo como final, por la misma razón que equals en P2.
  • ProductByName implements Comparator<Product>: por nombre sin distinguir mayúsculas (compareToIgnoreCase); si los nombres empatan, por código.
  • ProductByStock implements Comparator<Product>: por stock de menor a mayor; si empatan, por código. (Compara el número stock() tal cual: los gramos de un producto a granel cuentan como gramos.)

Los dos comparadores son clases con nombre. En la semana 12 van a ver una forma más corta de escribirlos.

5. SaleLine y Sale: la boleta

Una venta registrada es un valor que no cambia: dos records.

public record SaleLine(String code, String name, String category, int quantity,
                       long subtotalCents, long discountCents) { ... }
public record Sale(String code, LocalDate date, String customer, List<SaleLine> lines)
        implements Coded { ... }
  • Una SaleLine es una foto del momento de la venta: guarda el código, el nombre y la categoría del producto, la cantidad (unidades, o gramos si es a granel), el subtotal sin descuento y el descuento. No guarda una referencia al Product: si mañana sube el precio de la leche, las boletas de ayer no deben cambiar. Constructor compacto: código, nombre y categoría no null ni en blanco, quantity > 0, subtotalCents >= 0 y 0 <= discountCents <= subtotalCents; si no, IllegalArgumentException. Método long totalCents(): subtotal menos descuento.
  • Una Sale tiene un código de boleta, una fecha, un cliente y sus líneas. Constructor compacto: código no null ni en blanco (IllegalArgumentException), fecha no null (NullPointerException), cliente no null (NullPointerException) y guardado sin los espacios de los extremos (strip()); la cadena vacía "" significa cliente anónimo. Las líneas se copian con List.copyOf (la venta no cambia aunque cambie la lista que se pasó) y no pueden estar vacías (IllegalArgumentException). Métodos long totalCents() y long discountCents(): las sumas de las líneas.

6. Cart: el carrito

Lo que el cliente quiere llevar, antes de pasar por la caja.

Método Qué hace
Cart() Vacío.
void add(String code, int quantity) Agrega quantity de ese producto. Código null o en blanco, o cantidad no positiva: IllegalArgumentException. Si el código ya estaba, la cantidad se suma a la que había, en la misma posición. El carrito no revisa si el producto existe: eso lo hace la caja.
Map<String, Integer> items() Código → cantidad, en el orden en que apareció cada código por primera vez, en un mapa no modificable.
boolean isEmpty() Si no tiene nada.

7. SalesHistory y Register: registrar la venta

SalesHistory guarda las ventas en un Repository<Sale>:

Método Qué hace
SalesHistory() Vacío.
void add(Sale sale) Registra una venta. Código repetido: IllegalArgumentException.
Sale find(String code) La venta. Si no existe: NoSuchElementException.
List<Sale> sales() Todas, en el orden en que se registraron (copia no modificable).
int size() Cuántas hay.
String nextCode() El código de la próxima boleta: "B-" y el número size() + 1 con seis dígitos (String.format("B-%06d", n)): B-000001, B-000002, …
List<Sale> salesOn(LocalDate date) Las ventas de ese día, en orden de registro.
Set<String> customers() Los clientes con nombre (sin el anónimo ""), sin repetidos, en orden alfabético, no modificable.

Register es la caja. Recibe sus colaboradores por el constructor, en vez de crearlos:

public Register(Inventory inventory, Promotions promotions, SalesHistory history)
public Sale checkout(Cart cart, LocalDate date, String customer)

checkout hace la venta del carrito en la fecha dada, así:

  1. Si el carrito está vacío: IllegalArgumentException.
  2. Valida todas las líneas antes de tocar nada, en el orden del carrito. Para cada código: si no está en el inventario, ProductNotFoundException; si es un PerishableProduct vencido en date, ExpiredProductException; si la cantidad es mayor que el stock, InsufficientStockException (con el stock y la cantidad total del carrito para ese código). Se lanza la primera que aparezca.
  3. Arma una SaleLine por código del carrito, en su orden: subtotal product.priceFor(cantidad) y descuento promotions.discountFor(product, cantidad).
  4. Descuenta el stock de cada producto (con inventory.removeStock).
  5. Crea la Sale con el código history.nextCode(), la registra en el historial y la devuelve.

La venta es atómica. Si el paso 1 o el 2 fallan, checkout lanza la excepción y nada cambia: ningún stock, ningún registro en el historial, y el próximo código sigue siendo el mismo. Validar todo primero es lo que lo garantiza: si descontaran el stock línea por línea y la tercera fallara, tendrían que devolver lo de las dos primeras. Como el carrito suma las cantidades del mismo código, “3 gaseosas + 3 gaseosas” con 5 en stock se rechaza como una sola línea de 6.

checkout no captura ninguna de estas excepciones: no puede hacer nada útil con ellas. Las capturará la interfaz de usuario de P5, que sí puede avisar al cliente.

8. Pruebas propias

Al menos:

  • para cada excepción, una prueba que verifique el tipo, el mensaje y los datos;
  • Repository con dos tipos de elemento distintos de los de las pruebas públicas;
  • los empates de cada comparador, y un TreeSet con cada uno;
  • para checkout, una prueba de atomicidad por cada causa de error, con el error en la última línea de un carrito de tres, comprobando el stock de las tres y el historial;
  • una venta con los tres tipos de producto y una promoción.

Archivos provistos

Archivo Para qué
Makefile El mismo de siempre.
P3ExceptionsPublicTest.java, P3RepositoryPublicTest.java, P3InventoryPublicTest.java, P3SalesPublicTest.java Pruebas públicas de P3. No las modifiquen.
Las cuatro excepciones, Coded.java (completa), Repository.java, ProductByName.java, ProductByStock.java, SaleLine.java, Sale.java, Cart.java, SalesHistory.java, Register.java Esqueletos de las clases nuevas.

Copien las pruebas y los esqueletos a su proyecto sin borrar nada de lo anterior. Para que todo compile, agreguen primero a sus clases lo nuevo, aunque sea así:

// En Product:
public class Product implements Coded, Comparable<Product> {
    ...
    @Override
    public final int compareTo(Product other) {
        throw new UnsupportedOperationException("por hacer");
    }
}

// En Inventory:
public Set<String> categories() {
    throw new UnsupportedOperationException("por hacer");
}

public List<Product> productsIn(String category) {
    throw new UnsupportedOperationException("por hacer");
}

public List<Product> productsSortedBy(Comparator<? super Product> order) {
    throw new UnsupportedOperationException("por hacer");
}

Con eso make test compila, lo de P0 a P2 sigue pasando y las pruebas de P3 fallan hasta que las completen.

Cómo se prueba

Pruebas públicas

make test corre lo de P0 a P2 (8 casos de entrada y salida y 68 pruebas JUnit) y las 42 de P3: 6 de excepciones, 7 de Repository, 8 de orden y categorías y 21 de ventas.

Pruebas privadas y diseño

Las pruebas privadas repiten los mismos requisitos con otros datos: carritos más largos, errores en distintas posiciones, otras fechas, otros clientes, historiales cargados a mano con add. El diseño, además, tiene que cumplir esto:

  • que ninguna excepción se capture para ignorarla, y que las del dominio se lancen antes de cambiar el estado;
  • que Repository no tenga nada específico de productos o ventas, y que Inventory y SalesHistory no repitan lo que ya hace;
  • que la elección de cada colección se pueda justificar (orden, búsqueda, repetidos);
  • que ninguna clase devuelva una colección interna modificable.

Qué entregar

Para la entrega de la semana 12 (si usan git, el tag p3):

  1. Todo el código, las pruebas públicas de P0 a P3 sin cambios y sus pruebas propias.
  2. El README.md del equipo con el diagrama de clases actualizado (las excepciones pueden ir en un diagrama aparte). Respondan:
    • ¿Por qué estas excepciones son unchecked? ¿Habrían preferido checked si empezaran el proyecto de cero? ¿Qué cambiaría en checkout y en el código que lo llama?
    • ¿Qué colección usaron para cada cosa (inventario, categorías, carrito, historial, clientes) y por qué esa implementación (HashMap, LinkedHashMap, TreeMap, HashSet, TreeSet)?
    • ¿Cómo garantiza su checkout que la venta es atómica?