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
Comparabley otros órdenes conComparator, incluidos los empates.
Lo que necesitan de las semanas 9 a 11
- Semana 9: excepciones propias,
checked y unchecked,
throwsy las reglas de override, fail fast y probar las excepciones. Modelo:InsufficientFundsException. - Semana 10: clases genéricas, bounded types y wildcards.
- Semana 11:
Set,Map, devolver una colección sin romper el encapsulamiento, elegir la colección,Comparable,Comparatory empates en unTreeSet. - Eck: sec. 8.3 Exceptions and try..catch, sec. 10.2 Lists and Sets, sec. 10.3 Maps y sec. 10.5 Writing Generic Classes and Methods.
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,addStockyremoveStockcon un código inexistente:ProductNotFoundException.Inventory.addcon un código repetido:DuplicateProductException.Product.removeStock(y por lo tantoInventory.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 conequals:compareToda 0 exactamente cuando los códigos son iguales. DeclarencompareTocomofinal, por la misma razón queequalsen 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úmerostock()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
SaleLinees 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 alProduct: si mañana sube el precio de la leche, las boletas de ayer no deben cambiar. Constructor compacto: código, nombre y categoría nonullni en blanco,quantity > 0,subtotalCents >= 0y0 <= discountCents <= subtotalCents; si no,IllegalArgumentException. Métodolong totalCents(): subtotal menos descuento. - Una
Saletiene un código de boleta, una fecha, un cliente y sus líneas. Constructor compacto: código nonullni en blanco (IllegalArgumentException), fecha nonull(NullPointerException), cliente nonull(NullPointerException) y guardado sin los espacios de los extremos (strip()); la cadena vacía""significa cliente anónimo. Las líneas se copian conList.copyOf(la venta no cambia aunque cambie la lista que se pasó) y no pueden estar vacías (IllegalArgumentException). Métodoslong totalCents()ylong 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í:
- Si el carrito está vacío:
IllegalArgumentException. - 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 unPerishableProductvencido endate,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. - Arma una
SaleLinepor código del carrito, en su orden: subtotalproduct.priceFor(cantidad)y descuentopromotions.discountFor(product, cantidad). - Descuenta el stock de cada producto (con
inventory.removeStock). - Crea la
Salecon el códigohistory.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;
Repositorycon dos tipos de elemento distintos de los de las pruebas públicas;- los empates de cada comparador, y un
TreeSetcon 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
Repositoryno tenga nada específico de productos o ventas, y queInventoryySalesHistoryno 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):
- Todo el código, las pruebas públicas de P0 a P3 sin cambios y sus pruebas propias.
- El
README.mddel 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
checkouty 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
checkoutque la venta es atómica?
- ¿Por qué estas excepciones son unchecked? ¿Habrían preferido checked si
empezaran el proyecto de cero? ¿Qué cambiaría en