P5 — Aplicación
Publicación: semana 15 · Entrega y presentación: semana 16 · En parejas · Reglas generales de entrega: projects/README.md.
Bodega ya sabe todo lo que tiene que saber: productos, promociones, ventas atómicas, reportes. Pero todavía no es un programa que doña Rosa pueda usar: no tiene interfaz, y al terminar se olvida de todo. En P5 se vuelve una aplicación completa: una línea de comandos para vender, consultar y reponer, que guarda su estado en archivos CSV y lo recupera al día siguiente, y que avisa cuando algo se está acabando.
Es también la entrega en que los patrones de diseño aparecen donde resuelven un problema que el proyecto ya tiene: avisar del stock bajo sin que el inventario conozca la pantalla (Observer), crear el producto correcto a partir de una línea de texto (Factory) y elegir la promoción mientras el programa corre (Strategy, que ya tienen desde P2 sin haberle puesto nombre). Se presenta en el laboratorio de la semana 16.
Objetivos
- Separar el modelo (lo que ya existe) de la interfaz de usuario y de la persistencia, de modo que cada parte se pruebe sola.
- Aplicar Observer, Factory y Strategy donde resuelven un problema concreto, y explicar por qué.
- Guardar y cargar el estado en CSV con
java.nio.file, manejando los errores de E/S con excepciones checked. - Escribir un ciclo de comandos con
Scanneryswitchque no se cae con una entrada mala.
Lo que necesitan de las semanas 14 a 16
- Semana 14: Strategy
(y ya lo conocían:
Comparator), Observer, los detalles que importan, probar un subject con un spy y Factory. - Semana 15: leer y escribir con
Files, codificación, dónde va el código que guarda, CSV a mano, una línea mal formada, pruebas de ida y vuelta, serialización de Java y un menú de línea de comandos. Modelo de código:CsvSongRepositoryyPlaylistCli, con sus pruebas. - Semana 16: separar el modelo de la interfaz y una aplicación por capas.
- Eck: subsec. 2.4.6 Using Scanner for Input, subsec. 3.6.2 Menus and switch Statements, subsec. 4.3.6 Command-line Arguments y sec. 11.1 I/O Streams, Readers, and Writers.
La aplicación por capas
BodegaCli lee comandos, muestra resultados, captura los errores del usuario
│
├── Register, SalesReport, Promotions, Inventory, SalesHistory el modelo (P1 a P4)
│ └── LowStockListener ── BodegaCli escucha los avisos
│
└── Store<Product>, Store<Sale> guardar y cargar
└── CsvProductStore, CsvSaleStore ── ProductFactory, Csv
El modelo no sabe que existe una pantalla ni un archivo: no imprime nada, no
lee nada y no lanza IOException. Toda la E/S está en BodegaCli y en los
Store.
Requisitos
Todo lo de P0 a P4 sigue igual y sus pruebas deben seguir pasando.
1. Observer: el aviso de stock bajo
public interface LowStockListener {
void onLowStock(Product product);
}
Inventory es el subject. Métodos nuevos:
| Método | Qué hace |
|---|---|
void addLowStockListener(LowStockListener listener) |
Suscribe un listener. Con null: NullPointerException. |
void removeLowStockListener(LowStockListener listener) |
Lo da de baja. |
int lowStockThreshold() |
El umbral de los avisos: 5 si nadie lo cambió. |
void setLowStockThreshold(int threshold) |
Lo cambia. Negativo: IllegalArgumentException. |
Cuándo se avisa: Inventory.removeStock avisa a todos los listeners, en
el orden en que se suscribieron, cuando el producto cruza el umbral: antes
de descontar isLowStock(umbral) era false, y después es true. Si el
producto ya estaba bajo, descontar más no vuelve a avisar; si se repone por
encima del umbral y vuelve a bajar, sí. Si removeStock falla (stock
insuficiente), no avisa. Como el umbral se evalúa con isLowStock, para un
producto a granel está en kilogramos (P2).
Como Register.checkout descuenta con inventory.removeStock, una venta
también avisa. Por eso conviene que todo cambio de stock pase por
Inventory: si alguien descuenta con inventory.find(code).removeStock(...),
el inventario no se entera. Mencionen en el README cómo lo evitan en su
código, o por qué lo aceptan.
2. Factory: crear productos desde texto
public final class ProductFactory, con constructor private, es el único
lugar del programa que decide qué clase de producto crear:
| Método | Qué hace |
|---|---|
static Product fromFields(List<String> fields) |
Recibe los siete campos de una línea del CSV de productos (requisito 3) y crea un Product, un BulkProduct o un PerishableProduct según el primer campo. |
static List<String> toFields(Product product) |
Lo inverso: los siete campos de un producto. fromFields(toFields(p)) da un producto con los mismos datos. |
fromFields lanza IllegalArgumentException si hay un número de campos
distinto de 7, si el tipo no es unit, bulk ni perishable, si el precio o
el stock no son enteros, si la fecha no es AAAA-MM-DD válida, si un
perecible no tiene fecha o un producto que no es perecible sí la tiene, o si
el constructor del producto rechaza los datos. Ojo: LocalDate.parse lanza
DateTimeParseException, que no es una IllegalArgumentException;
captúrenla y envuélvanla con la causa (semana 9).
3. Persistencia en CSV
public interface Store<T> {
List<T> load() throws IOException;
void save(List<T> items) throws IOException;
}
Dos implementaciones, cada una con un constructor que recibe la ruta del
archivo (Path). Las dos escriben y leen en UTF-8, con una primera línea
de encabezado, y usan la clase Csv provista (la de la semana 15):
un campo con coma o comillas va entre comillas, con las comillas duplicadas.
CsvProductStore implements Store<Product>: una línea por producto, en
el orden de la lista:
kind,code,name,category,price_cents,stock,expires_on
unit,LEC-01,Leche evaporada,Lácteos,420,24,
bulk,ARR-01,Arroz a granel,Abarrotes,420,25000,
perishable,YOG-01,"Yogur de fresa, 1 l",Lácteos,690,6,2026-11-04
Los campos son los de ProductFactory. El precio va en céntimos (por kilo si
es a granel) y el stock en unidades (gramos si es a granel); expires_on va
vacío si el producto no es perecible.
CsvSaleStore implements Store<Sale>: una fila por línea de boleta;
las filas de una misma boleta van seguidas, en el orden de sus líneas, y las
boletas en el orden de la lista:
code,date,customer,product_code,product_name,category,quantity,subtotal_cents,discount_cents
B-000001,2026-11-02,Rosa Quispe,LEC-01,Leche evaporada,Lácteos,3,1260,0
B-000001,2026-11-02,Rosa Quispe,PAN-01,Pan francés,Panadería,10,300,0
B-000002,2026-11-02,,ARR-01,Arroz a granel,Abarrotes,2000,840,0
El cliente anónimo es el campo vacío.
Al cargar (load):
- Si el archivo no existe,
Files.readAllLineslanzaNoSuchFileException(unaIOException): déjenla pasar; quien llama decide qué hacer. - Las líneas en blanco se ignoran.
- Cualquier otro problema lanza
MalformedCsvException(provista; extiendeIOExceptiony su mensaje empieza conlínea N:), con el número de línea del archivo, contando el encabezado como línea 1. En particular, con estos mensajes exactos:- encabezado distinto del esperado, o archivo vacío:
línea 1: se esperaba el encabezadoseguido del encabezado; - una línea con un número de campos distinto:
línea N: se esperaban 7 campos y hay M(9 campos para las ventas).
- encabezado distinto del esperado, o archivo vacío:
- Para los demás errores el número de línea es obligatorio y el resto del
mensaje es libre: un campo que no se entiende (lo que rechace
ProductFactory,SaleLineoSale), un código de producto repetido, una boleta cuyas filas no están seguidas (aparece de nuevo más abajo: la línea es la de esa fila), o filas de una misma boleta con fecha o cliente distintos (la línea es la de la fila que no coincide).
Al guardar (save), el archivo se reemplaza completo.
MalformedCsvException es checked, igual que IOException: un archivo
mal escrito no es un error del programa, es algo que el programa correcto
encuentra y debe manejar. Compárenlo con las excepciones unchecked de P3.
Serialización. No se pide. Si la usan además del CSV (por ejemplo, para
una copia de respaldo), cada clase serializable necesita su
private static final long serialVersionUID, y con -Xlint:all -Werror el
compilador lo exige en cada subclase; lean antes por qué no se deserializan
datos que no son de confianza.
4. Strategy: las promociones
Discount es una strategy desde P2: Promotions delega en ella el cálculo
del descuento sin saber cuál es, y en P4 vieron que una lambda también sirve.
En P5 la aplicación elige las strategies mientras corre, con el comando
promo (requisito 5). No hace falta código nuevo en el modelo; en el README
expliquen dónde está el patrón en su proyecto y qué ganaron con él.
5. BodegaCli: la línea de comandos
public BodegaCli(Scanner in, PrintStream out, Path dataDir, LocalDate today)
public void run()
public static void main(String[] args)
main usa como carpeta de datos args[0] (o datos si no hay argumentos), la
entrada y la salida estándar y la fecha de hoy (LocalDate.now(), el único
lugar del proyecto donde se llama). Las pruebas usan el constructor, con una
fecha fija.
La salida se compara línea por línea con la esperada (requisito 6), así que
los textos de esta sección son exactos: respeten espacios, tildes, mayúsculas
y puntuación. Cuando un mensaje dice CÓDIGO, N, etc., se reemplaza por el
valor. Los montos van con Soles.format.
Al empezar
-
Carga
productos.csvyventas.csvde la carpeta de datos con los dosStore. Si un archivo no existe, se empieza con el inventario o el historial vacío. Los productos se agregan al inventario y las ventas al historial en el orden del archivo. -
Si la carga falla por cualquier otro motivo (una
IOException, incluidaMalformedCsvException, o un producto o una venta repetidos al agregarlos), escribeno se pudo cargar:seguido del mensaje de la excepción y termina sin guardar nada: así un archivo dañado no se sobrescribe con datos vacíos. -
Suscribe un
LowStockListeneral inventario (requisito 1) y escribe:Bodega lista (productos: 6, ventas: 2). Escriba ayuda para ver los comandos.
El ciclo
Antes de leer cada comando escribe > (con un espacio y sin salto de
línea: out.print("> ")). Lee una línea, le quita los espacios de los
extremos y la parte en palabras separadas por espacios (split("\\s+")). La
primera palabra es el comando. Una línea vacía no hace nada. Si se acaba la
entrada, se hace lo mismo que con salir.
Después de cada comando, si hubo avisos de stock bajo, escribe uno por línea,
en el orden en que llegaron:
aviso: stock bajo de CÓDIGO NOMBRE (CANTIDAD). El listener solo los
anota; BodegaCli los escribe al final del comando, después de la boleta.
Cómo se escribe una cantidad: 24 u. para un producto por unidad y
25000 g para uno a granel. Cómo se escribe un producto (comandos
productos y buscar): sus campos separados por |, con el precio por kilo
marcado y la fecha si es perecible:
LEC-01 | Leche evaporada | Lácteos | S/ 4.20 | 24 u.
ARR-01 | Arroz a granel | Abarrotes | S/ 4.20 por kg | 25000 g
YOG-01 | Yogur de fresa, 1 l | Lácteos | S/ 6.90 | 6 u. | vence 2026-11-04
Los comandos
| Comando | Qué hace y qué escribe |
|---|---|
ayuda |
Las doce líneas de la lista de abajo, tal cual. |
productos |
Cada producto en una línea, en orden de inserción; (sin productos) si no hay. |
buscar CÓDIGO |
El producto en una línea, o no existe el producto CÓDIGO. Sin código o con más palabras: uso: buscar CÓDIGO. |
reponer CÓDIGO N |
Suma N al stock y escribe CÓDIGO: ahora hay CANTIDAD. Si N no es un entero positivo: cantidad inválida: N; si el producto no existe: no existe el producto CÓDIGO. Sin exactamente dos argumentos: uso: reponer CÓDIGO CANTIDAD. |
vender CÓDIGO N [CÓDIGO N ...] [para CLIENTE] |
Arma un carrito con los pares código-cantidad y hace checkout con la fecha actual. El cliente es todo lo que va después de la palabra para (puede tener espacios); sin para, la venta es anónima. Escribe la boleta (abajo). Si no hay pares, si queda una palabra suelta o si después de para no hay nada: uso: vender CÓDIGO CANTIDAD [CÓDIGO CANTIDAD ...] [para CLIENTE]. Si una cantidad no es un entero positivo: cantidad inválida: N (la primera, y no se vende nada). Si checkout lanza una excepción del dominio de P3: venta rechazada: y su mensaje. |
ventas |
Cada venta: CÓDIGO | FECHA | TOTAL | CLIENTE, con - como cliente si es anónima; (sin ventas) si no hay. |
reporte |
El resumen de abajo, o (sin ventas). |
stock-bajo [UMBRAL] |
CÓDIGO NOMBRE: CANTIDAD por cada producto de inventory.lowStock(umbral), o (ninguno). Sin argumento, el umbral es lowStockThreshold(). Si no es un entero mayor o igual que cero: umbral inválido: X. Con más de un argumento: uso: stock-bajo [UMBRAL]. |
promo categoria P CATEGORÍA |
Agrega un CategoryDiscount de P % (la categoría es el resto de la línea) y escribe promoción agregada: P% en CATEGORÍA. |
promo lleva CÓDIGO N M |
Agrega un MultiBuyDiscount y escribe promoción agregada: lleva N, paga M en CÓDIGO. |
Para los dos promo: si un número no es entero o el constructor lo rechaza, promoción inválida; con otra forma, uso: promo categoria PORCENTAJE CATEGORÍA | promo lleva CÓDIGO LLEVA PAGA. Las promociones duran lo que dura el programa: no se guardan. |
|
fecha AAAA-MM-DD |
Cambia la fecha de las ventas siguientes y escribe fecha: AAAA-MM-DD. Si no se entiende: fecha inválida: X. Sin exactamente un argumento: uso: fecha AAAA-MM-DD. |
guardar |
Guarda productos y ventas (creando la carpeta si no existe, con Files.createDirectories) y escribe guardado (productos: N, ventas: M). Si falla: no se pudo guardar: y el mensaje. |
salir |
Lo mismo que guardar, y después hasta luego; termina. |
| otro | comando desconocido: PALABRA. |
El texto de ayuda:
productos: lista los productos
buscar CÓDIGO: muestra un producto
reponer CÓDIGO CANTIDAD: suma stock a un producto
vender CÓDIGO CANTIDAD [CÓDIGO CANTIDAD ...] [para CLIENTE]: registra una venta
ventas: lista las ventas
reporte: resume las ventas
stock-bajo [UMBRAL]: lista los productos por reponer
promo categoria PORCENTAJE CATEGORÍA: descuento en una categoría
promo lleva CÓDIGO LLEVA PAGA: lleva N, paga M
fecha AAAA-MM-DD: cambia la fecha de las ventas
guardar: guarda los datos
salir: guarda y termina
La boleta de vender (la línea del encabezado lleva para CLIENTE solo si
hay cliente; cada línea empieza con dos espacios, y el descuento aparece solo
si es mayor que cero):
Boleta B-000003 del 2026-11-03 para Jorge Huamán
GAS-01 Gaseosa 500 ml, 3 u.: S/ 5.00 (descuento S/ 2.50)
ARR-01 Arroz a granel, 1500 g: S/ 6.30
Total: S/ 11.30
aviso: stock bajo de GAS-01 Gaseosa 500 ml (5 u.)
El monto de cada línea es su totalCents(). El reporte (los días y las
categorías en el orden de SalesReport, y los tres productos de
topProducts(3), o menos si no hay tres):
Ventas: 5, total S/ 60.28
Por día:
2026-11-02: S/ 24.00
2026-11-03: S/ 36.28
Por categoría:
Abarrotes: S/ 14.70
Bebidas: S/ 10.00
Lácteos: S/ 32.58
Panadería: S/ 3.00
Más vendidos:
1. LEC-01 Leche evaporada: S/ 20.16
2. ARR-01 Arroz a granel: S/ 14.70
3. YOG-01 Yogur de fresa, 1 l: S/ 12.42
BodegaCli captura las excepciones que puede explicar al usuario (las del
dominio y las de E/S) y no captura las demás: una NullPointerException
es un error de su programa, y debe verse.
6. Casos de prueba de la aplicación
En tests/ hay cuatro sesiones completas: BodegaCli.p5-N.in es la entrada,
.expected la salida esperada, .data/ la carpeta de datos inicial y .after/ los
archivos como deben quedar al terminar. El Makefile copia .data/ a
build/run/BodegaCli.p5-N/, ejecuta java -cp build BodegaCli build/run/BodegaCli.p5-N
con la entrada, y compara la salida y los archivos. Las sesiones empiezan con
fecha antes de vender: si no, la boleta saldría con la fecha del día en que se
corre la prueba.
| Caso | Qué prueba |
|---|---|
p5-1 |
Una jornada: productos, promociones, ventas con y sin cliente, avisos de stock bajo, reposición, reporte; y los CSV que quedan. |
p5-2 |
Todos los mensajes de error, ventas rechazadas que no cambian nada, un perecible vendido su último día, y el fin de la entrada sin salir. |
p5-3 |
Un productos.csv mal formado: el programa no arranca y no toca los archivos. |
p5-4 |
Sin archivos: empieza vacía y al salir crea los dos CSV con su encabezado. |
Para ver la diferencia cuando un caso falla, make test muestra el diff;
para correr una sesión a mano:
mkdir -p /tmp/prueba && cp tests/BodegaCli.p5-1.data/* /tmp/prueba/
java -cp build BodegaCli /tmp/prueba < tests/BodegaCli.p5-1.in
7. Pruebas propias
- Observer: un spy propio, con un producto a granel y con dos listeners donde uno se da de baja mientras se le avisa.
- Persistencia: ida y vuelta con datos difíciles (comas, comillas, tildes,
cliente vacío) y un caso de error por cada mensaje del requisito 3, en un
directorio temporal (
@TempDir). - La aplicación: pruebas JUnit con
Scannersobre un texto yPrintStreamsobre unByteArrayOutputStream(comoPlaylistCliTestde la semana 15) y al menos una sesión propia entests/con su.data/,.expectedy.after/(por ejemplo,BodegaCli.mio-1).
8. Opcional
Si les sobra tiempo, cualquiera de estos (cuéntenlo en el README):
- Un Decorator de
Storeque registre cada carga y cada guardado (comoLoggingSongRepositoryde la semana 15). - Un Composite: una canasta de productos que se vende como uno solo y cuyo precio es la suma de sus partes.
- Guardar de forma segura: escribir en un archivo temporal y moverlo sobre el
original con
Files.move, para no perder datos si el programa se interrumpe a mitad desave.
Archivos provistos
| Archivo | Para qué |
|---|---|
Makefile |
El mismo de siempre. |
P5LowStockPublicTest.java, P5FactoryPublicTest.java, P5CsvPublicTest.java, P5CliPublicTest.java |
Pruebas públicas de P5. No las modifiquen. |
tests/ |
Las cuatro sesiones de BodegaCli. Cópienlas a la carpeta tests/ de su proyecto, junto a las de P0. |
Csv.java, MalformedCsvException.java, LowStockListener.java, Store.java |
Completos. |
ProductFactory.java, CsvProductStore.java, CsvSaleStore.java, BodegaCli.java |
Esqueletos. |
Para que todo compile, agreguen primero a Inventory los cuatro métodos del
requisito 1, aunque sea con throw new UnsupportedOperationException("por hacer");.
Cómo se prueba
Pruebas públicas
make test corre lo de P0 a P4 (8 casos de entrada y salida y 128 pruebas
JUnit) y lo de P5: 4 sesiones de BodegaCli y 40 pruebas JUnit (9 del
aviso de stock bajo, 9 de ProductFactory, 15 de CSV y 7 de la aplicación).
Pruebas privadas y diseño
Las pruebas privadas corren otras sesiones (otros productos, otras fechas, otros errores, archivos con otros defectos) y repiten las de P0 a P4. El diseño, además, tiene que cumplir esto:
- que el modelo no tenga E/S ni dependa de
BodegaCli; - que
ProductFactorysea el único lugar con la decisión del tipo de producto, y que el inventario no conozca a sus listeners más que por la interfaz; - que
BodegaClino repita lógica del modelo (por ejemplo, que no valide el stock por su cuenta antes de llamar acheckout); - el diagrama de clases final y el README.
Presentación (laboratorio de la semana 16)
Cada equipo tiene 10 minutos:
- Una demostración por línea de comandos: cargar datos, vender con una promoción, provocar un aviso de stock bajo y una venta rechazada, guardar, salir y volver a entrar para mostrar que los datos siguen ahí.
- El diagrama de clases final, recorrido de la interfaz al archivo.
- Una decisión de diseño de la que estén orgullosos y una que cambiarían.
Después vienen preguntas del docente y de otro equipo: dónde está un invariante, qué prueba lo cubre, cómo se agregaría un tipo de producto nuevo (¿cuántos archivos hay que tocar?). Cualquier integrante debe poder responder.
Qué entregar
Antes de la presentación de la semana 16 (si usan git, el
tag p5):
- Todo el código, las pruebas públicas de P0 a P5 y los casos de
tests/sin cambios, y sus pruebas propias. - El
README.mdfinal del equipo: integrantes, qué funciona y qué no, cómo se ejecuta (makeyjava -cp build BodegaCli datos), el diagrama de clases final y una sección “Patrones”: para Observer, Factory y Strategy (y los opcionales que hayan hecho), qué problema del proyecto resuelve cada uno, en qué clases está y qué pasaría sin él. - Una carpeta
datos/de ejemplo con los CSV que usarán en la demostración.