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 Scanner y switch que no se cae con una entrada mala.

Lo que necesitan de las semanas 14 a 16

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.readAllLines lanza NoSuchFileException (una IOException): déjenla pasar; quien llama decide qué hacer.
  • Las líneas en blanco se ignoran.
  • Cualquier otro problema lanza MalformedCsvException (provista; extiende IOException y su mensaje empieza con lí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 encabezado seguido 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).
  • 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, SaleLine o Sale), 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

  1. Carga productos.csv y ventas.csv de la carpeta de datos con los dos Store. 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.

  2. Si la carga falla por cualquier otro motivo (una IOException, incluida MalformedCsvException, o un producto o una venta repetidos al agregarlos), escribe no 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.

  3. Suscribe un LowStockListener al 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 Scanner sobre un texto y PrintStream sobre un ByteArrayOutputStream (como PlaylistCliTest de la semana 15) y al menos una sesión propia en tests/ con su .data/, .expected y .after/ (por ejemplo, BodegaCli.mio-1).

8. Opcional

Si les sobra tiempo, cualquiera de estos (cuéntenlo en el README):

  • Un Decorator de Store que registre cada carga y cada guardado (como LoggingSongRepository de 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 de save.

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 ProductFactory sea 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 BodegaCli no repita lógica del modelo (por ejemplo, que no valide el stock por su cuenta antes de llamar a checkout);
  • el diagrama de clases final y el README.

Presentación (laboratorio de la semana 16)

Cada equipo tiene 10 minutos:

  1. 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í.
  2. El diagrama de clases final, recorrido de la interfaz al archivo.
  3. 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):

  1. Todo el código, las pruebas públicas de P0 a P5 y los casos de tests/ sin cambios, y sus pruebas propias.
  2. El README.md final del equipo: integrantes, qué funciona y qué no, cómo se ejecuta (make y java -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.
  3. Una carpeta datos/ de ejemplo con los CSV que usarán en la demostración.