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:

Arquitectura en capas: el navegador y los clientes de la API pasan por Spring Security (cadena web con sesión y CSRF, cadena API sin estado con JWT); después los controladores web.mvc con Thymeleaf y web.api con DTO llaman a la capa de servicio transaccional, que usa los repositorios de Spring Data JPA sobre MySQL 8.4; modelo y dto, y config y Actuator, son transversales 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.

Modelo de entidades: Departamento 1:N Empleado; Empleado se relaciona consigo mismo como jefe, con Cliente como comercial y con Pedido como empleado que lo atiende; Cliente 1:N Pedido; Pedido 1:N LineaPedido con clave compuesta; LineaPedido N:1 Producto; Producto N:1 Categoria, que tiene categoría padre; Usuario 0..1 Empleado y N:M Rol 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 verify y 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>

Catálogo filtrado por la categoría Monitores visto por Ana: dos tarjetas, Monitor 24 FHD con 35 en stock y Monitor 27 4K con 12, cada una con un campo de unidades y el botón Reponer 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";
    }
}

Listado de clientes de EMPRESA con buscador, NIF, ciudad, comercial asignado, límite de crédito y un botón para crear un pedido al cliente 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>

Ficha de Construcciones Levante S.A.: NIF, ciudad Alicante, email, comercial Carmen Ruiz Ortega y fecha de alta; a la derecha, su historial con los pedidos 5002, 5009 y 5018 y un total facturado de 14.260,19 € 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 tres DECIMAL con 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.

Listado de pedidos con pestañas por estado y una tabla con número, fecha, cliente, número de líneas, total y estado con insignias de colores: PENDIENTE en amarillo, ENVIADO en azul y ENTREGADO en verde 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):

Detalle del pedido 5001, ENTREGADO: tres líneas (Portátil Gaming 16, Ratón inalámbrico y Monitor 24 FHD con un 5 % de descuento), total 1.802,90 €, cliente Talleres Martín S.L., atendido por Carmen Ruiz Ortega y fechas de pedido y envío 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:

Diagrama de secuencia del alta de un pedido: el controlador llama a PedidoService.crear, que dentro de una transacción busca cliente y empleado, y para cada línea busca el producto, comprueba catálogo y stock, descuenta el stock y añade la línea; al final guarda el pedido con sus líneas y Hibernate actualiza el stock; si una línea falla, la excepción deshace todo 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 el EntityManager de la transacción (módulo 14): al confirmarla, Hibernate detecta el cambio de stock (dirty checking) y ejecuta el UPDATE. Y como ReglaNegocioException es una excepción no comprobada (RuntimeException), @Transactional hace 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):

Formulario Nuevo pedido de Carmen: cliente Academia Nova, primera línea Monitor 24 FHD con cantidad 3 y descuento 5, segunda línea Torre Workstation (4 uds.) con cantidad 6 y tres filas vacías 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:

El mismo formulario con el aviso rojo Stock insuficiente de Torre Workstation: hay 4 y se piden 6; el desplegable del monitor sigue mostrando 35 unidades 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):

Detalle del pedido 5025 recién creado con el mensaje verde Pedido 5025 registrado, estado PENDIENTE, dos líneas y total de 2.295,15 €, cliente Academia Nova y atendido por Carmen Ruiz Ortega 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>

Detalle del pedido 5025 visto por Ana con la tarjeta de cambio de estado a la derecha: desplegable con CANCELADO seleccionado, botón Cambiar y el aviso Al cancelar, el stock vuelve al almacén Figura 12. Cambio de estado, solo para ADMIN. Captura real.

Pedido 5025 con el mensaje verde Pedido 5025 marcado como CANCELADO y la insignia gris CANCELADO; ya no aparece la tarjeta de cambio de estado 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) con getTotalElements() es un truco para contar: pide una página de un solo elemento y lee el total que Spring Data calcula con count(*). Para un contador puro, un método count() del repositorio es aún más directo.

Panel de inicio de Ana: bienvenida personalizada, indicadores Facturado este año 20.704,79 €, Facturado este mes 0,00 €, 3 pedidos pendientes y 5 productos con poco stock; la lista Reponer pronto con Torre Workstation, Pack teclado y ratón, Smartphone Gama Alta, Mesa Elevable e Impresora Láser, y tarjetas de clientes, productos, empleados y departamentos 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.

Página Informes comerciales: barras de ventas de noviembre de 2025 a julio de 2026 con el importe y el número de pedidos de cada mes (enero destaca con 8.945,05 €), ranking de productos más vendidos encabezado por Monitor 24 FHD con 40 unidades, y la tabla de facturación por cliente encabezada por Instituto Tecnológico Sur con 20.602,55 € 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 @Min y @Max directamente en un @RequestParam funcionan sin @Validated en 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:

Swagger UI con las secciones Empleados, hola-controller, Informes (con las operaciones ventas-mensuales, productos-mas-vendidos y clientes) y Pedidos, todas con candado Figura 16. La API documentada con OpenAPI. Captura real.

Operación GET /api/informes/productos-mas-vendidos ejecutada en Swagger UI con top 5 y el token de Ana: respuesta 200 con el ranking en JSON 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:

Página Usuarios de la aplicación con ana (EMPLEADO y ADMIN), carmen y pablo (EMPLEADO) activos y tomas (EMPLEADO) desactivado con una cruz roja 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:

Del código al servidor: git push a GitHub, GitHub Actions ejecuta mvnw verify con las pruebas, docker build crea la imagen empresa-app:1.0 y se publica en un registro; en el servidor, docker compose arranca el proxy HTTPS, la aplicación con perfil prod y MySQL 8.4 con su volumen 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 @Autowired en campos
  • Sin código muerto, sin System.out.println fuera 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 BigDecimal y se redondean a céntimos en un único sitio
  • spring.jpa.open-in-view=false y ddl-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 POST llevan token CSRF; nada modifica datos con GET
  • 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 verify pasa 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 --build funciona desde cero (down -v antes)
  • /actuator/health/readiness responde UP
  • Un README.md del 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.md y su workflow de integración continua, y enseñarlo: es una carta de presentación excelente.