Módulo 24: Proyecto final
Objetivos del módulo
- Integrar en una sola aplicación todo lo aprendido en el curso: Java moderno, JPA e Hibernate, Spring Boot, Spring Data, API REST, Thymeleaf, Spring Security, pruebas y Docker
- Analizar unos requisitos y convertirlos en un plan de trabajo por iteraciones
- Diseñar la arquitectura en capas y el modelo de datos sobre la base de datos EMPRESA
- Implementar un servicio transaccional de alta de pedidos que valida y descuenta stock, y su cancelación
- Escribir consultas de informes con JPQL y proyecciones (records): ventas por mes, por cliente y productos más vendidos
- Ofrecer la misma funcionalidad por la web (con login por roles) y por una API REST documentada y protegida con JWT
- Probar la aplicación en todos los niveles y desplegarla con Docker Compose
- Revisar la calidad con una lista de comprobación y evaluarla con una rúbrica
1. El proyecto: Gestión comercial de EMPRESA
A lo largo de los módulos 18 a 23 has construido empresa-app pieza a pieza: el proyecto Spring Boot (18), los repositorios (19), la API REST (20), la web con Thymeleaf (21), la seguridad (22) y las pruebas y el despliegue (23). En este proyecto final la convertimos en una aplicación completa y coherente para el departamento comercial de EMPRESA S.A.: gestionar el catálogo y el stock, los clientes y los pedidos, y consultar informes de ventas.
📘 Concepto: Un proyecto real no se escribe «de arriba abajo» de una vez. Se parte de unos requisitos, se diseña una arquitectura y se avanza en iteraciones cortas: cada una añade una funcionalidad completa (de la base de datos a la pantalla, con sus pruebas) que se puede enseñar y probar. Así trabajaremos aquí.
El módulo está escrito como una guía: se explican las decisiones y se muestra el código de las piezas clave, ejecutado y comprobado. El resto (plantillas completas, pruebas de cada clase) sigue los patrones de los módulos anteriores, y una buena parte queda para que la completes tú en los ejercicios y en las propuestas de ampliación.
Requisitos funcionales
| Código | Requisito | Quién |
|---|---|---|
| RF1 | Iniciar y cerrar sesión con usuario y contraseña; la API se usa con un token JWT | Todos |
| RF2 | Panel de inicio con lo facturado en el año y en el mes, los pedidos pendientes y los productos con poco stock | Empleados |
| RF3 | Catálogo de productos por categoría con su precio y stock | Empleados |
| RF4 | Reponer stock de un producto (entrada de mercancía) | ADMIN |
| RF5 | Clientes: listado con búsqueda por nombre o ciudad y ficha con su historial de pedidos | Empleados |
| RF6 | Pedidos: listado filtrable por estado con el total de cada pedido, y detalle con sus líneas e importes | Empleados |
| RF7 | Alta de pedido con varias líneas; el pedido queda a nombre del empleado conectado | Empleados |
| RF8 | Al dar de alta un pedido se comprueba el stock y se descuenta; si una línea falla, no se guarda nada | Sistema |
| RF9 | Cambiar el estado de un pedido (PENDIENTE → ENVIADO → ENTREGADO, o CANCELADO); al cancelar, el stock vuelve al almacén | ADMIN |
| RF10 | Informes: ventas de los últimos 12 meses, productos más vendidos y facturación por cliente | ADMIN |
| RF11 | Panel de empleados y departamentos (CRUD de los módulos 21 y 22) y gestión de usuarios | ADMIN |
| RF12 | Toda la funcionalidad de consulta y alta de pedidos, y los informes, disponibles en una API REST documentada con OpenAPI | Clientes de la API |
Requisitos no funcionales
| Código | Requisito | Cómo se cumple |
|---|---|---|
| RNF1 | Seguridad: contraseñas cifradas, autorización por rol, CSRF en la web, API sin estado | Spring Security 7 (módulo 22) |
| RNF2 | Integridad: operaciones de varias tablas, todo o nada | @Transactional en los servicios |
| RNF3 | Rendimiento: sin consultas N+1 en los listados | @EntityGraph y consultas con proyecciones |
| RNF4 | Calidad: pruebas automáticas en todos los niveles y cobertura medida | JUnit 6, Mockito, MockMvcTester, Testcontainers, JaCoCo (módulo 23) |
| RNF5 | Portabilidad: se ejecuta igual en cualquier equipo con Docker | Dockerfile multietapa y Docker Compose |
| RNF6 | Configuración por entorno, sin secretos en el código | Perfiles dev, h2 y prod; variables de entorno |
| RNF7 | Usabilidad: interfaz en español (e inglés), adaptable al móvil | Thymeleaf, messages.properties, Bootstrap 5.3 |
2. Arquitectura
La aplicación sigue la arquitectura en capas que has usado desde el módulo 18, con dos puertas de entrada (la web y la API) que comparten los mismos servicios:
Figura 1. Arquitectura en capas de la aplicación.
| Capa / paquete | Responsabilidad | No debe… |
|---|---|---|
web.mvc | Recibir peticiones del navegador, preparar el Model, elegir la vista, PRG y mensajes flash | Contener reglas de negocio ni acceder a repositorios |
web.api | Recibir JSON, validar, devolver DTO y códigos HTTP | Exponer entidades JPA |
servicio | Reglas de negocio, transacciones, seguridad por método | Saber si la llamada viene de la web o de la API |
repositorio | Consultas a la base de datos | Contener lógica de negocio |
modelo | Entidades JPA: las tablas de EMPRESA | Depender de Spring MVC |
dto | Records de entrada y salida, formularios, proyecciones | — |
seguridad, config | Configuración transversal | — |
La estructura final del proyecto (lo nuevo de este módulo, marcado con *):
empresa-app/
├── Dockerfile · docker-compose.yml · .env (no se sube) · .github/workflows/ci.yml
├── docker/mysql/ 01-empresa-crear.sql · 02-empresa-datos.sql · 03-seguridad.sql
└── src/
├── main/java/es/curso/empresa/
│ ├── config/ EmpresaProperties, WebConfig, OpenApiConfig, RelojConfig*
│ ├── modelo/ Empleado, Departamento, Cliente, Producto, Categoria, Pedido, LineaPedido, Usuario, Rol
│ ├── repositorio/ ...Repository (PedidoRepository con las consultas de informes*)
│ ├── dto/ PedidoListado*, VentaMensual*, ProductoVendido*, PedidoForm*, ...
│ ├── servicio/ PedidoService, ProductoService, ClienteService, InformeService*, ...
│ ├── seguridad/ SeguridadConfig, JwtConfig, TokenService, UsuarioDetailsService, ...
│ └── web/
│ ├── api/ AuthApiController, PedidoApiController, InformeApiController*, ...
│ └── mvc/ InicioController, PedidoController*, ClienteController*, InformeController*, ...
├── main/resources/
│ ├── application.properties · application-{dev,h2,prod}.properties · messages*.properties
│ ├── db/h2/ · db/seguridad/
│ └── templates/ fragmentos/ · pedidos/* · clientes/* · informes/* · inicio.html ...
└── test/java/es/curso/empresa/ pruebas unitarias, slices e integración (sección 10)
Tecnologías y versiones
| Pieza | Versión |
|---|---|
| Java | 25 LTS |
| Spring Boot | 4.1.1 (Spring Framework 7.0.9, Spring Security 7.1.1, Spring Data 2026) |
| Persistencia | Hibernate ORM 7.4.5 (Jakarta Persistence 3.2), HikariCP, MySQL Connector/J 9.7.0 |
| Base de datos | MySQL 8.4 LTS (H2 2.4 para desarrollo y pruebas) |
| Web | Thymeleaf 3.1 + thymeleaf-extras-springsecurity6, Bootstrap 5.3.8, Bootstrap Icons 1.13 |
| API | Jackson 3, springdoc-openapi 3.1.1 (Swagger UI) |
| Pruebas | JUnit 6.0.3, Mockito 5.23, AssertJ 3.27, Testcontainers 2.0.5, JaCoCo 0.8.15 |
| Despliegue | Docker (Eclipse Temurin 25 JRE), Docker Compose, GitHub Actions |
3. Modelo de datos
El proyecto no necesita tablas nuevas: todo cabe en el esquema EMPRESA del Curso de Bases de Datos (más las tablas de usuarios y roles del módulo 22). Es una situación muy habitual: la aplicación se adapta a una base de datos que ya existe.
Figura 2. Entidades JPA sobre las tablas de EMPRESA.
| Tabla | Entidad | Detalles de mapeo (módulos 14-16 y 19) |
|---|---|---|
departamentos, empleados | Departamento, Empleado | Empleado.jefe es una relación reflexiva @ManyToOne |
clientes | Cliente | comercial → Empleado |
categorias, productos | Categoria, Producto | Jerarquía de categorías; descatalogado con un AttributeConverter 'S'/'N'; fechaAlta con auditoría |
pedidos, lineas_pedido | Pedido, LineaPedido | Composición: cascade = ALL y orphanRemoval; clave compuesta @EmbeddedId + @MapsId; estado como enum |
usuarios, roles, usuarios_roles | Usuario, Rol | @OneToOne con Empleado, @ManyToMany con Rol |
Dos métodos del modelo concentran el cálculo de importes, y los usan tanto la web como la API:
/** cantidad × precio × (1 − descuento/100), redondeado a céntimos. */
public BigDecimal getImporte() { // LineaPedido
BigDecimal bruto = precioUnitario.multiply(BigDecimal.valueOf(cantidad));
BigDecimal factor = BigDecimal.ONE.subtract(descuento.movePointLeft(2));
return bruto.multiply(factor).setScale(2, RoundingMode.HALF_UP);
}
/** Importe total del pedido aplicando el descuento de cada línea. */
public BigDecimal getTotal() { // Pedido
return lineas.stream().map(LineaPedido::getImporte).reduce(BigDecimal.ZERO, BigDecimal::add);
}
⚠️ Importante: El precio se copia a la línea (
precioUnitario) al crear el pedido. Si mañana sube el precio del producto, los pedidos antiguos no cambian. Es una regla de negocio clásica que el esquema EMPRESA ya recoge.
4. Plan de trabajo
| Iteración | Contenido | Requisitos |
|---|---|---|
| 0 | Punto de partida: empresa-app de los módulos 18-23 (CRUD, API, seguridad, pruebas, Docker) | RF1, RF11 |
| 1 | Catálogo y stock: aviso de stock bajo y reposición | RF3, RF4 |
| 2 | Clientes: búsqueda y ficha con historial | RF5 |
| 3 | Pedidos: listado con totales, alta transaccional con varias líneas, estados y cancelación | RF6, RF7, RF8, RF9 |
| 4 | Panel de inicio e informes, en la web y en la API | RF2, RF10, RF12 |
| 5 | Pruebas de lo nuevo, despliegue con Docker Compose y revisión de calidad | RNF1-RNF7 |
💡 Consejo: Al terminar cada iteración, ejecuta
mvn verifyy haz un commit. Si algo se rompe en la iteración siguiente, sabrás exactamente qué cambio lo provocó.
5. Iteración 1: catálogo y stock
El catálogo por categorías ya existía (ejercicio 2 del módulo 21). Añadimos dos funciones al servicio:
/** Productos a la venta con menos unidades que el mínimo (proyecto final). */
public List<Producto> stockBajo(int minimo) {
return productos.findByStockLessThanAndDescatalogadoFalse(minimo);
}
/** Entrada de mercancía: suma unidades al stock (solo ADMIN). */
@Transactional
@PreAuthorize("hasRole('ADMIN')")
public Producto reponer(Integer id, int unidades) {
if (unidades <= 0) {
throw new ReglaNegocioException("Las unidades a reponer deben ser positivas");
}
Producto p = buscar(id);
p.setStock(p.getStock() + unidades);
return p; // dirty checking: el UPDATE lo hace Hibernate al confirmar
}
/** Productos que se pueden vender ahora: no descatalogados y con stock (formulario de pedidos). */
public List<Producto> aLaVenta() {
return productos.findByDescatalogadoFalseAndStockGreaterThanOrderByNombre(0);
}
findByStockLessThanAndDescatalogadoFalse es la consulta derivada del módulo 19, y findByDescatalogadoFalseAndStockGreaterThanOrderByNombre una nueva del mismo estilo. El controlador recibe el formulario de reposición con el patrón PRG:
// Proyecto final: entrada de mercancía. Solo ADMIN (regla de URL y @PreAuthorize en el servicio)
@PostMapping("/{id}/stock")
public String reponer(@PathVariable Integer id, @RequestParam int unidades, RedirectAttributes flash) {
try {
Producto p = productos.reponer(id, unidades);
flash.addFlashAttribute("exito", "Stock de " + p.getNombre() + ": " + p.getStock() + " unidades");
} catch (ReglaNegocioException ex) {
flash.addFlashAttribute("fallo", ex.getMessage());
}
return "redirect:/productos";
}
Y en cada tarjeta del catálogo, solo para ADMIN y solo si el producto se vende:
<!-- Proyecto final: reponer stock (solo ADMIN) -->
<form sec:authorize="hasRole('ADMIN')" th:unless="${p.descatalogado}"
th:action="@{/productos/{id}/stock(id=${p.id})}" method="post" class="card-footer d-flex gap-2 bg-white">
<input type="number" name="unidades" min="1" value="10" class="form-control form-control-sm" style="width: 90px">
<button class="btn btn-sm btn-outline-primary"><i class="bi bi-box-arrow-in-down"></i> Reponer</button>
</form>
Figura 3. Catálogo con la reposición de stock para ADMIN. Captura real.
6. Iteración 2: clientes
La búsqueda combina dos criterios con Or en una consulta derivada, y trae al comercial en la misma consulta:
// Proyecto final (módulo 24): búsqueda de clientes por nombre o ciudad
@EntityGraph(attributePaths = "comercial")
Page<Cliente> findByNombreContainingIgnoreCaseOrCiudadContainingIgnoreCase(String nombre, String ciudad,
Pageable pageable);
/** Búsqueda por nombre o ciudad (proyecto final). */
public Page<Cliente> buscar(String texto, Pageable pageable) {
return texto == null || texto.isBlank() ? clientes.findAll(pageable)
: clientes.findByNombreContainingIgnoreCaseOrCiudadContainingIgnoreCase(texto, texto, pageable);
}
/** Clientes en la web: listado con búsqueda y ficha con su historial de pedidos. */
@Controller
@RequestMapping("/clientes")
public class ClienteController {
private final ClienteService clientes;
private final PedidoService pedidos;
public ClienteController(ClienteService clientes, PedidoService pedidos) {
this.clientes = clientes;
this.pedidos = pedidos;
}
@GetMapping
public String listar(@RequestParam(required = false) String q,
@PageableDefault(size = 10, sort = "nombre") Pageable pageable, Model model) {
model.addAttribute("pagina", clientes.buscar(q, pageable));
model.addAttribute("q", q);
return "clientes/lista";
}
@GetMapping("/{id}")
public String detalle(@PathVariable Integer id, Model model) {
model.addAttribute("cliente", clientes.buscar(id));
model.addAttribute("pedidos", pedidos.listado(null, id,
Pageable.ofSize(50).withPage(0)).getContent());
return "clientes/detalle";
}
}
Figura 4. Listado de clientes con el fragmento de paginación del módulo 21. Captura real.
La ficha reutiliza el listado de pedidos con totales de la iteración 3 (filtrado por cliente) y suma lo facturado en la propia plantilla con la selección y la proyección de SpEL (módulo 21):
<td class="text-end"
th:text="${#numbers.formatDecimal(#aggregates.sum(pedidos.?[estado.name() != 'CANCELADO'].![total]), 1, 'DEFAULT', 2, 'DEFAULT') + ' €'}">0 €</td>
Figura 5. Ficha de cliente con su historial y el total facturado. Captura real.
7. Iteración 3: pedidos
Un listado con totales en una sola consulta
El listado debe mostrar el total de cada pedido. Si cargáramos los pedidos y llamáramos a getTotal(), Hibernate tendría que traer las líneas de cada uno: el problema N+1 del módulo 16. Mejor que la base de datos sume, con una consulta JPQL que construye directamente un record (proyección con select new):
/** Una fila del listado de pedidos con su total, calculado en la propia consulta. Proyecto final. */
public record PedidoListado(Integer id, LocalDate fechaPedido, EstadoPedido estado,
Integer clienteId, String cliente, Long lineas, BigDecimal total) {
public PedidoListado {
total = total.setScale(2, RoundingMode.HALF_UP); // redondeo a céntimos
}
}
/** Listado de pedidos con su total, calculado por la base de datos en UNA consulta. */
@Query(value = """
select new es.curso.empresa.dto.PedidoListado(
p.id, p.fechaPedido, p.estado, c.id, c.nombre, count(l),
coalesce(sum(l.cantidad * l.precioUnitario * (1 - l.descuento / 100)), 0))
from Pedido p join p.cliente c left join p.lineas l
where (:estado is null or p.estado = :estado)
and (:clienteId is null or c.id = :clienteId)
group by p.id, p.fechaPedido, p.estado, c.id, c.nombre""",
countQuery = """
select count(p) from Pedido p
where (:estado is null or p.estado = :estado)
and (:clienteId is null or p.cliente.id = :clienteId)""")
Page<PedidoListado> listado(@Param("estado") EstadoPedido estado, @Param("clienteId") Integer clienteId,
Pageable pageable);
| Detalle | Por qué |
|---|---|
select new es.curso.empresa.dto.PedidoListado(...) | Proyección: Hibernate llama al constructor del record con cada fila (nombre completo de la clase) |
left join p.lineas l y coalesce(..., 0) | Un pedido sin líneas también sale, con total 0 |
(:estado is null or p.estado = :estado) | Filtros opcionales en una sola consulta |
countQuery | Con group by, Spring Data no sabe derivar la consulta de recuento de la página: se la damos |
Pageable con Sort | Spring Data añade el order by y el limit (sort = "fechaPedido" → order by p.fechaPedido) |
Constructor compacto con setScale(2) | La multiplicación en la base de datos devuelve 1497.00000000: redondeamos a céntimos |
💡 Consejo: Nuestra primera versión no tenía el constructor compacto, y la API devolvía
"total":358.80000000. Los productos de tresDECIMALcon escalas 2, 2 y 2 dan escala 8 en MySQL. Redondear en el record lo arregla en un único sitio para la web y para la API.
Figura 6. Listado de pedidos con el total calculado en la consulta. Captura real.
El estado se pinta con un pequeño fragmento reutilizable (fragmentos/estado.html):
<!--/* Insignia de color según el estado del pedido */-->
<span th:fragment="estado(e)" class="badge"
th:classappend="${e.name() == 'PENDIENTE'} ? 'text-bg-warning' : (${e.name() == 'ENVIADO'} ? 'text-bg-info' : (${e.name() == 'ENTREGADO'} ? 'text-bg-success' : 'text-bg-secondary'))"
th:text="${e}">PENDIENTE</span>
El detalle usa PedidoService.buscar, que carga pedido, cliente, empleado, líneas y productos con un único @EntityGraph (módulo 20):
Figura 7. Detalle de un pedido con importes por línea. Captura real.
El alta de un pedido: el corazón de la aplicación
PedidoService.crear existía desde el módulo 20 (la API ya creaba pedidos). Es el mejor ejemplo del curso de por qué existen las transacciones:
Figura 8. Alta de un pedido: todo o nada.
/**
* Crea un pedido y descuenta el stock de cada producto.
* Si cualquier línea falla (producto inexistente, sin stock...), la excepción
* provoca el rollback de TODA la transacción: ni pedido ni cambios de stock.
*/
@Transactional
public Pedido crear(NuevoPedidoRequest req) {
Cliente cliente = clientes.findById(req.clienteId())
.orElseThrow(() -> new RecursoNoEncontradoException("cliente", req.clienteId()));
Empleado empleado = req.empleadoId() == null ? null
: empleados.findById(req.empleadoId())
.orElseThrow(() -> new RecursoNoEncontradoException("empleado", req.empleadoId()));
Pedido pedido = new Pedido(cliente, empleado);
for (NuevoPedidoRequest.Linea l : req.lineas()) {
Producto producto = productos.findById(l.productoId())
.orElseThrow(() -> new RecursoNoEncontradoException("producto", l.productoId()));
if (producto.isDescatalogado()) {
throw new ReglaNegocioException("El producto " + producto.getNombre() + " está descatalogado");
}
if (producto.getStock() < l.cantidad()) {
throw new ReglaNegocioException("Stock insuficiente de " + producto.getNombre()
+ ": hay " + producto.getStock() + " y se piden " + l.cantidad());
}
producto.setStock(producto.getStock() - l.cantidad()); // dirty checking → UPDATE
pedido.anadirLinea(producto, l.cantidad(), l.descuento());
}
return pedidos.save(pedido); // cascade ALL: inserta también las líneas
}
📘 Concepto: Fíjate en que no hay ningún
productos.save(producto). El producto está gestionado por elEntityManagerde la transacción (módulo 14): al confirmarla, Hibernate detecta el cambio destock(dirty checking) y ejecuta elUPDATE. Y comoReglaNegocioExceptiones una excepción no comprobada (RuntimeException),@Transactionalhace rollback automáticamente: el stock descontado de las líneas anteriores se deshace.
El formulario web con varias líneas
La API recibe un NuevoPedidoRequest en JSON. La web necesita un objeto de formulario con una lista de líneas que Thymeleaf pueda enlazar con th:field. Lo más sencillo y robusto, sin JavaScript, es un número fijo de filas (cinco) en las que las vacías se ignoran:
/**
* Formulario web de alta de pedido: un cliente y hasta 5 líneas.
* Las líneas sin producto se ignoran. Se convierte en el mismo NuevoPedidoRequest de la API.
*/
public class PedidoForm {
public static final int LINEAS = 5;
@NotNull(message = "Elige un cliente")
private Integer clienteId;
@Valid
private List<LineaForm> lineas = new ArrayList<>();
public PedidoForm() {
for (int i = 0; i < LINEAS; i++) {
lineas.add(new LineaForm());
}
}
/** Validación entre campos: al menos una línea con producto. */
@AssertTrue(message = "El pedido debe tener al menos una línea con producto")
public boolean isAlgunaLinea() {
return lineas.stream().anyMatch(l -> l.getProductoId() != null);
}
/** Traduce el formulario a la petición que entiende PedidoService. */
public NuevoPedidoRequest aPeticion(Integer empleadoId) {
List<NuevoPedidoRequest.Linea> utiles = lineas.stream()
.filter(l -> l.getProductoId() != null)
.map(l -> new NuevoPedidoRequest.Linea(l.getProductoId(),
l.getCantidad() == null ? 1 : l.getCantidad(), l.getDescuento()))
.toList();
return new NuevoPedidoRequest(clienteId, empleadoId, utiles);
}
// getters y setters
/** Una fila del formulario. */
public static class LineaForm {
private Integer productoId;
@Positive(message = "Cantidad > 0")
private Integer cantidad = 1;
@DecimalMin(value = "0", message = "Mínimo 0 %")
@DecimalMax(value = "50", message = "Máximo 50 %")
private BigDecimal descuento;
// getters y setters
}
}
En la plantilla, cada fila se enlaza con un índice. La sintaxis __${...}__ es el preprocesamiento de Thymeleaf: evalúa primero la expresión interior y la pega en la exterior, de modo que *{lineas[__${i.index}__].productoId} se convierte en *{lineas[0].productoId}, *{lineas[1].productoId}…
<!-- Lista indexada: lineas[0].productoId, lineas[1].productoId... -->
<tr th:each="linea, i : *{lineas}">
<td>
<select class="form-select" th:field="*{lineas[__${i.index}__].productoId}">
<option value="">—</option>
<option th:each="p : ${productos}" th:value="${p.id}"
th:text="|${p.nombre} · ${#numbers.formatDecimal(p.precio, 1, 'DEFAULT', 2, 'DEFAULT')} € (${p.stock} uds.)|">Monitor</option>
</select>
</td>
<td>
<input type="number" min="1" class="form-control" th:field="*{lineas[__${i.index}__].cantidad}"
th:errorclass="is-invalid">
<div class="invalid-feedback" th:errors="*{lineas[__${i.index}__].cantidad}">Error</div>
</td>
<td>
<input type="number" min="0" max="50" step="0.5" class="form-control"
th:field="*{lineas[__${i.index}__].descuento}" th:errorclass="is-invalid">
<div class="invalid-feedback" th:errors="*{lineas[__${i.index}__].descuento}">Error</div>
</td>
</tr>
Spring MVC hace el camino inverso al recibir el formulario: los parámetros lineas[0].productoId=9, lineas[0].cantidad=3… rellenan la lista del PedidoForm. El controlador:
@PostMapping("/guardar")
public String guardar(@Valid @ModelAttribute("pedido") PedidoForm form, BindingResult errores,
@AuthenticationPrincipal UsuarioAutenticado usuario,
Model model, RedirectAttributes flash) {
if (errores.hasErrors()) {
return prepararFormulario(model);
}
Pedido p;
try {
// El pedido queda a nombre del empleado que ha iniciado sesión
p = pedidos.crear(form.aPeticion(usuario.empleadoId()));
} catch (ReglaNegocioException | RecursoNoEncontradoException ex) {
errores.reject("negocio", ex.getMessage()); // p. ej. stock insuficiente
return prepararFormulario(model);
}
flash.addFlashAttribute("exito", "Pedido " + p.getId() + " registrado");
return "redirect:/pedidos/" + p.getId();
}
private String prepararFormulario(Model model) {
model.addAttribute("clientes", clientes.listarTodos());
model.addAttribute("productos", productos.aLaVenta());
return "pedidos/formulario";
}
Las piezas de todo el curso encajan aquí: Bean Validation (módulo 20), BindingResult y PRG (21), el principal con @AuthenticationPrincipal (22) y el mismo servicio transaccional que usa la API (20). Veamos un caso real: Carmen prepara un pedido de Academia Nova con 3 monitores al 5 % y 6 torres Workstation (en el desplegable se ve que solo quedan 4):
Figura 9. Formulario de alta de un pedido con varias líneas. Captura real.
Al registrarlo, el servicio detecta la falta de stock en la segunda línea, después de haber descontado ya los 3 monitores. La excepción deshace la transacción y el formulario vuelve con el error y los datos escritos:
Figura 10. El servicio rechaza el pedido: los 35 monitores siguen en el almacén. Captura real.
Carmen corrige la cantidad a 1 y lo registra. El pedido 5025 queda a su nombre y el stock de monitores baja a 32 (y el de torres, a 3):
Figura 11. Pedido registrado tras el patrón PRG. Captura real.
Cambiar el estado y cancelar
Solo ADMIN puede cambiar el estado (regla POST /pedidos/*/estado y @PreAuthorize en el servicio). Una regla nueva del proyecto: al cancelar, las unidades vuelven al almacén.
@Transactional
@PreAuthorize("hasRole('ADMIN')")
public Pedido cambiarEstado(Integer id, EstadoPedido nuevo) {
Pedido p = buscar(id);
if (p.getEstado() == EstadoPedido.ENTREGADO || p.getEstado() == EstadoPedido.CANCELADO) {
throw new ReglaNegocioException("El pedido " + id + " ya está " + p.getEstado());
}
if (nuevo == EstadoPedido.ENVIADO) {
p.setFechaEnvio(LocalDate.now());
}
if (nuevo == EstadoPedido.CANCELADO) {
// Proyecto final: al cancelar, las unidades vuelven al almacén
p.getLineas().forEach(l -> l.getProducto().setStock(l.getProducto().getStock() + l.getCantidad()));
}
p.setEstado(nuevo);
return p;
}
En el detalle, Ana ve una tarjeta para cambiar el estado (solo si el pedido sigue abierto):
<!-- Solo ADMIN y solo si el pedido sigue abierto -->
<div class="card" sec:authorize="hasRole('ADMIN')"
th:if="*{estado.name() == 'PENDIENTE' or estado.name() == 'ENVIADO'}">
<div class="card-body">
<form th:action="@{/pedidos/{id}/estado(id=*{id})}" method="post" class="d-flex gap-2">
<select name="estado" class="form-select">
<option th:each="e : ${estados}" th:value="${e}" th:text="${e}"
th:selected="${e == pedido.estado}">ENVIADO</option>
</select>
<button class="btn btn-primary">Cambiar</button>
</form>
<p class="small text-secondary mt-2 mb-0">Al cancelar, el stock vuelve al almacén.</p>
</div>
</div>
Figura 12. Cambio de estado, solo para ADMIN. Captura real.
Figura 13. Pedido cancelado. Captura real.
Comprobado con la API antes y después de cancelar (salida real):
$ curl -s http://localhost:8080/api/productos/9 | grep -o '"stock":[0-9]*' # Monitor 24 FHD
"stock":32
$ curl -s http://localhost:8080/api/productos/5 | grep -o '"stock":[0-9]*' # Torre Workstation
"stock":3
... Ana cancela el pedido 5025 desde la web ...
"stock":35
"stock":4
8. Iteración 4: panel de inicio e informes
Consultas de informes con JPQL
Tres records para los resultados (con el mismo redondeo que PedidoListado):
public record VentaMensual(Integer anio, Integer mes, Long pedidos, BigDecimal total) { ... }
public record ProductoVendido(Integer id, String nombre, Long unidades, BigDecimal importe) { ... }
// y FacturacionCliente(String cliente, Long pedidos, BigDecimal total), del ejercicio 3 del módulo 19
Y las consultas en PedidoRepository:
/** Ventas por mes desde una fecha (EXTRACT es JPQL estándar desde Jakarta Persistence 3.2). */
@Query("""
select new es.curso.empresa.dto.VentaMensual(
extract(year from p.fechaPedido), extract(month from p.fechaPedido), count(distinct p),
sum(l.cantidad * l.precioUnitario * (1 - l.descuento / 100)))
from LineaPedido l join l.pedido p
where p.estado <> es.curso.empresa.modelo.EstadoPedido.CANCELADO and p.fechaPedido >= :desde
group by extract(year from p.fechaPedido), extract(month from p.fechaPedido)
order by extract(year from p.fechaPedido), extract(month from p.fechaPedido)""")
List<VentaMensual> ventasMensuales(@Param("desde") LocalDate desde);
/** Productos más vendidos (por unidades). El tamaño de la página decide cuántos. */
@Query("""
select new es.curso.empresa.dto.ProductoVendido(
pr.id, pr.nombre, sum(l.cantidad), sum(l.cantidad * l.precioUnitario * (1 - l.descuento / 100)))
from LineaPedido l join l.producto pr join l.pedido p
where p.estado <> es.curso.empresa.modelo.EstadoPedido.CANCELADO
group by pr.id, pr.nombre
order by sum(l.cantidad) desc, pr.nombre""")
List<ProductoVendido> masVendidos(Pageable pageable);
long countByEstado(EstadoPedido estado);
/** Importe facturado en un intervalo de fechas (para el panel de inicio). */
@Query("""
select coalesce(sum(l.cantidad * l.precioUnitario * (1 - l.descuento / 100)), 0)
from LineaPedido l join l.pedido p
where p.estado <> es.curso.empresa.modelo.EstadoPedido.CANCELADO
and p.fechaPedido between :desde and :hasta""")
BigDecimal facturado(@Param("desde") LocalDate desde, @Param("hasta") LocalDate hasta);
| Técnica | Dónde |
|---|---|
extract(year from ...) | Función estándar de JPQL 3.2, portable entre MySQL y H2 (las funciones year()/month() son extensiones de Hibernate) |
| Constante de enum en JPQL | es.curso.empresa.modelo.EstadoPedido.CANCELADO, con el nombre completo |
List<...> masVendidos(Pageable) | Un Pageable en una consulta que devuelve List solo aplica el limit: PageRequest.of(0, 5) = los 5 primeros |
| Consulta con valor único | facturado devuelve un BigDecimal |
Resultados reales sobre los datos de EMPRESA (los imprime la prueba InformesRepositoryTest, sección 10):
ProductoVendido[id=9, nombre=Monitor 24 FHD, unidades=40, importe=4934.50]
ProductoVendido[id=8, nombre=Pack teclado y ratón, unidades=30, importe=897.00]
ProductoVendido[id=3, nombre=Portátil Básico 15, unidades=24, importe=10479.00]
VentaMensual[anio=2026, mes=1, pedidos=1, total=8945.05]
VentaMensual[anio=2026, mes=2, pedidos=1, total=838.00]
VentaMensual[anio=2026, mes=3, pedidos=1, total=2085.25]
VentaMensual[anio=2026, mes=4, pedidos=1, total=1296.00]
VentaMensual[anio=2026, mes=5, pedidos=1, total=1663.64]
VentaMensual[anio=2026, mes=6, pedidos=1, total=2462.05]
VentaMensual[anio=2026, mes=7, pedidos=3, total=3414.80]
El servicio de informes y el reloj
«Las ventas de los últimos 12 meses» o «lo facturado este mes» dependen de hoy. Si el servicio llamara a LocalDate.now(), las pruebas darían resultados distintos cada día. La solución profesional es inyectar un java.time.Clock como bean (y en las pruebas, pasarle uno fijo):
/** El "reloj" de la aplicación como bean: en las pruebas se sustituye por uno fijo. */
@Configuration
public class RelojConfig {
@Bean
public Clock reloj() {
return Clock.systemDefaultZone();
}
}
/** Informes comerciales del proyecto final. Solo lectura. */
@Service
@Transactional(readOnly = true)
public class InformeService {
/** Datos del panel de inicio. */
public record Resumen(BigDecimal facturadoMes, BigDecimal facturadoAnio, long pendientes) { }
private final PedidoRepository pedidos;
private final Clock reloj;
public InformeService(PedidoRepository pedidos, Clock reloj) {
this.pedidos = pedidos;
this.reloj = reloj;
}
public List<VentaMensual> ventasMensuales(int meses) {
LocalDate desde = YearMonth.now(reloj).minusMonths(meses - 1L).atDay(1);
return pedidos.ventasMensuales(desde);
}
public List<ProductoVendido> masVendidos(int cuantos) {
return pedidos.masVendidos(PageRequest.of(0, cuantos));
}
public List<FacturacionCliente> facturacionPorCliente() {
return pedidos.facturacionPorCliente();
}
public Resumen resumen() {
LocalDate hoy = LocalDate.now(reloj);
return new Resumen(
pedidos.facturado(hoy.withDayOfMonth(1), hoy),
pedidos.facturado(hoy.withDayOfYear(1), hoy),
pedidos.countByEstado(EstadoPedido.PENDIENTE));
}
}
El panel de inicio
InicioController distingue al usuario anónimo (solo la bienvenida) del autenticado (indicadores):
@GetMapping("/")
public String inicio(Authentication auth, Model model) {
model.addAttribute("empresa", props.nombre());
if (auth != null && auth.isAuthenticated()) { // los anónimos solo ven la bienvenida
model.addAttribute("resumen", informes.resumen());
model.addAttribute("stockBajo", productos.stockBajo(10));
model.addAttribute("numClientes", clientes.listar(Pageable.ofSize(1)).getTotalElements());
model.addAttribute("numProductos", productos.listar(null, null, Pageable.ofSize(1)).getTotalElements());
model.addAttribute("numEmpleados", empleados.listar(Pageable.ofSize(1)).getTotalElements());
model.addAttribute("numDepartamentos", departamentos.listar().size());
}
return "inicio";
}
💡 Consejo:
Pageable.ofSize(1)congetTotalElements()es un truco para contar: pide una página de un solo elemento y lee el total que Spring Data calcula concount(*). Para un contador puro, un métodocount()del repositorio es aún más directo.
Figura 14. Panel de inicio con los indicadores comerciales. Captura real.
(«Este mes» es 0 € porque los datos de EMPRESA llegan hasta julio de 2026 y la captura se tomó en octubre.)
La página de informes
Las barras del informe de ventas no necesitan ninguna librería de gráficos: son barras de progreso de Bootstrap cuyo ancho se calcula con BigDecimal en la plantilla, proporcional al mes de más ventas:
<div class="progress" style="height: 20px">
<div class="progress-bar barra-ventas"
th:style="|width: ${v.total.multiply(100).divide(maximo, 0, T(java.math.RoundingMode).HALF_UP)}%|"></div>
</div>
T(java.math.RoundingMode) es la forma de SpEL de referirse a una clase (para usar su constante HALF_UP), y |...| es la sustitución literal de Thymeleaf, que mezcla texto y expresiones.
Figura 15. Informes comerciales (solo ADMIN). Captura real.
Los informes en la API
/** Informes comerciales en JSON (solo ADMIN: regla /api/informes/** en SeguridadConfig). */
@RestController
@RequestMapping("/api/informes")
@Tag(name = "Informes", description = "Ventas por mes, por cliente y productos más vendidos (ADMIN)")
public class InformeApiController {
private final InformeService informes;
public InformeApiController(InformeService informes) {
this.informes = informes;
}
@GetMapping("/ventas-mensuales")
@Operation(summary = "Ventas de los últimos meses (sin pedidos cancelados)")
public List<VentaMensual> ventasMensuales(
@Parameter(description = "Número de meses", example = "12")
@RequestParam(defaultValue = "12") @Min(1) @Max(36) int meses) {
return informes.ventasMensuales(meses);
}
@GetMapping("/productos-mas-vendidos")
@Operation(summary = "Ranking de productos por unidades vendidas")
public List<ProductoVendido> masVendidos(@RequestParam(defaultValue = "5") @Min(1) @Max(20) int top) {
return informes.masVendidos(top);
}
@GetMapping("/clientes")
@Operation(summary = "Facturación por cliente, de mayor a menor")
public List<FacturacionCliente> facturacionPorCliente() {
return informes.facturacionPorCliente();
}
}
📘 Concepto: Las restricciones
@Miny@Maxdirectamente en un@RequestParamfuncionan sin@Validateden la clase: desde Spring Framework 6.1, los controladores aplican la validación de métodos integrada cuando un parámetro tiene restricciones, y un valor fuera de rango produce un 400 (HandlerMethodValidationException).
Peticiones reales con el token de Ana y con el de Carmen (la primera, la versión anterior al redondeo a céntimos):
$ curl -s "http://localhost:8080/api/informes/ventas-mensuales?meses=12" -H "Authorization: Bearer $TOKEN_ANA"
[{"anio":2025,"mes":11,"pedidos":2,"total":358.80000000},{"anio":2026,"mes":1,"pedidos":1,"total":8945.05000000}, ... ]
$ curl -s "http://localhost:8080/api/informes/productos-mas-vendidos?top=3" -H "Authorization: Bearer $TOKEN_ANA"
[{"id":9,"nombre":"Monitor 24 FHD","unidades":40,"importe":4934.50},{"id":8,"nombre":"Pack teclado y ratón","unidades":30,"importe":897.00},{"id":3,"nombre":"Portátil Básico 15","unidades":24,"importe":10479.00}]
$ curl -i "http://localhost:8080/api/informes/clientes" -H "Authorization: Bearer $TOKEN_CARMEN"
HTTP/1.1 403
{"detail":"No tienes permiso para realizar esta operación","instance":"/api/informes/clientes","status":403,"title":"Acceso denegado"}
$ curl -i "http://localhost:8080/api/informes/ventas-mensuales?meses=50" -H "Authorization: Bearer $TOKEN_ANA"
HTTP/1.1 400
{"detail":"Validation failure","instance":"/api/informes/ventas-mensuales","status":400,"title":"Bad Request"}
El 403 de Carmen llega con el ProblemDetail del ejercicio 5 del módulo 22. El 400 es correcto pero poco informativo: el ejercicio 4 lo mejora.
En Swagger UI aparece la nueva sección Informes, con el candado del esquema bearer-jwt:
Figura 16. La API documentada con OpenAPI. Captura real.
Figura 17. Un informe ejecutado desde Swagger UI con autenticación Bearer. Captura real.
9. La seguridad del proyecto
Las reglas nuevas se añaden a las dos cadenas del módulo 22, antes de las reglas generales:
// Cadena de la API
.requestMatchers(HttpMethod.GET, "/api/productos/**", "/api/categorias/**").permitAll()
.requestMatchers("/api/informes/**").hasRole("ADMIN") // proyecto final
.requestMatchers(HttpMethod.GET, "/api/**").hasAnyRole("ADMIN", "EMPLEADO")
// Cadena web
.requestMatchers("/admin/**").hasRole("ADMIN") // ejercicio 2 del módulo 22
.requestMatchers("/informes/**").hasRole("ADMIN") // proyecto final
.requestMatchers(HttpMethod.POST, "/pedidos/*/estado", "/productos/*/stock").hasRole("ADMIN")
En el menú, el enlace Informes solo aparece a ADMIN (sec:authorize="hasRole('ADMIN')"), igual que la entrada Usuarios del menú de usuario:
Figura 18. Gestión de usuarios para ADMIN (ejercicio 2 del módulo 22). Captura real.
Matriz de permisos final
| Funcionalidad | Web | API | Anónimo | EMPLEADO | ADMIN |
|---|---|---|---|---|---|
| Login | /login | POST /api/auth/login | ✅ | ✅ | ✅ |
| Catálogo | /productos | GET /api/productos/** | API ✅ / web ➡️ login | ✅ | ✅ |
| Reponer stock, alta y edición de productos | POST /productos/{id}/stock | POST/PUT/PATCH/DELETE /api/productos | ❌ | ❌ | ✅ |
| Clientes | /clientes/** | GET /api/clientes/** | ❌ | ✅ | ✅ |
| Pedidos: consulta y alta | /pedidos, /pedidos/nuevo | GET /api/pedidos/**, POST /api/pedidos | ❌ | ✅ (a su nombre) | ✅ |
| Cambiar estado / cancelar | POST /pedidos/{id}/estado | PATCH /api/pedidos/{id}/estado | ❌ | ❌ | ✅ |
| Informes | /informes | /api/informes/** | ❌ | ❌ | ✅ |
| Empleados y departamentos: consulta | /empleados, /departamentos | GET /api/empleados (y la ficha propia) | ❌ | ✅ | ✅ |
| Empleados y departamentos: cambios, usuarios | /empleados/nuevo, POST ..., /admin/** | — | ❌ | ❌ | ✅ |
10. Pruebas
El proyecto llega al final con 61 pruebas (60 con Surefire y 1 con Failsafe) en todos los niveles de la pirámide del módulo 23. Las nuevas de este módulo:
| Clase | Tipo | Comprueba |
|---|---|---|
InformesRepositoryTest | @DataJpaTest (H2) | Las cuatro consultas JPQL nuevas con los datos de EMPRESA |
ProyectoFinalServiciosTest | Unitaria (Mockito) | Cancelar devuelve el stock; el resumen usa el reloj inyectado |
PedidoControllerTest | @WebMvcTest | Formulario de pedido: validación, error de stock, empleado conectado, 403 al cambiar estado |
Las consultas de informes
/** Proyecto final: las consultas JPQL de informes y del listado de pedidos, sobre los datos de EMPRESA. */
@DataJpaTest(properties =
"spring.datasource.url=jdbc:h2:mem:informes;MODE=MySQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH")
@ActiveProfiles("h2")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class InformesRepositoryTest {
@Autowired PedidoRepository pedidos;
@Test
void productosMasVendidos() {
List<ProductoVendido> top = pedidos.masVendidos(PageRequest.of(0, 3));
top.forEach(System.out::println);
assertThat(top).extracting(ProductoVendido::nombre)
.containsExactly("Monitor 24 FHD", "Pack teclado y ratón", "Portátil Básico 15");
assertThat(top.getFirst().unidades()).isEqualTo(40);
}
@Test
void ventasPorMesDe2026() {
List<VentaMensual> ventas = pedidos.ventasMensuales(LocalDate.of(2026, 1, 1));
ventas.forEach(System.out::println);
assertThat(ventas).extracting(VentaMensual::mes).containsExactly(1, 2, 3, 4, 5, 6, 7);
assertThat(ventas.getLast().pedidos()).isEqualTo(3);
}
@Test
void listadoDePendientesConTotales() {
Page<PedidoListado> p = pedidos.listado(EstadoPedido.PENDIENTE, null,
PageRequest.of(0, 10, Sort.by(Sort.Direction.DESC, "fechaPedido")));
p.forEach(System.out::println);
assertThat(p.getTotalElements()).isEqualTo(3);
assertThat(p.getContent()).extracting(PedidoListado::id).containsExactly(5024, 5023, 5022);
assertThat(p.getContent().getFirst().total()).isEqualByComparingTo("1497.00");
}
@Test
void historialDeUnCliente() {
assertThat(pedidos.listado(null, 1002, PageRequest.of(0, 50)).getContent())
.extracting(PedidoListado::id).containsExactlyInAnyOrder(5002, 5009, 5018);
}
}
PedidoListado[id=5024, fechaPedido=2026-07-10, estado=PENDIENTE, clienteId=1008, cliente=Gestoría Delta, lineas=1, total=1497.00]
PedidoListado[id=5023, fechaPedido=2026-07-08, estado=PENDIENTE, clienteId=1009, cliente=Laura Benítez Sanz, lineas=2, total=418.80]
PedidoListado[id=5022, fechaPedido=2026-07-01, estado=PENDIENTE, clienteId=1012, cliente=Diseños Creativos S.L., lineas=1, total=1499.00]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 18.14 s -- in es.curso.empresa.repositorio.InformesRepositoryTest
Reglas de negocio con un reloj fijo
@Test
void cancelarUnPedidoDevuelveElStock() {
PedidoService servicio = new PedidoService(pedidos, clientes, empleados, productos);
Producto monitor = new Producto("Monitor 24 FHD", null, new Categoria("Monitores", null),
new BigDecimal("139.00"), 32);
Pedido p = new Pedido(new Cliente("Academia Nova", "B1"), null);
p.anadirLinea(monitor, 3, BigDecimal.ZERO);
when(pedidos.findConLineasById(5025)).thenReturn(Optional.of(p));
servicio.cambiarEstado(5025, EstadoPedido.CANCELADO);
assertThat(p.getEstado()).isEqualTo(EstadoPedido.CANCELADO);
assertThat(monitor.getStock()).isEqualTo(35);
// Y un pedido cancelado ya no se puede volver a cambiar
assertThatThrownBy(() -> servicio.cambiarEstado(5025, EstadoPedido.ENVIADO))
.isInstanceOf(ReglaNegocioException.class).hasMessage("El pedido 5025 ya está CANCELADO");
assertThat(monitor.getStock()).isEqualTo(35);
}
@Test
void elResumenUsaElMesYElAnioDelReloj() {
Clock elDiaDeLaPrueba = Clock.fixed(LocalDate.of(2026, 7, 20).atStartOfDay(ZoneId.of("Europe/Madrid")).toInstant(),
ZoneId.of("Europe/Madrid"));
InformeService informes = new InformeService(pedidos, elDiaDeLaPrueba);
when(pedidos.facturado(LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 20))).thenReturn(new BigDecimal("3414.80"));
when(pedidos.facturado(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 7, 20))).thenReturn(new BigDecimal("20704.79"));
when(pedidos.countByEstado(EstadoPedido.PENDIENTE)).thenReturn(3L);
InformeService.Resumen r = informes.resumen();
assertThat(r.facturadoMes()).isEqualByComparingTo("3414.80");
assertThat(r.facturadoAnio()).isEqualByComparingTo("20704.79");
assertThat(r.pendientes()).isEqualTo(3);
verify(pedidos).countByEstado(EstadoPedido.PENDIENTE);
}
Con Clock.fixed, «hoy» es siempre el 20 de julio de 2026 y la prueba comprueba exactamente qué intervalos de fechas pide el servicio: si alguien cambia withDayOfMonth(1) por error, when(...) no coincidirá y la prueba fallará.
El formulario de pedidos
@Test
void elPedidoQuedaANombreDelEmpleadoConectado() {
Pedido creado = new Pedido(new Cliente("Academia Nova", "B1"), null);
when(pedidos.crear(any())).thenReturn(creado);
assertThat(mvc.post().uri("/pedidos/guardar").with(user(CARMEN)).with(csrf())
.param("clienteId", "1004")
.param("lineas[0].productoId", "9").param("lineas[0].cantidad", "3").param("lineas[0].descuento", "5")
.param("lineas[1].cantidad", "1")) // fila vacía: se ignora
.hasStatus(HttpStatus.FOUND)
.flash().containsKey("exito");
ArgumentCaptor<NuevoPedidoRequest> peticion = ArgumentCaptor.forClass(NuevoPedidoRequest.class);
verify(pedidos).crear(peticion.capture());
System.out.println("Petición recibida por el servicio: " + peticion.getValue());
assertThat(peticion.getValue().empleadoId()).isEqualTo(110);
assertThat(peticion.getValue().lineas()).hasSize(1);
}
Petición recibida por el servicio: NuevoPedidoRequest[clienteId=1004, empleadoId=110, lineas=[Linea[productoId=9, cantidad=3, descuento=5]]]
El ArgumentCaptor de Mockito «atrapa» el objeto que el controlador pasó al servicio: así comprobamos que la fila vacía se ignoró y que el empleado salió del principal (Carmen, 110) y no de un parámetro que el usuario pudiera manipular. Las otras tres pruebas de la clase comprueban el error de validación sin líneas, el error de stock y el 403 de un EMPLEADO al cambiar el estado.
mvn verify
Resumen de la salida real con todo el proyecto:
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 153.9 s -- in es.curso.empresa.EmpresaAppIntegracionTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.749 s -- in es.curso.empresa.modelo.ImporteLineaTest
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 17.95 s -- in es.curso.empresa.repositorio.ClienteRepositoryTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 18.14 s -- in es.curso.empresa.repositorio.InformesRepositoryTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 70.65 s -- in es.curso.empresa.repositorio.PedidoRepositoryMySqlTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 16.17 s -- in es.curso.empresa.repositorio.RepositoriosH2Test
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.010 s -- in es.curso.empresa.seguridad.TokenServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.709 s -- in es.curso.empresa.servicio.EmpleadoServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.575 s -- in es.curso.empresa.servicio.PedidoServiceTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.065 s -- in es.curso.empresa.servicio.ProyectoFinalServiciosTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 14.08 s -- in es.curso.empresa.web.api.PedidoApiControllerTest
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.36 s -- in es.curso.empresa.web.api.ProductoApiControllerTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.77 s -- in es.curso.empresa.web.Ejercicios22Test
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 12.11 s -- in es.curso.empresa.web.mvc.EmpleadoControllerTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 10.12 s -- in es.curso.empresa.web.mvc.PedidoControllerTest
[INFO] Tests run: 60, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 159.9 s -- in es.curso.empresa.EmpresaAppMySqlIT
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] All coverage checks have been met.
[INFO] BUILD SUCCESS
[INFO] Total time: 10:31 min
La cobertura de instrucciones subió del 47 % del módulo 23 al 49 %: el código nuevo ha llegado con sus pruebas. Las plantillas Thymeleaf no cuentan para JaCoCo, que mide bytecode de Java.
11. Despliegue con Docker Compose
El despliegue es el del módulo 23, ya con todo el proyecto:
Figura 19. Del código al servidor.
docker compose down -v # empezar de cero: borra también los datos de MySQL
docker compose up -d --build
Salida real (resumida):
Image empresa-app:1.0 Built
Network empresa-app_default Created
Volume empresa-app_datos-mysql Created
Container empresa-app-db-1 Created
Container empresa-app-app-1 Created
Container empresa-app-db-1 Started
Container empresa-app-db-1 Waiting
Container empresa-app-db-1 Healthy
Container empresa-app-app-1 Starting
Container empresa-app-app-1 Started
Esta vez, con el healthcheck por TCP del módulo 23, los logs confirman el orden correcto: MySQL termina la carga de EMPRESA y se pone a escuchar en el puerto 3306 (18:18:10) antes de que arranque la aplicación (18:18:18):
db-1 | 2026-10-04 18:18:08+02:00 [Note] [Entrypoint]: MySQL init process done. Ready for start up.
db-1 | 2026-10-04T16:18:10.288288Z 0 [System] [MY-010931] [Server] /usr/sbin/mysqld: ready for connections. Version: '8.4.11' ... port: 3306
app-1 | 2026-10-04T18:18:18.226+02:00 INFO 1 --- [empresa-app] [main] es.curso.empresa.EmpresaAppApplication : Starting EmpresaAppApplication v0.0.1-SNAPSHOT using Java 25.0.4.1 ...
app-1 | 2026-10-04T18:18:44.195+02:00 INFO 1 --- [empresa-app] [main] es.curso.empresa.EmpresaAppApplication : Started EmpresaAppApplication in 27.353 seconds
(Los registros de MySQL salen en UTC y los de la aplicación en hora de Madrid: 16:18:10Z son las 18:18:10.)
Prueba de humo de la versión desplegada (salida real):
$ curl -s http://localhost:8080/actuator/health/readiness
{"status":"UP"}
$ curl -s "http://localhost:8080/api/informes/productos-mas-vendidos?top=3" -H "Authorization: Bearer $TOKEN_ANA"
[{"id":9,"nombre":"Monitor 24 FHD","unidades":40,"importe":4934.50},{"id":8,"nombre":"Pack teclado y ratón","unidades":30,"importe":897.00},{"id":3,"nombre":"Portátil Básico 15","unidades":24,"importe":10479.00}]
$ curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/v3/api-docs
404
En producción la documentación OpenAPI está desactivada (404), como establecimos en el perfil prod; los importes ya salen redondeados a céntimos.
12. Lista de comprobación de calidad
Antes de entregar el proyecto (o de pasarlo a producción), repasa esta lista:
Código y diseño
- Cada clase está en su capa; los controladores no usan repositorios ni contienen reglas de negocio
- La API no expone entidades: solo records DTO
- Las reglas de negocio lanzan excepciones propias (
ReglaNegocioException,RecursoNoEncontradoException) - Inyección por constructor, campos
final, sin@Autowireden campos - Sin código muerto, sin
System.out.printlnfuera de las pruebas, logs con SLF4J - Nombres en un único idioma y coherentes con el dominio (pedido, línea, cliente…)
Datos
- Las operaciones que tocan varias filas o tablas son
@Transactional; las consultas,readOnly = true - Los listados no provocan N+1 (revisa el SQL en el log del perfil
dev) - Los importes son
BigDecimaly se redondean a céntimos en un único sitio spring.jpa.open-in-view=falseyddl-auto=validate
Seguridad
- Contraseñas con BCrypt; ninguna contraseña ni secreto en el código ni en Git
- Todas las rutas tienen una regla; por defecto,
anyRequest().authenticated() - Las operaciones de ADMIN están protegidas por URL y con
@PreAuthorize - Los formularios
POSTllevan token CSRF; nada modifica datos conGET - Los errores no muestran trazas; la API responde
ProblemDetail - Swagger UI y los endpoints sensibles de Actuator desactivados o protegidos en producción
Pruebas
mvn verifypasa en una máquina limpia (o en GitHub Actions)- Hay pruebas unitarias de cada regla de negocio y de cada consulta propia
- Hay pruebas de seguridad: 401, 403 y CSRF
- La cobertura no baja del mínimo configurado en JaCoCo
Despliegue y documentación
docker compose up -d --buildfunciona desde cero (down -vantes)/actuator/health/readinessrespondeUP- Un
README.mddel proyecto explica cómo arrancarlo, los usuarios de prueba y las variables de entorno - La API está documentada (
@Tag,@Operation, códigos de respuesta)
13. Rúbrica de evaluación
Una propuesta para evaluar el proyecto (sobre 10 puntos):
| Criterio | Peso | Insuficiente (0-4) | Suficiente (5-6) | Notable (7-8) | Sobresaliente (9-10) |
|---|---|---|---|---|---|
| Arquitectura y diseño | 15 % | Lógica en los controladores o en las vistas | Capas separadas con algunos fallos | Capas limpias, DTO, excepciones propias | Además, decisiones justificadas y código fácil de extender |
| Persistencia | 15 % | Mapeos erróneos o N+1 graves | Entidades y repositorios correctos | Consultas JPQL con proyecciones, sin N+1 | Además, transacciones bien delimitadas y consultas optimizadas |
| Lógica de negocio | 15 % | El alta de pedido no controla el stock | Alta de pedido correcta | Alta, cancelación y validaciones completas, con rollback comprobado | Además, reglas adicionales (crédito, auditoría…) bien probadas |
| API REST | 10 % | Expone entidades o códigos HTTP incorrectos | CRUD con DTO y códigos correctos | Validación, ProblemDetail, paginación, OpenAPI | Además, documentación cuidada y ejemplos en Swagger UI |
| Interfaz web | 10 % | Pantallas incompletas o sin validación | Pantallas funcionales | Formularios con validación, PRG, layout, i18n | Además, experiencia de usuario cuidada y accesible |
| Seguridad | 15 % | Rutas sin proteger o contraseñas en claro | Login y roles en la web | Web y API (JWT) protegidas, CSRF, @PreAuthorize | Además, matriz de permisos completa probada con pruebas |
| Pruebas | 15 % | Sin pruebas o no pasan | Algunas unitarias | Unitarias, slices e integración; cobertura ≥ 40 % | Además, Testcontainers, pruebas de seguridad y CI |
| Despliegue y documentación | 5 % | No se puede arrancar en otro equipo | Arranca con instrucciones | Docker Compose funcional, perfil prod | Además, CI/CD, healthchecks y README completo |
💡 Consejo: Si trabajas en equipo, usad pull requests y revisión de código: un compañero revisa cada cambio con esta misma lista antes de unirlo a
main. Es como se trabaja en las empresas, y la evaluación puede tenerlo en cuenta.
14. Ejercicios
Los seis primeros amplían el proyecto con funciones habituales en una aplicación comercial; sus soluciones están probadas (Ejercicios24WebTest, Ejercicios24ServicioTest y VentasEmpleadoTest: 7 pruebas, todas en verde). Los siguientes son propuestas de ampliación de mayor tamaño, pensadas como continuación del curso.
Ejercicio 1: Exportar los pedidos a CSV
Añade un enlace «Exportar» al listado de pedidos que descargue un fichero pedidos.csv con número, fecha, cliente, líneas, total y estado, respetando el filtro de estado y con el formato que espera Excel en español (; como separador y coma decimal).
Ver solución
/** Ejercicio 1 (módulo 24): el listado en CSV, separado por ";" para que Excel lo abra en español. */
@GetMapping("/exportar")
@ResponseBody
public ResponseEntity<String> exportar(@RequestParam(required = false) EstadoPedido estado) {
var fecha = DateTimeFormatter.ofPattern("dd/MM/yyyy");
StringBuilder csv = new StringBuilder("pedido;fecha;cliente;lineas;total;estado\n");
for (PedidoListado p : pedidos.listado(estado, null, PageRequest.of(0, 10_000, Sort.by("id"))).getContent()) {
csv.append(p.id()).append(';')
.append(p.fechaPedido().format(fecha)).append(';')
.append('"').append(p.cliente().replace("\"", "\"\"")).append("\";")
.append(p.lineas()).append(';')
.append(p.total().toPlainString().replace('.', ',')).append(';')
.append(p.estado()).append('\n');
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=pedidos.csv")
.contentType(new MediaType("text", "csv", StandardCharsets.UTF_8))
.body(csv.toString());
}
En la plantilla: <a class="btn btn-outline-secondary" th:href="@{/pedidos/exportar(estado=${estado})}">Exportar</a>. La prueba (con dos pedidos simulados, uno con comillas en el nombre) imprime el fichero generado (salida real):
pedido;fecha;cliente;lineas;total;estado
5002;28/01/2025;"Construcciones Levante S.A.";3;7402,50;ENTREGADO
5022;01/07/2026;"Diseños ""Creativos"" S.L.";1;1499,00;PENDIENTE
@ResponseBody hace que este método de un @Controller devuelva datos y no una vista (módulo 21). La cabecera Content-Disposition: attachment pide al navegador que descargue el fichero. Los nombres van entre comillas y las comillas internas se duplican, que es la regla de escape del formato CSV (RFC 4180). La ruta /pedidos/exportar está protegida por la regla general de la cadena web: hay que estar autenticado.
Ejercicio 2: Límite de crédito
Un pedido no puede superar el límite de crédito del cliente (clientes.limite_credito). Añade la regla al servicio y compruébala con una prueba unitaria: Gestoría Delta tiene un límite de 1.000 € y quiere una Torre Workstation de 1.899 €.
Ver solución
Al final de PedidoService.crear, antes de guardar:
// Ejercicio 2 (módulo 24): un pedido no puede superar el límite de crédito del cliente
if (cliente.getLimiteCredito() != null && pedido.getTotal().compareTo(cliente.getLimiteCredito()) > 0) {
throw new ReglaNegocioException("El pedido (" + pedido.getTotal() + " €) supera el límite de crédito de "
+ cliente.getNombre() + " (" + cliente.getLimiteCredito() + " €)");
}
return pedidos.save(pedido);
@Test
void ej2PedidoQueSuperaElLimiteDeCredito() {
Cliente gestoria = new Cliente("Gestoría Delta", "B33333333");
gestoria.setLimiteCredito(new BigDecimal("1000.00"));
Producto torre = new Producto("Torre Workstation", null, new Categoria("Sobremesa", null),
new BigDecimal("1899.00"), 4);
when(clientes.findById(1009)).thenReturn(Optional.of(gestoria));
when(productos.findById(5)).thenReturn(Optional.of(torre));
assertThatThrownBy(() -> servicio.crear(new NuevoPedidoRequest(1009, null,
List.of(new NuevoPedidoRequest.Linea(5, 1, null)))))
.isInstanceOf(ReglaNegocioException.class)
.hasMessage("El pedido (1899.00 €) supera el límite de crédito de Gestoría Delta (1000.00 €)");
verify(pedidos, never()).save(any());
}
La comprobación va al final porque necesita el total del pedido, es decir, después de haber descontado el stock de todas las líneas. No pasa nada: la excepción provoca el rollback y el stock se restaura, como en el caso de falta de stock. Al ser una ReglaNegocioException, la web la muestra como error global del formulario y la API la convierte en un 409 con ProblemDetail sin escribir nada más. Fíjate en que se compara con compareTo y no con equals, por la escala de BigDecimal. Una versión más realista sumaría además los pedidos pendientes del cliente.
Ejercicio 3: Historial de pedidos de un cliente en la API
Añade GET /api/clientes/{id}/pedidos, que devuelva los pedidos del cliente con sus totales (reutilizando PedidoListado), ordenados del más reciente al más antiguo, y un 404 si el cliente no existe.
Ver solución
En ClienteApiController (que ahora recibe también PedidoService por constructor):
/** Ejercicio 3 (módulo 24): historial de pedidos de un cliente, con sus totales. */
@GetMapping("/{id}/pedidos")
public List<PedidoListado> pedidos(@PathVariable Integer id) {
servicio.buscar(id); // 404 si el cliente no existe
return pedidos.listado(null, id, PageRequest.of(0, 100, Sort.by("fechaPedido").descending())).getContent();
}
Respuesta real de la aplicación desplegada con Docker Compose:
$ curl -s "http://localhost:8080/api/clientes/1002/pedidos" -H "Authorization: Bearer $TOKEN_ANA"
[{"id":5018,"fechaPedido":"2026-03-03","estado":"ENTREGADO","clienteId":1002,"cliente":"Construcciones Levante S.A.","lineas":2,"total":2085.25},{"id":5009,"fechaPedido":"2025-05-19","estado":"ENTREGADO","clienteId":1002,"cliente":"Construcciones Levante S.A.","lineas":3,"total":4772.44},{"id":5002,"fechaPedido":"2025-01-28","estado":"ENTREGADO","clienteId":1002,"cliente":"Construcciones Levante S.A.","lineas":3,"total":7402.50}]
La llamada a servicio.buscar(id) sirve solo para que un cliente inexistente produzca RecursoNoEncontradoException (→ 404 con ProblemDetail) en lugar de una lista vacía, que sería ambigua. Es un subrecurso REST, como /api/pedidos/{id}/lineas del módulo 20. Las pruebas del ejercicio comprueban el 200 con jwt() de EMPLEADO y el 404.
Ejercicio 4: Errores de parámetros con detalle
GET /api/informes/ventas-mensuales?meses=50 devuelve un 400 con el texto genérico «Validation failure». Haz que la respuesta diga qué parámetro está mal y por qué, en el mismo formato que los errores de validación del cuerpo (módulo 20).
Ver solución
La excepción que lanza la validación de parámetros es HandlerMethodValidationException, y ResponseEntityExceptionHandler tiene un método para ella que podemos sobrescribir en ApiExceptionHandler:
/** Ejercicio 4 (módulo 24): restricciones en parámetros (@Min, @Max en @RequestParam...). */
@Override
protected @Nullable ResponseEntity<Object> handleHandlerMethodValidationException(
HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errores = new TreeMap<>();
ex.getParameterValidationResults().forEach(r -> errores.put(
r.getMethodParameter().getParameterName(),
r.getResolvableErrors().getFirst().getDefaultMessage()));
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"Hay parámetros con valores no permitidos");
pd.setTitle("Parámetros no válidos");
pd.setProperty("errores", errores);
return ResponseEntity.badRequest().body(pd);
}
Salida real de la prueba:
{"detail":"Hay parámetros con valores no permitidos","instance":"/api/informes/ventas-mensuales","status":400,"title":"Parámetros no válidos","errores":{"meses":"debe ser menor que o igual a 36"}}
El mensaje «debe ser menor que o igual a 36» es el mensaje por defecto de @Max de Hibernate Validator en español, elegido por el idioma de la petición. getParameterName() funciona porque Spring Boot compila con la opción -parameters de javac, que conserva los nombres de los parámetros en el bytecode. Podrías personalizar el texto con @Max(value = 36, message = "Como mucho 36 meses").
Ejercicio 5: Ventas por empleado
Escribe una consulta JPQL con proyección a un record VentaEmpleado(empleadoId, empleado, pedidos, total) que devuelva lo vendido por cada empleado (sin pedidos cancelados), de mayor a menor, y pruébala con @DataJpaTest.
Ver solución
/** Ejercicio 5 (módulo 24): lo vendido por cada empleado (sin pedidos cancelados). */
public record VentaEmpleado(Integer empleadoId, String empleado, Long pedidos, BigDecimal total) {
public VentaEmpleado {
total = total.setScale(2, RoundingMode.HALF_UP);
}
}
/** Ejercicio 5 (módulo 24): ranking de ventas por empleado. */
@Query("""
select new es.curso.empresa.dto.VentaEmpleado(
e.id, concat(e.nombre, ' ', e.apellidos), count(distinct p),
sum(l.cantidad * l.precioUnitario * (1 - l.descuento / 100)))
from LineaPedido l join l.pedido p join p.empleado e
where p.estado <> es.curso.empresa.modelo.EstadoPedido.CANCELADO
group by e.id, e.nombre, e.apellidos
order by sum(l.cantidad * l.precioUnitario * (1 - l.descuento / 100)) desc""")
List<VentaEmpleado> ventasPorEmpleado();
@Test
void rankingDeVendedores() {
List<VentaEmpleado> ranking = pedidos.ventasPorEmpleado();
ranking.forEach(System.out::println);
assertThat(ranking).isNotEmpty();
assertThat(ranking).extracting(VentaEmpleado::total).isSortedAccordingTo((a, b) -> b.compareTo(a));
}
Salida real:
VentaEmpleado[empleadoId=112, empleado=Elena Navarro Castro, pedidos=5, total=23833.55]
VentaEmpleado[empleadoId=110, empleado=Carmen Ruiz Ortega, pedidos=8, total=22018.34]
VentaEmpleado[empleadoId=111, empleado=David Torres Romero, pedidos=6, total=12931.97]
VentaEmpleado[empleadoId=113, empleado=Sergio Jiménez Vidal, pedidos=2, total=2335.00]
El join p.empleado e es un inner join: los pedidos sin empleado (5014 y 5023) quedan fuera, lo que tiene sentido en un ranking de vendedores. Carmen tiene más pedidos, pero Elena factura más: count(distinct p) cuenta pedidos y no líneas. Con un left join y coalesce(concat(...), 'Sin asignar') aparecerían también los pedidos sin empleado.
Ejercicio 6: Auditoría de los cambios de estado
Registra en el log cada cambio de estado de un pedido con el estado anterior, el nuevo y el usuario que lo hizo, obteniéndolo del contexto de seguridad. Compruébalo con una prueba que capture la salida.
Ver solución
En PedidoService.cambiarEstado, justo antes de cambiar el estado:
// Ejercicio 6 (módulo 24): quién cambió el estado, sacado del contexto de seguridad
var auth = SecurityContextHolder.getContext().getAuthentication();
log.info("Pedido {}: {} -> {} por {}", id, p.getEstado(), nuevo, auth == null ? "sistema" : auth.getName());
p.setEstado(nuevo);
La prueba usa dos utilidades: OutputCaptureExtension de Spring Boot, que captura la salida estándar en un parámetro CapturedOutput, y TestingAuthenticationToken de Spring Security para simular el usuario:
@ExtendWith({MockitoExtension.class, OutputCaptureExtension.class})
class Ejercicios24ServicioTest {
...
@AfterEach
void limpiar() {
SecurityContextHolder.clearContext();
}
@Test
void ej6ElCambioDeEstadoQuedaEnElLog(CapturedOutput salida) {
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("ana", null, "ROLE_ADMIN"));
Pedido p = new Pedido(new Cliente("Gestoría Delta", "B33333333"), null);
when(pedidos.findConLineasById(5024)).thenReturn(Optional.of(p));
servicio.cambiarEstado(5024, EstadoPedido.ENVIADO);
assertThat(salida.getOut()).contains("Pedido 5024: PENDIENTE -> ENVIADO por ana");
}
}
Línea real del log durante la prueba:
INFO 102 --- [empresa-app] [main] e.curso.empresa.servicio.PedidoService : Pedido 5024: PENDIENTE -> ENVIADO por ana
El SecurityContextHolder funciona igual desde la web (sesión) y desde la API (JWT): el servicio no necesita saber de dónde viene la petición. El @AfterEach es importante: el contexto de seguridad se guarda en una variable del hilo, y sin limpiarlo podría «contaminar» la siguiente prueba que se ejecute en el mismo hilo. Para una auditoría persistente (quién y cuándo modificó cada fila), Spring Data ofrece @CreatedBy/@LastModifiedBy con un AuditorAware que lee este mismo contexto.
Ejercicio 7 (propuesto): Un frontend con React o Angular
Crea una aplicación de una sola página (SPA) con React (Vite) o Angular que consuma la API: pantalla de login que guarde el JWT en memoria, catálogo, alta de pedido y el informe de productos más vendidos con una librería de gráficos. La aplicación se servirá desde http://localhost:5173, que ya está permitido en la configuración CORS. Pista: el Curso de JavaScript explica fetch y los módulos de Vite.
Ejercicio 8 (propuesto): Mensajería y eventos
Cuando se registre un pedido, publica un evento de aplicación PedidoRegistrado (módulo 17) y procésalo con @TransactionalEventListener(phase = AFTER_COMMIT) para enviar un correo al comercial del cliente con spring-boot-starter-mail (en desarrollo, a un Mailpit en Docker). Como segundo paso, publica el evento en RabbitMQ o Kafka (spring-boot-starter-amqp, spring-kafka) para que lo consuma otro servicio.
Ejercicio 9 (propuesto): Microservicios con Spring Cloud
Divide la aplicación en un servicio de catálogo y otro de pedidos, con su propia base de datos cada uno. Usa Spring Cloud Gateway como puerta de entrada (validando el JWT una sola vez), comunícalos con un cliente HTTP declarativo (@HttpExchange) y añade un Circuit Breaker con Resilience4j para cuando el catálogo no responda. Reflexiona: ¿cómo se descuenta ahora el stock de forma consistente sin una transacción común? (patrón Saga).
Ejercicio 10 (propuesto): Facturas en PDF
Genera la factura de un pedido entregado en PDF (GET /pedidos/{id}/factura) con OpenPDF o Flying Saucer a partir de una plantilla Thymeleaf específica, con los datos fiscales de EMPRESA S.A., las líneas, la base imponible, el IVA del 21 % y el total.
Ejercicio 11 (propuesto): Caché y rendimiento de los informes
Los informes se calculan en cada visita. Activa la caché de Spring (@EnableCaching, @Cacheable("informes")) en InformeService, invalídala con @CacheEvict al registrar o cancelar un pedido, y mide la diferencia con Actuator (/actuator/metrics/cache.gets). Después, crea en MySQL los índices que mejoren las consultas y compáralo con EXPLAIN.
Ejercicio 12 (propuesto): Migraciones con Flyway y despliegue en la nube
Sustituye los scripts manuales por migraciones de Flyway (V1__empresa.sql, V2__seguridad.sql, V3__indices.sql) con spring-boot-starter-flyway, para que la base de datos se cree y actualice sola al arrancar. Después, despliega la imagen en un servicio de contenedores gestionado (Google Cloud Run, Azure Container Apps o Render) con una base de datos MySQL gestionada y los secretos en el gestor de la plataforma.
Resumen
| Pieza | Cómo se ha resuelto |
|---|---|
| Requisitos | 12 funcionales y 7 no funcionales, repartidos en iteraciones con una funcionalidad completa cada una |
| Arquitectura | Capas web.mvc / web.api → servicio → repositorio → MySQL, con modelo, dto, seguridad y config transversales |
| Modelo | Esquema EMPRESA sin cambios + usuarios/roles; composición Pedido-LineaPedido; importes calculados en el modelo |
| Alta de pedido | @Transactional: valida catálogo y stock, descuenta (dirty checking) y guarda en cascada; cualquier excepción deshace todo |
| Formulario de varias líneas | PedidoForm con lista indexada, th:field="*{lineas[__${i.index}__].productoId}", @AssertTrue entre campos |
| Cancelación | Solo ADMIN (URL + @PreAuthorize); devuelve el stock |
| Listados e informes | JPQL con select new Record(...), group by, extract, countQuery y Pageable; redondeo en el constructor compacto |
| Fechas en la lógica | Clock como bean; Clock.fixed en las pruebas |
| Web | Thymeleaf con layout, fragmentos, sec:authorize, PRG y mensajes flash |
| API | DTO, ProblemDetail, validación de cuerpo y parámetros, OpenAPI con esquema Bearer |
| Seguridad | Dos cadenas (sesión + CSRF / JWT sin estado), roles ADMIN y EMPLEADO, matriz de permisos |
| Pruebas | 61 pruebas: unitarias, @DataJpaTest, @WebMvcTest, @SpringBootTest, Testcontainers; JaCoCo con cobertura mínima |
| Despliegue | Dockerfile multietapa, Docker Compose con healthcheck por TCP, perfil prod, GitHub Actions |
| Calidad | Lista de comprobación y rúbrica de evaluación |
💡 Consejo: Has llegado al final del Curso de Java Avanzado. Lo que tienes entre manos es una aplicación con la misma estructura que muchas aplicaciones empresariales reales. El mejor siguiente paso es llevar alguna de las propuestas de ampliación hasta el final, subir el proyecto a GitHub con su
README.mdy su workflow de integración continua, y enseñarlo: es una carta de presentación excelente.