diff --git a/docs/requisitos/historias-de-usuario.md b/docs/requisitos/historias-de-usuario.md index 4577beb..ca345b7 100644 --- a/docs/requisitos/historias-de-usuario.md +++ b/docs/requisitos/historias-de-usuario.md @@ -7,38 +7,81 @@ ## Cómo leer cada historia - **Historia:** quién la pide, qué quiere y para qué. -- **Ficha:** fila del Product Backlog, prioridad MoSCoW de esa fila, sprint en que se hizo, estado y estimación: puntos de historia, tipo de trabajo y horas sin IA y con IA (ver [Estimación](#estimación)). +- **Ficha:** fila del Product Backlog, prioridad MoSCoW de esa fila, sprint en que se hizo, estado y estimación: puntos de historia, líneas de código, horas sin IA y con IA y coste de la IA (ver [Estimación](#estimación)). - **Criterios de aceptación:** las condiciones que se tienen que cumplir para dar la historia por terminada (Definition of Done). Cada uno se lee como *Dado* (situación de partida), *Cuando* (lo que hace el usuario o el sistema) y *Entonces* (el resultado que se espera). ## Estimación -Cada historia tiene un **tamaño en puntos de historia** y una **estimación de horas**, sin IA y con IA. +Cada historia tiene un **tamaño** (puntos y líneas de código), las **horas** que llevaría sin IA y con IA, y el **coste de la IA** (tokens y dinero). Lo que se puede medir en el repositorio se ha medido; lo que no, sale de valores de referencia publicados y se da con un intervalo. -**Puntos de historia.** Escala de Fibonacci (1, 2, 3, 5, 8…): miden el tamaño relativo de una historia (cuánto trabajo, cuánta complejidad y cuánta incertidumbre) y no dependen de quién la haga. En las historias hechas son los puntos que figuran en las actas de los sprints; si una historia se hizo en dos sprints, se suman (por ejemplo, HU-03: 8 + 3). Las [métricas](../scrum/metricas.md) suman además el trabajo que no es una historia, como las migraciones con Flyway; HU-17 se estimó al hacerla, después del Sprint 12. En las que no se han hecho (HU-15, HU-16 y HU-38 a HU-43) son una estimación por comparación con historias parecidas, la misma que usa el plan de la ampliación. +### 1. Tamaño -**Horas sin IA.** Horas ideales de trabajo de una persona del equipo, programando sin ayuda de IA: 4 horas por punto. Incluyen diseñar, programar, escribir las pruebas, revisar el pull request y actualizar la documentación. La cifra tiene en cuenta que el equipo está aprendiendo Spring Boot. Es coherente con la velocidad media de los sprints (unos 18,5 puntos, es decir, unas 74 horas) si cada una de las cinco personas dedica unas 7 horas por semana. +- **Puntos de historia** (Fibonacci): tamaño relativo, decidido al planificar cada sprint. En las historias hechas son los de las actas; si se hizo en dos sprints, se suman (HU-03: 8 + 3). En las descartadas, una estimación por comparación con historias parecidas. +- **Líneas de código**: se han contado las líneas reales del repositorio, sin comentarios ni líneas en blanco: 15.103 en total (Java de la aplicación, pruebas, vistas HTML, CSS y JavaScript y migraciones SQL). Repartidas entre los 185 puntos hechos, salen **82 líneas por punto**. Las líneas de cada historia son sus puntos × 82. -**Horas con IA.** Con un asistente de IA, escribir el código cuesta poco, pero no desaparece el resto del trabajo: explicar bien lo que se quiere, leer y entender lo que propone, comprobar que cumple los criterios de aceptación, probarlo, corregir sus errores e integrarlo con lo que ya existe. Por eso el ahorro no es igual en todas las historias: +### 2. Horas sin IA -| Tipo | Qué incluye | Horas con IA | Por qué | -|---|---|---:|---| -| **Interfaz y datos** | Formularios, listados, páginas, consultas sencillas | 30 % de las horas sin IA | Código repetitivo que la IA escribe bien; casi todo el tiempo es revisarlo y probarlo | -| **Reglas del negocio** | Cuotas, apuestas, resolución, saldos, límites | 45 % | Hay que comprobar a mano los casos límite y entender las reglas para poder defenderlas | -| **Integración y seguridad** | API externa, correo, inicio de sesión, contraseñas | 55 % | La IA se equivoca más con servicios externos y con la seguridad; hay que probar con el servicio real y revisar con más cuidado | +Horas de una persona del equipo que programa la historia sin IA: diseñarla, escribir el código y las pruebas, depurarla y dejarla revisada. Dependen de la **productividad** (líneas terminadas por hora), que no se puede medir en este proyecto porque se hizo con IA. Se toman valores de referencia y se combinan con la fórmula de **tres valores (PERT)**: (optimista + 4 × probable + pesimista) / 6. -### Totales +| | Optimista | Probable | Pesimista | Resultado | +|---|---:|---:|---:|---:| +| Líneas por hora | 25 | 15 | 8 | 13,9 líneas/h | + +- **Optimista, 25:** el orden de magnitud que se mide en los cursos del Personal Software Process con programas pequeños e individuales. +- **Probable, 15:** en un proyecto en equipo con un framework que se está aprendiendo (Spring Boot), con pruebas automáticas y coordinación, se produce menos. +- **Pesimista, 8:** cuando hay que aprender la tecnología sobre la marcha. + +Con 13,9 líneas por hora, **un punto (82 líneas) son unas 5,9 horas sin IA**. + +*Comprobación:* el modelo COCOMO básico (proyecto pequeño, equipo con experiencia en el dominio) da para 15,1 mil líneas unos 42 meses-persona. Eso incluye gestión y documentación de un proyecto profesional, así que es un techo, no la estimación. + +### 3. Horas con IA + +Con IA la persona no escribe el código, pero tiene que dirigir a la IA y responder de lo que entrega. Se separa en cuatro partes: + +| Parte | Cómo se calcula | En qué se basa | +|---|---|---| +| **Dirigir a la IA** | 0,95 h por cada mil líneas | **Medido:** el código de los sprints 1 a 12 (13.625 líneas) se generó en unas 13 horas de reloj de trabajo con la IA | +| **Revisar el código** | Leerlo y entenderlo a 138 líneas/h | Tres valores: 300, 150 y 75 líneas/h. Los estudios de revisión de código recomiendan no pasar de 200–400 líneas/h para encontrar fallos; quien aprende Spring necesita ir más despacio | +| **Comprobar los criterios** | 10 minutos por criterio de aceptación | Probar a mano en el navegador el *Dado / Cuando / Entonces* de cada criterio de la historia | +| **Corregir fallos** | 2,5 fallos por cada mil líneas × 1 h | **Medido:** la revisión encontró 34 fallos en 13.625 líneas (issues #34–#67). Cada uno: entenderlo, corregirlo con ayuda y comprobarlo | + +Para un punto (82 líneas): 0,1 h de dirigir + 0,6 h de revisar + 0,2 h de corregir, más los criterios de la historia. **La parte que más pesa es revisar**: con IA, el trabajo pasa de escribir código a leerlo y entenderlo. -| | Puntos | Horas sin IA | Horas con IA | +### 4. Coste de la IA + +Se ha medido con el registro de la sesión de trabajo con la IA, que guarda los tokens de cada respuesta, y las tarifas de la API de Anthropic del modelo usado (octubre de 2026): 4 $ por millón de tokens de entrada, 20 $ por millón de salida, 0,20 $ por millón leídos de la caché y 5–8 $ por millón escritos en ella. + +| Fase | Tokens generados | Tokens de contexto leídos | Coste | |---|---:|---:|---:| -| Historias hechas · Interfaz y datos | 54 | 216 | 66 | -| Historias hechas · Reglas del negocio | 105 | 420 | 188 | -| Historias hechas · Integración y seguridad | 26 | 104 | 58 | -| **Historias hechas (45)** | **185** | **740** | **312** | -| Historias descartadas (8) | 29 | 116 | 49 | +| Programar la app (sprints 1–12, 13.625 líneas) | 1,0 millones | 372 millones | 114 $ | +| Revisar y corregir los 34 fallos | 0,3 millones | 107 millones | 47 $ | +| **Por cada mil líneas** | **92 mil** | **35 millones** | **11,8 $** | + +- **Por punto (82 líneas)** salen unos 1,0 $. +- **Casi todo el coste es contexto:** en cada paso la IA vuelve a leer la conversación y los archivos (la caché lo abarata). Lo que escribe es una parte pequeña. +- **Son precios de la API.** Con una suscripción mensual a Claude se paga una cuota fija, no por token. +- **No incluye** el coste de los documentos, las presentaciones ni la wiki, que no son código. + +### Totales + +| | Puntos | Líneas | Horas sin IA | Horas con IA | Tokens generados | Coste de la IA | +|---|---:|---:|---:|---:|---:|---:| +| **Historias hechas (45)** | 185 | 15.170 | 1.091 | 177 | 1,4 millones | 179 $ | +| Historias descartadas (8) | 29 | 2.378 | 171 | 28 | 0,2 millones | 28 $ | + +**Cómo se reparten las 177 horas con IA de las historias hechas:** 14 h dirigiendo a la IA, 110 h revisando el código, 16 h comprobando criterios y 38 h corrigiendo fallos. -**Lectura.** Las 45 historias hechas suman unas 740 horas sin IA, unas 148 por persona: más de lo que cinco estudiantes pueden dedicar al proyecto en un cuatrimestre. Con IA bajan a unas 312 horas, unas 62 por persona, que sí caben. Aun así, con IA el tiempo sigue siendo, sobre todo, entender y revisar: si se salta esa parte, se pierde lo que se evalúa en la defensa. +**Lectura:** +- **Sin IA,** las historias hechas suman unas 1.091 horas, unas 218 por persona. Con lo que cinco estudiantes dedican a una asignatura en un cuatrimestre no cabe. +- **Con IA,** bajan a unas 177 horas, unas 35 por persona, y el coste es de unos 179 $. +- **El ahorro solo es real si la revisión se hace.** Si se salta, el tiempo baja todavía más, pero nadie del equipo conoce el código, y eso es lo que se evalúa en la defensa. -> Son estimaciones, no mediciones. Para afinarlas, a partir de ahora conviene apuntar en el acta de cada sprint las horas reales y compararlas con estas: si no cuadran, se ajustan las horas por punto o los porcentajes con IA para los siguientes sprints. +**Límites de la estimación:** +- **Líneas de cada historia:** salen de sus puntos; las que realmente ocupa cada una pueden variar. +- **Fallos:** solo se cuentan los que encontró la revisión. +- **Productividad sin IA y ritmo de revisión:** son valores de referencia, no medidos aquí. +- **Para afinarla:** conviene apuntar en cada acta las horas reales de revisión y compararlas con estas. ## Actores @@ -48,61 +91,61 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de ## Resumen -| Historia | Título | Estado | Criterios | Prioridad | Puntos | Horas sin IA | Horas con IA | -|---|---|---|---|---:|---:|---:|---:| -| HU-01 | Alta de competiciones y equipos con su calificación | Hecha | 2 | 1 (Muy alta) | 8 | 32 | 10 | -| HU-02 | Forma reciente de un equipo | Hecha | 1 | 1 (Muy alta) | 2 | 8 | 4 | -| HU-03 | Cuotas automáticas con margen para la casa | Hecha | 3 | 3 (Media) | 11 | 44 | 20 | -| HU-04 | Introducir el resultado | Hecha | 2 | 1 (Muy Alta) | 5 | 20 | 9 | -| HU-05 | Suspender o anular un evento | Hecha | 2 | 2 (Alta) | 4 | 16 | 7 | -| HU-06 | Dinero apostado a cada resultado | Hecha | 1 | 3 (Media) | 2 | 8 | 2 | -| HU-07 | Límites de apuesta del creador | Hecha | 1 | 3 (Media) | 3 | 12 | 5 | -| HU-08 | Catálogo sin registrarse | Hecha | 2 | 1 (Muy alta) | 2 | 8 | 2 | -| HU-09 | Web adaptada a móvil | Hecha | 1 | 3 (Media) | 3 | 12 | 4 | -| HU-10 | Juego responsable | Hecha | 2 | 4 (Baja) | 6 | 24 | 11 | -| HU-11 | Registro | Hecha | 2 | 1 (Muy alta) | 3 | 12 | 7 | -| HU-12 | Inicio de sesión | Hecha | 2 | 1 (Muy alta) | 3 | 12 | 7 | -| HU-13 | Consultar el saldo | Hecha | 2 | 1 (Muy alta) | 2 | 8 | 2 | -| HU-14 | Saldo de bienvenida y recargas | Hecha | 2 | 2 (Alta) | 2 | 8 | 4 | -| HU-15 | Cuenta bancaria | Descartada (Won't) | 2 | 5 (Muy Baja) | 5 | 20 | 11 | -| HU-16 | Retirar el saldo | Descartada (Won't) | 3 | 5 (Muy Baja) | 3 | 12 | 5 | -| HU-17 | Recuperar la contraseña | Hecha | 2 | 3 (Media) | 5 | 20 | 11 | -| HU-18 | Eliminar la cuenta | Hecha | 2 | 1 (Muy alta) | 3 | 12 | 5 | -| HU-19 | Catálogo por deporte y fecha | Hecha | 2 | 1 (Muy alta) | 3 | 12 | 4 | -| HU-20 | Cuotas de cada resultado | Hecha | 2 | 3 (Media) | 3 | 12 | 4 | -| HU-21 | Cargar eventos a mano o desde la API | Hecha | 2 | 2 (Alta) | 8 | 32 | 18 | -| HU-22 | Búsqueda y filtros | Hecha | 3 | 3 (Media) | 3 | 12 | 4 | -| HU-23 | Apuesta simple | Hecha | 2 | 1 (Muy alta) | 5 | 20 | 9 | -| HU-24 | Apuestas activas | Hecha | 1 | 2 (Alta) | 3 | 12 | 4 | -| HU-25 | Resolución automática | Hecha | 3 | 3 (Media) | 8 | 32 | 14 | -| HU-26 | Cancelar una apuesta | Hecha | 1 | 2 (Alta) | 2 | 8 | 4 | -| HU-27 | Cambiar el importe | Hecha | 2 | 2 (Alta) | 2 | 8 | 4 | -| HU-28 | Boleto de combinadas | Hecha | 3 | 4 (Baja) | 5 | 20 | 9 | -| HU-29 | Multiplicador total y ganancia potencial | Hecha | 3 | 2 (Alta) | 5 | 20 | 6 | -| HU-30 | Resolución de combinadas | Hecha | 3 | 4 (Baja) | 5 | 20 | 9 | -| HU-31 | Ficha del equipo | Hecha | 2 | 3 (Media) | 5 | 20 | 6 | -| HU-32 | Gráfico de evolución | Hecha | 1 | 4 (Baja) | 3 | 12 | 4 | -| HU-33 | Cara a cara | Hecha | 2 | 5 (Muy Baja) | 3 | 12 | 4 | -| HU-34 | Historial | Hecha | 1 | 2 (Alta) | 2 | 8 | 2 | -| HU-35 | Estadísticas personales | Hecha | 1 | 4 (Baja) | 3 | 12 | 5 | -| HU-36 | Ranking | Hecha | 4 | 3 (Media) | 5 | 20 | 9 | -| HU-37 | Avisos | Hecha | 3 | 2 (Alta) | 5 | 20 | 9 | -| HU-38 | Seguir a otro usuario | Descartada (Won't) | 1 | 3 (Media) | 3 | 12 | 4 | -| HU-39 | Seguidos y seguidores | Descartada (Won't) | 1 | 3 (Media) | 2 | 8 | 2 | -| HU-40 | Actividad de los usuarios que sigo | Descartada (Won't) | 1 | 5 (Muy Baja) | 3 | 12 | 4 | -| HU-41 | Feed de actividad | Descartada (Won't) | 1 | 5 (Muy Baja) | 5 | 20 | 9 | -| HU-42 | Privacidad del perfil | Descartada (Won't) | 2 | 3 (Media) | 3 | 12 | 5 | -| HU-43 | Copiar una apuesta | Descartada (Won't) | 2 | 4 (Baja) | 5 | 20 | 9 | -| HU-44 | Apuestas a largo plazo | Hecha | 3 | 4 (Baja) | 5 | 20 | 9 | -| HU-45 | Gestión de mercados | Hecha | 3 | 4 (Baja) | 5 | 20 | 6 | -| HU-46 | Editar o borrar un evento | Hecha | 2 | 1 (Muy Alta) | 3 | 12 | 5 | -| HU-47 | Cambiar nombre y contraseña | Hecha | 2 | 2 (Alta) | 2 | 8 | 4 | -| HU-48 | Perfil público de un jugador | Hecha | 1 | 3 (Media) | 2 | 8 | 2 | -| HU-49 | Plantilla y alineación probable | Hecha | 2 | 4 (Baja) | 5 | 20 | 9 | -| HU-50 | Plantillas desde la API y a mano | Hecha | 2 | 2 (Alta) | 5 | 20 | 11 | -| HU-51 | Ligas privadas | Hecha | 3 | 4 (Baja) | 5 | 20 | 9 | -| HU-52 | Doble oportunidad, goles y ambos marcan | Hecha | 3 | 3 (Media) | 8 | 32 | 14 | -| HU-54 | Cuentas de la casa | Hecha | 2 | 4 (Baja) | 3 | 12 | 5 | +| Historia | Título | Estado | Criterios | Prioridad | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:|---:| +| HU-01 | Alta de competiciones y equipos con su calificación | Hecha | 2 | 1 (Muy alta) | 8 | 656 | 47 | 7,3 | 7,8 $ | +| HU-02 | Forma reciente de un equipo | Hecha | 1 | 1 (Muy alta) | 2 | 164 | 12 | 1,9 | 1,9 $ | +| HU-03 | Cuotas automáticas con margen para la casa | Hecha | 3 | 3 (Media) | 11 | 902 | 65 | 10 | 10,7 $ | +| HU-04 | Introducir el resultado | Hecha | 2 | 1 (Muy Alta) | 5 | 410 | 29 | 4,7 | 4,8 $ | +| HU-05 | Suspender o anular un evento | Hecha | 2 | 2 (Alta) | 4 | 328 | 24 | 3,8 | 3,9 $ | +| HU-06 | Dinero apostado a cada resultado | Hecha | 1 | 3 (Media) | 2 | 164 | 12 | 1,9 | 1,9 $ | +| HU-07 | Límites de apuesta del creador | Hecha | 1 | 3 (Media) | 3 | 246 | 18 | 2,8 | 2,9 $ | +| HU-08 | Catálogo sin registrarse | Hecha | 2 | 1 (Muy alta) | 2 | 164 | 12 | 2,1 | 1,9 $ | +| HU-09 | Web adaptada a móvil | Hecha | 1 | 3 (Media) | 3 | 246 | 18 | 2,8 | 2,9 $ | +| HU-10 | Juego responsable | Hecha | 2 | 4 (Baja) | 6 | 492 | 35 | 5,6 | 5,8 $ | +| HU-11 | Registro | Hecha | 2 | 1 (Muy alta) | 3 | 246 | 18 | 3,0 | 2,9 $ | +| HU-12 | Inicio de sesión | Hecha | 2 | 1 (Muy alta) | 3 | 246 | 18 | 3,0 | 2,9 $ | +| HU-13 | Consultar el saldo | Hecha | 2 | 1 (Muy alta) | 2 | 164 | 12 | 2,1 | 1,9 $ | +| HU-14 | Saldo de bienvenida y recargas | Hecha | 2 | 2 (Alta) | 2 | 164 | 12 | 2,1 | 1,9 $ | +| HU-15 | Cuenta bancaria | Descartada (Won't) | 2 | 5 (Muy Baja) | 5 | 410 | 29 | 4,7 | 4,8 $ | +| HU-16 | Retirar el saldo | Descartada (Won't) | 3 | 5 (Muy Baja) | 3 | 246 | 18 | 3,1 | 2,9 $ | +| HU-17 | Recuperar la contraseña | Hecha | 2 | 3 (Media) | 5 | 410 | 29 | 4,7 | 4,8 $ | +| HU-18 | Eliminar la cuenta | Hecha | 2 | 1 (Muy alta) | 3 | 246 | 18 | 3,0 | 2,9 $ | +| HU-19 | Catálogo por deporte y fecha | Hecha | 2 | 1 (Muy alta) | 3 | 246 | 18 | 3,0 | 2,9 $ | +| HU-20 | Cuotas de cada resultado | Hecha | 2 | 3 (Media) | 3 | 246 | 18 | 3,0 | 2,9 $ | +| HU-21 | Cargar eventos a mano o desde la API | Hecha | 2 | 2 (Alta) | 8 | 656 | 47 | 7,3 | 7,8 $ | +| HU-22 | Búsqueda y filtros | Hecha | 3 | 3 (Media) | 3 | 246 | 18 | 3,1 | 2,9 $ | +| HU-23 | Apuesta simple | Hecha | 2 | 1 (Muy alta) | 5 | 410 | 29 | 4,7 | 4,8 $ | +| HU-24 | Apuestas activas | Hecha | 1 | 2 (Alta) | 3 | 246 | 18 | 2,8 | 2,9 $ | +| HU-25 | Resolución automática | Hecha | 3 | 3 (Media) | 8 | 656 | 47 | 7,5 | 7,8 $ | +| HU-26 | Cancelar una apuesta | Hecha | 1 | 2 (Alta) | 2 | 164 | 12 | 1,9 | 1,9 $ | +| HU-27 | Cambiar el importe | Hecha | 2 | 2 (Alta) | 2 | 164 | 12 | 2,1 | 1,9 $ | +| HU-28 | Boleto de combinadas | Hecha | 3 | 4 (Baja) | 5 | 410 | 29 | 4,9 | 4,8 $ | +| HU-29 | Multiplicador total y ganancia potencial | Hecha | 3 | 2 (Alta) | 5 | 410 | 29 | 4,9 | 4,8 $ | +| HU-30 | Resolución de combinadas | Hecha | 3 | 4 (Baja) | 5 | 410 | 29 | 4,9 | 4,8 $ | +| HU-31 | Ficha del equipo | Hecha | 2 | 3 (Media) | 5 | 410 | 29 | 4,7 | 4,8 $ | +| HU-32 | Gráfico de evolución | Hecha | 1 | 4 (Baja) | 3 | 246 | 18 | 2,8 | 2,9 $ | +| HU-33 | Cara a cara | Hecha | 2 | 5 (Muy Baja) | 3 | 246 | 18 | 3,0 | 2,9 $ | +| HU-34 | Historial | Hecha | 1 | 2 (Alta) | 2 | 164 | 12 | 1,9 | 1,9 $ | +| HU-35 | Estadísticas personales | Hecha | 1 | 4 (Baja) | 3 | 246 | 18 | 2,8 | 2,9 $ | +| HU-36 | Ranking | Hecha | 4 | 3 (Media) | 5 | 410 | 29 | 5,0 | 4,8 $ | +| HU-37 | Avisos | Hecha | 3 | 2 (Alta) | 5 | 410 | 29 | 4,9 | 4,8 $ | +| HU-38 | Seguir a otro usuario | Descartada (Won't) | 1 | 3 (Media) | 3 | 246 | 18 | 2,8 | 2,9 $ | +| HU-39 | Seguidos y seguidores | Descartada (Won't) | 1 | 3 (Media) | 2 | 164 | 12 | 1,9 | 1,9 $ | +| HU-40 | Actividad de los usuarios que sigo | Descartada (Won't) | 1 | 5 (Muy Baja) | 3 | 246 | 18 | 2,8 | 2,9 $ | +| HU-41 | Feed de actividad | Descartada (Won't) | 1 | 5 (Muy Baja) | 5 | 410 | 29 | 4,5 | 4,8 $ | +| HU-42 | Privacidad del perfil | Descartada (Won't) | 2 | 3 (Media) | 3 | 246 | 18 | 3,0 | 2,9 $ | +| HU-43 | Copiar una apuesta | Descartada (Won't) | 2 | 4 (Baja) | 5 | 410 | 29 | 4,7 | 4,8 $ | +| HU-44 | Apuestas a largo plazo | Hecha | 3 | 4 (Baja) | 5 | 410 | 29 | 4,9 | 4,8 $ | +| HU-45 | Gestión de mercados | Hecha | 3 | 4 (Baja) | 5 | 410 | 29 | 4,9 | 4,8 $ | +| HU-46 | Editar o borrar un evento | Hecha | 2 | 1 (Muy Alta) | 3 | 246 | 18 | 3,0 | 2,9 $ | +| HU-47 | Cambiar nombre y contraseña | Hecha | 2 | 2 (Alta) | 2 | 164 | 12 | 2,1 | 1,9 $ | +| HU-48 | Perfil público de un jugador | Hecha | 1 | 3 (Media) | 2 | 164 | 12 | 1,9 | 1,9 $ | +| HU-49 | Plantilla y alineación probable | Hecha | 2 | 4 (Baja) | 5 | 410 | 29 | 4,7 | 4,8 $ | +| HU-50 | Plantillas desde la API y a mano | Hecha | 2 | 2 (Alta) | 5 | 410 | 29 | 4,7 | 4,8 $ | +| HU-51 | Ligas privadas | Hecha | 3 | 4 (Baja) | 5 | 410 | 29 | 4,9 | 4,8 $ | +| HU-52 | Doble oportunidad, goles y ambos marcan | Hecha | 3 | 3 (Media) | 8 | 656 | 47 | 7,5 | 7,8 $ | +| HU-54 | Cuentas de la casa | Hecha | 2 | 4 (Baja) | 3 | 246 | 18 | 3,0 | 2,9 $ | **53 historias**: 45 hechas y 8 descartadas. **107 criterios de aceptación** en total; 94 de ellos son de historias hechas. @@ -114,9 +157,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** dar de alta competiciones, equipos y deportistas con su calificación de calidad (0–10), **para** que el sistema tenga la base con la que calcular las cuotas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 1, 2 | Must | 1 | Hecha | 8 | Interfaz y datos | 32 → 10 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 1, 2 | Must | 1 | Hecha | 8 | 656 | 47 | 7,3 | 7,8 $ | **Criterios de aceptación** @@ -129,9 +172,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** ajustar manualmente el factor de forma reciente de un equipo (rachas, moral, lesiones…), **para** que las cuotas reflejen su momento actual y no solo su calidad general. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 3 | Must | 6 | Hecha | 2 | Reglas del negocio | 8 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 3 | Must | 6 | Hecha | 2 | 164 | 12 | 1,9 | 1,9 $ | **Criterios de aceptación** @@ -143,9 +186,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** que las cuotas iniciales (G/E/P) de cada evento se generen automáticamente con un margen para la casa, **para** no tener que fijarlas a mano. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 3 | Must | 2, 6 | Hecha | 11 (8 + 3) | Reglas del negocio | 44 → 20 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 3 | Must | 2, 6 | Hecha | 11 (8 + 3) | 902 | 65 | 10 | 10,7 $ | **Criterios de aceptación** @@ -159,9 +202,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** introducir el resultado final de un evento, **para** que se resuelvan todas las apuestas asociadas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 16, 17 | Must | 3 | Hecha | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 16, 17 | Must | 3 | Hecha | 5 | 410 | 29 | 4,7 | 4,8 $ | **Criterios de aceptación** @@ -174,9 +217,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** suspender o anular un evento (aplazamiento, error en las cuotas…), **para** que nadie apueste en condiciones incorrectas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 17 | Must | 3, 5 | Hecha | 4 (3 + 1) | Reglas del negocio | 16 → 7 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 17 | Must | 3, 5 | Hecha | 4 (3 + 1) | 328 | 24 | 3,8 | 3,9 $ | **Criterios de aceptación** @@ -189,9 +232,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** ver cuánto se ha apostado en cada resultado de un evento, **para** comprobar que el ajuste de cuotas funciona y detectar desequilibrios. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 17 | Must | 3 | Hecha | 2 | Interfaz y datos | 8 → 2 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 17 | Must | 3 | Hecha | 2 | 164 | 12 | 1,9 | 1,9 $ | **Criterios de aceptación** @@ -203,9 +246,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** fijar límites de apuesta (importe mínimo y máximo, número máximo de selecciones en una combinada), **para** controlar el riesgo de la casa. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 17 | Must | 4, 6 | Hecha | 3 (1 + 2) | Reglas del negocio | 12 → 5 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 17 | Must | 4, 6 | Hecha | 3 (1 + 2) | 246 | 18 | 2,8 | 2,9 $ | **Criterios de aceptación** @@ -217,9 +260,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** editar o borrar un evento que he creado mal (fecha, equipos, fase), **para** corregir errores sin tener que anularlo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 29 | Should | 8 | Hecha | 3 | Reglas del negocio | 12 → 5 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 29 | Should | 8 | Hecha | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -232,9 +275,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** ver un panel con lo que se ha apostado, lo que se ha pagado y el beneficio de la casa, **para** comprobar si las cuotas están bien ajustadas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 35 | Should | 11 | Hecha | 3 | Reglas del negocio | 12 → 5 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 35 | Should | 11 | Hecha | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -251,9 +294,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** visitante, **quiero** consultar el catálogo de eventos y sus cuotas sin estar registrado, **para** decidir si me interesa crear una cuenta. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 9 | Must | 1 | Hecha | 2 | Interfaz y datos | 8 → 2 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 9 | Must | 1 | Hecha | 2 | 164 | 12 | 2,1 | 1,9 $ | **Criterios de aceptación** @@ -266,9 +309,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** que la web se adapte a móvil y a ordenador, **para** poder apostar desde cualquier dispositivo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| Requisito no funcional | — | 9 | Hecha | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| Requisito no funcional | — | 9 | Hecha | 3 | 246 | 18 | 2,8 | 2,9 $ | **Criterios de aceptación** @@ -280,9 +323,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** poder fijarme límites de apuesta (diarios o semanales) o una pausa temporal, **para** jugar de forma responsable. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 27 | Could | 9 | Hecha | 6 (5 + 1) | Reglas del negocio | 24 → 11 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 27 | Could | 9 | Hecha | 6 (5 + 1) | 492 | 35 | 5,6 | 5,8 $ | **Criterios de aceptación** @@ -299,9 +342,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** registrarme y crear un perfil, **para** poder acceder a la aplicación con una cuenta propia. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 5 | Must | 1 | Hecha | 3 | Integración y seguridad | 12 → 7 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 5 | Must | 1 | Hecha | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -314,9 +357,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** iniciar sesión de forma segura, **para** proteger el acceso a mi cuenta y a mi saldo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 5, 8 | Must | 1 | Hecha | 3 | Integración y seguridad | 12 → 7 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 5, 8 | Must | 1 | Hecha | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -329,9 +372,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** consultar mi saldo de moneditas virtuales en cualquier momento, **para** tener control de cuánto tengo disponible para apostar. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 4, 12 | Must | 1 | Hecha | 2 | Interfaz y datos | 8 → 2 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 4, 12 | Must | 1 | Hecha | 2 | 164 | 12 | 2,1 | 1,9 $ | **Criterios de aceptación** @@ -344,9 +387,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** recibir una recarga inicial de moneditas al registrarme (y recargas periódicas gratuitas), **para** poder seguir jugando sin aportar dinero real. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 4 | Must | 1 | Hecha | 2 | Reglas del negocio | 8 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 4 | Must | 1 | Hecha | 2 | 164 | 12 | 2,1 | 1,9 $ | **Criterios de aceptación** @@ -359,9 +402,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** añadir y gestionar los datos de una cuenta bancaria (real o ficticia), **para** poder retirar mi saldo cuando lo desee. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 25 | Won't | — | Descartada (Won't) | 5 | Integración y seguridad | 20 → 11 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 25 | Won't | — | Descartada (Won't) | 5 | 410 | 29 | 4,7 | 4,8 $ | **Criterios de aceptación** @@ -374,9 +417,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** retirar mi saldo disponible a mi cuenta bancaria vinculada, **para** poder disponer de mis ganancias fuera de la aplicación. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 25 | Won't | — | Descartada (Won't) | 3 | Reglas del negocio | 12 → 5 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 25 | Won't | — | Descartada (Won't) | 3 | 246 | 18 | 3,1 | 2,9 $ | **Criterios de aceptación** @@ -390,9 +433,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** recuperar el acceso si olvido mi contraseña, **para** no perder mi cuenta ni mi saldo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 22 | Should | — | Hecha | 5 | Integración y seguridad | 20 → 11 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 22 | Should | — | Hecha | 5 | 410 | 29 | 4,7 | 4,8 $ | **Criterios de aceptación** @@ -405,9 +448,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** eliminar mi cuenta, **para** que se borren mis datos personales si dejo de usar la aplicación. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 22 | Should | 9 | Hecha | 3 | Reglas del negocio | 12 → 5 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 22 | Should | 9 | Hecha | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -420,9 +463,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** cambiar mi nombre y mi contraseña desde *Mi cuenta*, **para** mantener mis datos al día. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 30 | Could | 8 | Hecha | 2 | Integración y seguridad | 8 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 30 | Could | 8 | Hecha | 2 | 164 | 12 | 2,1 | 1,9 $ | **Criterios de aceptación** @@ -439,9 +482,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver una lista de eventos deportivos disponibles organizados por deporte y fecha, **para** poder apostar a tiempo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 9 | Must | 1 | Hecha | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 9 | Must | 1 | Hecha | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -454,9 +497,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver las cuotas asociadas a cada resultado posible de un evento, **para** apostar cuando el riesgo-recompensa compense. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 3, 9 | Must | 2 | Hecha | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 3, 9 | Must | 2 | Hecha | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -469,9 +512,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** administrador, **quiero** cargar o actualizar eventos y cuotas manualmente o desde una fuente externa, **para** corregir errores y mantener los datos al día. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 2, 13, 17 | Could, Must | 5 | Hecha | 8 | Integración y seguridad | 32 → 18 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 2, 13, 17 | Could, Must | 5 | Hecha | 8 | 656 | 47 | 7,3 | 7,8 $ | **Criterios de aceptación** @@ -484,9 +527,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** buscar un equipo o una competición y filtrar el catálogo, **para** encontrar rápido el evento que me interesa. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 21 | Should | 2 | Hecha | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 21 | Should | 2 | Hecha | 3 | 246 | 18 | 3,1 | 2,9 $ | **Criterios de aceptación** @@ -504,9 +547,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** seleccionar un evento y una cuota y confirmar una apuesta con parte de mi saldo, **para** intentar ganar más moneditas si acierto el resultado. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 6 | Must | 2 | Hecha | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 6 | Must | 2 | Hecha | 5 | 410 | 29 | 4,7 | 4,8 $ | **Criterios de aceptación** @@ -519,9 +562,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver el estado de mis apuestas activas, **para** saber en todo momento cuánto tengo comprometido y en qué apuestas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 10, 11 | Must | 2 | Hecha | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 10, 11 | Must | 2 | Hecha | 3 | 246 | 18 | 2,8 | 2,9 $ | **Criterios de aceptación** @@ -533,9 +576,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** que mis apuestas se resuelvan automáticamente en cuanto termina el evento, **para** recibir mis ganancias (o ver la pérdida) sin tener que reclamarlo yo mismo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 16 | Must | 3, 5 | Hecha | 8 (5 + 3) | Reglas del negocio | 32 → 14 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 16 | Must | 3, 5 | Hecha | 8 (5 + 3) | 656 | 47 | 7,5 | 7,8 $ | **Criterios de aceptación** @@ -549,9 +592,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** cancelar una apuesta activa antes de que empiece el evento, **para** recuperar el importe si cambio de opinión. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 14 | Could | 2 | Hecha | 2 | Reglas del negocio | 8 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 14 | Could | 2 | Hecha | 2 | 164 | 12 | 1,9 | 1,9 $ | **Criterios de aceptación** @@ -564,9 +607,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** modificar el importe de una apuesta activa antes de que empiece el evento, **para** ajustar lo que arriesgo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 14 | Could | 7 | Hecha | 2 | Reglas del negocio | 8 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 14 | Could | 7 | Hecha | 2 | 164 | 12 | 2,1 | 1,9 $ | **Criterios de aceptación** @@ -579,9 +622,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** apostar a más cosas de un partido de fútbol (doble oportunidad, más o menos de 2,5 goles y ambos marcan), **para** tener más opciones que el 1X2. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 34 | Should | 11 | Hecha | 8 | Reglas del negocio | 32 → 14 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 34 | Should | 11 | Hecha | 8 | 656 | 47 | 7,5 | 7,8 $ | **Criterios de aceptación** @@ -599,9 +642,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** añadir a un mismo boleto selecciones de varios eventos, **para** crear mi propia apuesta combinada. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 15, 18 | Must | 4 | Hecha | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 15, 18 | Must | 4 | Hecha | 5 | 410 | 29 | 4,9 | 4,8 $ | **Criterios de aceptación** @@ -615,9 +658,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** que el sistema calcule el multiplicador total del boleto y la ganancia potencial, **para** saber cuánto puedo ganar antes de confirmar. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 15, 18 | Must | 4 | Hecha | 5 | Interfaz y datos | 20 → 6 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 15, 18 | Must | 4 | Hecha | 5 | 410 | 29 | 4,9 | 4,8 $ | **Criterios de aceptación** @@ -631,9 +674,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** que mi combinada se resuelva según el resultado de todas sus selecciones, **para** cobrar solo si acierto todas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 16 | Must | 4 | Hecha | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 16 | Must | 4 | Hecha | 5 | 410 | 29 | 4,9 | 4,8 $ | **Criterios de aceptación** @@ -651,9 +694,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** consultar la ficha de un equipo o deportista con sus últimos resultados y su posición en la clasificación, **para** valorar su forma antes de apostar. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 20 | Should | 7 | Hecha | 5 | Interfaz y datos | 20 → 6 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 20 | Should | 7 | Hecha | 5 | 410 | 29 | 4,7 | 4,8 $ | **Criterios de aceptación** @@ -666,9 +709,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver la evolución de un equipo a lo largo de la temporada en un gráfico, **para** detectar tendencias (rachas, mejora o bajón). -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 20 | Should | 9 | Hecha | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 20 | Should | 9 | Hecha | 3 | 246 | 18 | 2,8 | 2,9 $ | **Criterios de aceptación** @@ -680,9 +723,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver la plantilla de cada equipo con la nota de cada jugador y su alineación probable, **para** saber con quién juega antes de apostar. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 32 | Should | 10 | Hecha | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 32 | Should | 10 | Hecha | 5 | 410 | 29 | 4,7 | 4,8 $ | **Criterios de aceptación** @@ -695,9 +738,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** descargar las plantillas reales desde la API y añadir, puntuar o quitar jugadores a mano, **para** mantenerlas al día. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 32 | Should | 10 | Hecha | 5 | Integración y seguridad | 20 → 11 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 32 | Should | 10 | Hecha | 5 | 410 | 29 | 4,7 | 4,8 $ | **Criterios de aceptación** @@ -710,9 +753,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** comparar cara a cara a los dos rivales de un evento, **para** decidir mi apuesta con más información. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 20 | Should | 7 | Hecha | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 20 | Should | 7 | Hecha | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -729,9 +772,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver mi historial completo de apuestas con sus resultados, **para** poder repasar mis decisiones pasadas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 19 | Should | 3 | Hecha | 2 | Interfaz y datos | 8 → 2 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 19 | Should | 3 | Hecha | 2 | 164 | 12 | 1,9 | 1,9 $ | **Criterios de aceptación** @@ -743,9 +786,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver estadísticas básicas de mi actividad (porcentaje de aciertos, rentabilidad), **para** saber si mi balance global es positivo o negativo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 19 | Should | 3 | Hecha | 3 | Reglas del negocio | 12 → 5 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 19 | Should | 3 | Hecha | 3 | 246 | 18 | 2,8 | 2,9 $ | **Criterios de aceptación** @@ -761,9 +804,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver un ranking de usuarios ordenado por ganancias o por porcentaje de aciertos, **para** poder compararme con mis compañeros. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 24 | Could | 2, 3 | Hecha | 5 (3 + 2) | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 24 | Could | 2, 3 | Hecha | 5 (3 + 2) | 410 | 29 | 5,0 | 4,8 $ | **Criterios de aceptación** @@ -778,9 +821,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** recibir una notificación cuando se resuelve una de mis apuestas, **para** saber si he ganado o perdido sin tener que revisar la aplicación constantemente. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 23 | Could | 7 | Hecha | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 23 | Could | 7 | Hecha | 5 | 410 | 29 | 4,9 | 4,8 $ | **Criterios de aceptación** @@ -794,9 +837,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** buscar y seguir a otro usuario desde su perfil, **para** poder ver después su actividad. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 26 | Won't | — | Descartada (Won't) | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 26 | Won't | — | Descartada (Won't) | 3 | 246 | 18 | 2,8 | 2,9 $ | **Criterios de aceptación** @@ -808,9 +851,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver una lista de a quién sigo y quién me sigue, **para** gestionar mis conexiones dentro de la app. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 26 | Won't | — | Descartada (Won't) | 2 | Interfaz y datos | 8 → 2 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 26 | Won't | — | Descartada (Won't) | 2 | 164 | 12 | 1,9 | 1,9 $ | **Criterios de aceptación** @@ -822,9 +865,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** consultar el historial de apuestas públicas y las estadísticas generales de los usuarios que sigo, **para** comparar mis resultados con los suyos. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 26 | Won't | — | Descartada (Won't) | 3 | Interfaz y datos | 12 → 4 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 26 | Won't | — | Descartada (Won't) | 3 | 246 | 18 | 2,8 | 2,9 $ | **Criterios de aceptación** @@ -836,9 +879,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver un feed de actividad con las últimas apuestas resueltas de los usuarios que sigo, **para** estar al tanto sin visitar cada perfil. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 26 | Won't | — | Descartada (Won't) | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 26 | Won't | — | Descartada (Won't) | 5 | 410 | 29 | 4,5 | 4,8 $ | **Criterios de aceptación** @@ -850,9 +893,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** elegir el nivel de privacidad de mi perfil (público, solo seguidores o privado), **para** decidir quién ve mis apuestas y mis estadísticas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 26 | Won't | — | Descartada (Won't) | 3 | Reglas del negocio | 12 → 5 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 26 | Won't | — | Descartada (Won't) | 3 | 246 | 18 | 3,0 | 2,9 $ | **Criterios de aceptación** @@ -865,9 +908,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** copiar a mi boleto una apuesta pendiente de un usuario al que sigo, **para** apostar lo mismo que él. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 26 | Won't | — | Descartada (Won't) | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 26 | Won't | — | Descartada (Won't) | 5 | 410 | 29 | 4,7 | 4,8 $ | **Criterios de aceptación** @@ -880,9 +923,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** ver el perfil público de otro jugador desde el ranking, con su porcentaje de aciertos y su balance, **para** compararme con él sin tener que seguirlo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 31 | Could | 8 | Hecha | 2 | Interfaz y datos | 8 → 2 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 31 | Could | 8 | Hecha | 2 | 164 | 12 | 1,9 | 1,9 $ | **Criterios de aceptación** @@ -894,9 +937,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** crear una liga privada con mis amigos y unirme con un código de invitación, **para** competir en un ranking solo entre nosotros. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 33 | Could | 11 | Hecha | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 33 | Could | 11 | Hecha | 5 | 410 | 29 | 4,9 | 4,8 $ | **Criterios de aceptación** @@ -914,9 +957,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** usuario, **quiero** apostar a quién ganará un premio o una competición (Balón de Oro, campeón de LaLiga, Pichichi…), **para** predecir resultados a largo plazo. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 28 | Should | 6 | Hecha | 5 | Reglas del negocio | 20 → 9 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 28 | Should | 6 | Hecha | 5 | 410 | 29 | 4,9 | 4,8 $ | **Criterios de aceptación** @@ -930,9 +973,9 @@ Cada historia tiene un **tamaño en puntos de historia** y una **estimación de **Como** creador de apuestas, **quiero** crear un mercado a largo plazo con sus candidatos y cuotas, cerrarlo y marcar el ganador, **para** gestionar este tipo de apuestas. -| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Tipo | Horas sin IA → con IA | -|---|---|---|---|---:|---|---:| -| 28 | Should | 6 | Hecha | 5 | Interfaz y datos | 20 → 6 | +| Fila del backlog | Prioridad | Sprint | Estado | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---|---|---|---:|---:|---:|---:|---:| +| 28 | Should | 6 | Hecha | 5 | 410 | 29 | 4,9 | 4,8 $ | **Criterios de aceptación** diff --git a/docs/wiki/Historias-de-usuario.md b/docs/wiki/Historias-de-usuario.md index 2767791..30b000c 100644 --- a/docs/wiki/Historias-de-usuario.md +++ b/docs/wiki/Historias-de-usuario.md @@ -26,15 +26,23 @@ En total hay **107 criterios de aceptación**; 94 son de historias hechas. ## Estimación -Cada historia tiene su tamaño en **puntos de historia** (Fibonacci) y una estimación de **horas sin IA y con IA**, según el tipo de trabajo. El método y la ficha de cada historia están en el [documento de historias](https://github.com/riverri/IS1/blob/main/docs/requisitos/historias-de-usuario.md#estimación). - -| | Puntos | Horas sin IA | Horas con IA | -|---|---:|---:|---:| -| Historias hechas (45) | 185 | 740 | 312 | -| Historias descartadas (8) | 29 | 116 | 49 | - -- **Sin IA:** 4 horas por punto (diseñar, programar, probar, revisar y documentar). -- **Con IA:** el 30 % en interfaz y datos, el 45 % en reglas del negocio y el 55 % en integración y seguridad. Lo que más tiempo sigue llevando es entender, revisar y probar lo que propone la IA. +Cada historia tiene su **tamaño** (puntos de historia y líneas de código), las **horas sin IA y con IA** y el **coste de la IA**. El método completo y la ficha de cada historia están en el [documento de historias](https://github.com/riverri/IS1/blob/main/docs/requisitos/historias-de-usuario.md#estimación). + +| | Puntos | Líneas | Horas sin IA | Horas con IA | Coste de la IA | +|---|---:|---:|---:|---:|---:| +| Historias hechas (45) | 185 | 15.170 | 1.091 | 177 | 179 $ | +| Historias descartadas (8) | 29 | 2.378 | 171 | 28 | 28 $ | + +- **Tamaño:** medido en el repositorio. Son 15.103 líneas de código real para 185 puntos, unas 82 líneas por punto. +- **Sin IA:** unas 14 líneas terminadas por hora. Sale de valores de referencia en tres puntos (25, 15 y 8 líneas/h). +- **Con IA:** se suman cuatro partes. + - **Dirigir a la IA:** medido. + - **Revisar el código:** unas 138 líneas/h. + - **Comprobar cada criterio:** 10 minutos por criterio. + - **Corregir fallos:** 2,5 fallos por cada mil líneas, medido en la revisión de los 34 fallos. + + Revisar es lo que más tiempo lleva. +- **Coste de la IA:** medido con los tokens de la sesión de trabajo y las tarifas de la API. Son unos 11,8 $ por cada mil líneas, casi todo por volver a leer el contexto. ## Por tema diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ApuestasApplication.java b/src/main/java/es/ucm/fdi/is1/apuestas/ApuestasApplication.java index 5b9186e..affee96 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/ApuestasApplication.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/ApuestasApplication.java @@ -5,11 +5,23 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.scheduling.annotation.EnableScheduling; +/** + * Punto de entrada de Better, la web de apuestas con moneditas virtuales. + * + */ @SpringBootApplication @ConfigurationPropertiesScan @EnableScheduling public class ApuestasApplication { + /** Arranca el servidor web embebido (puerto 8080, o el de la variable de entorno PORT). */ public static void main(String[] args) { SpringApplication.run(ApuestasApplication.class, args); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/ApiProperties.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/ApiProperties.java index 6f93df3..c1c77ad 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/ApiProperties.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/ApiProperties.java @@ -6,6 +6,8 @@ /** * Configuración de la API de datos deportivos (application.properties, prefijo apuestas.api). + * Spring la rellena al arrancar gracias a {@code @ConfigurationPropertiesScan}. Los intervalos de las tareas + * programadas no están aquí: los lee directamente {@link SincronizacionProgramada}. * * @param url dirección base de la API v4 de football-data.org * @param token clave personal; se lee de la variable de entorno FOOTBALL_DATA_TOKEN y nunca se sube a Git @@ -21,11 +23,16 @@ public record ApiProperties(String url, String token, List competiciones List competicionesPlantillas, int diasAtras, int diasAdelante, String zonaHoraria) { + /** Ligas de las que se descargan las plantillas: {@code competicionesPlantillas} o, si no hay, {@code competiciones}. */ public List ligasDePlantillas() { return competicionesPlantillas == null || competicionesPlantillas.isEmpty() ? competiciones : competicionesPlantillas; } + /** + * Si hay clave de la API. Sin ella no se sincroniza nada: las tareas programadas no hacen nada y los botones + * de Gestión muestran un aviso. + */ public boolean configurada() { return token != null && !token.isBlank(); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/FootballDataCliente.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/FootballDataCliente.java index 0aca180..656af81 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/FootballDataCliente.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/FootballDataCliente.java @@ -6,13 +6,20 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; -/** Cliente de la API v4 de football-data.org (plan gratuito: 10 peticiones por minuto). */ +/** + * Cliente de la API v4 de football-data.org (plan gratuito: 10 peticiones por minuto). + * Es la implementación real de {@link FuenteDatosDeportivos}: hace peticiones GET con el {@code RestClient} de + * Spring y convierte el JSON en {@link PartidoApi} y {@link PlantillaApi}. Los tiempos máximos de espera los pone + * {@link TiemposDeEsperaConfig}. Si la API da error o no responde, se lanza {@code RestClientException}. + */ @Component public class FootballDataCliente implements FuenteDatosDeportivos { private final RestClient http; public FootballDataCliente(RestClient.Builder builder, ApiProperties propiedades) { + // Todas las peticiones llevan la clave en la cabecera X-Auth-Token, que es donde la busca football-data.org. + // Sin clave se manda vacía; de todos modos, los servicios no llaman a la API si no está configurada this.http = builder .baseUrl(propiedades.url()) .defaultHeader("X-Auth-Token", propiedades.token() == null ? "" : propiedades.token()) @@ -21,10 +28,12 @@ public FootballDataCliente(RestClient.Builder builder, ApiProperties propiedades @Override public List partidos(String competicion, LocalDate desde, LocalDate hasta) { + // Las fechas van como AAAA-MM-DD, que es lo que da LocalDate.toString() PartidoApi.Respuesta respuesta = http.get() .uri("/competitions/{codigo}/matches?dateFrom={desde}&dateTo={hasta}", competicion, desde, hasta) .retrieve() .body(PartidoApi.Respuesta.class); + // Respuesta vacía o sin lista: no hay partidos return respuesta == null || respuesta.matches() == null ? List.of() : respuesta.matches(); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/FuenteDatosDeportivos.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/FuenteDatosDeportivos.java index 84909a7..45cd7dd 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/FuenteDatosDeportivos.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/FuenteDatosDeportivos.java @@ -3,11 +3,24 @@ import java.time.LocalDate; import java.util.List; -/** De dónde salen los partidos y resultados reales. Interfaz para poder sustituirla en las pruebas. */ +/** + * De dónde salen los partidos y resultados reales. Interfaz para poder sustituirla en las pruebas. + * La implementa {@link FootballDataCliente}; en las pruebas de sincronización se usa un objeto simulado, + * así no hace falta Internet ni la clave de la API. + */ public interface FuenteDatosDeportivos { + /** + * Partidos de una competición (código de la API, p. ej. PD) entre dos fechas. + * + * @throws org.springframework.web.client.RestClientException si la API da error o no responde + */ List partidos(String competicion, LocalDate desde, LocalDate hasta); - /** Equipos de una competición con sus plantillas. */ + /** + * Equipos de una competición con sus plantillas. + * + * @throws org.springframework.web.client.RestClientException si la API da error o no responde + */ List plantillas(String competicion); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/PartidoApi.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/PartidoApi.java index da849f9..5d1a62a 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/PartidoApi.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/PartidoApi.java @@ -7,11 +7,24 @@ /** * Partido tal como lo devuelve la API v4 de football-data.org * ({@code GET /competitions/{codigo}/matches}). Solo se leen los campos que usamos. + * Con {@code ignoreUnknown} el resto del JSON se descarta sin dar error. + * + * @param id identificador del partido en la API; se guarda en el evento como {@code idExterno} + * @param utcDate fecha y hora de inicio en UTC, en formato ISO-8601 (p. ej. 2026-10-10T19:00:00Z) + * @param status estado: SCHEDULED, TIMED, IN_PLAY, PAUSED, FINISHED, POSTPONED, SUSPENDED, CANCELLED… + * @param matchday número de jornada; puede ser null + * @param homeTeam equipo local + * @param awayTeam equipo visitante + * @param score ganador y marcador */ @JsonIgnoreProperties(ignoreUnknown = true) public record PartidoApi(Long id, String utcDate, String status, Integer matchday, EquipoApi homeTeam, EquipoApi awayTeam, MarcadorApi score) { + /** + * Equipo de un partido: identificador en la API, nombre largo, nombre corto y dirección del escudo. + * En una eliminatoria con el rival aún por decidir llega sin identificador. + */ @JsonIgnoreProperties(ignoreUnknown = true) public record EquipoApi(Integer id, String name, String shortName, String crest) { } @@ -19,14 +32,17 @@ public record EquipoApi(Integer id, String name, String shortName, String crest) /** * {@code winner}: HOME_TEAM, AWAY_TEAM, DRAW o null si no ha terminado. * {@code duration}: REGULAR, EXTRA_TIME o PENALTY_SHOOTOUT. {@code fullTime}: goles al final. + * {@code regularTime}: goles de los 90 minutos, el que se usa si hubo prórroga. */ @JsonIgnoreProperties(ignoreUnknown = true) public record MarcadorApi(String winner, String duration, GolesApi fullTime, GolesApi regularTime) { + /** Solo con el ganador, sin marcador (se usa en las pruebas). */ public MarcadorApi(String winner) { this(winner, null, null, null); } + /** Sin {@code regularTime} (se usa en las pruebas). */ public MarcadorApi(String winner, String duration, GolesApi fullTime) { this(winner, duration, fullTime, null); } @@ -42,15 +58,18 @@ public boolean isProrroga() { */ public GolesApi marcador90() { GolesApi goles = isProrroga() ? regularTime : fullTime; + // Un marcador a medias (un lado null) no sirve para resolver apuestas return goles != null && goles.home() != null && goles.away() != null ? goles : null; } } + /** Goles del local y del visitante; null mientras no se conocen. */ @JsonIgnoreProperties(ignoreUnknown = true) public record GolesApi(Integer home, Integer away) { } + /** Respuesta completa de la API: la lista de partidos viene en el campo {@code matches}. */ @JsonIgnoreProperties(ignoreUnknown = true) public record Respuesta(List matches) { } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/PlantillaApi.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/PlantillaApi.java index 33f5b40..2913abc 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/PlantillaApi.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/PlantillaApi.java @@ -7,16 +7,26 @@ /** * Equipo con su plantilla tal como lo devuelve la API v4 de football-data.org * ({@code GET /competitions/{codigo}/teams}). Solo se leen los campos que usamos. + * Lo usa {@link SincronizacionPlantillas} (HU-50). + * + * @param id identificador del equipo en la API (el mismo que en {@link PartidoApi.EquipoApi}) + * @param name nombre largo + * @param shortName nombre corto + * @param squad jugadores; vacía en las competiciones que no traen plantillas en el plan gratuito */ @JsonIgnoreProperties(ignoreUnknown = true) public record PlantillaApi(Integer id, String name, String shortName, List squad) { - /** {@code position}: "Goalkeeper", "Defence", "Midfield", "Offence" o una más detallada. */ + /** + * {@code position}: "Goalkeeper", "Defence", "Midfield", "Offence" o una más detallada. + * {@code id} se guarda en el jugador como {@code idExterno}; {@code dateOfBirth} empieza por AAAA-MM-DD. + */ @JsonIgnoreProperties(ignoreUnknown = true) public record JugadorApi(Integer id, String name, String position, String dateOfBirth, String nationality, Integer shirtNumber) { } + /** Respuesta completa de la API: la lista de equipos viene en el campo {@code teams}. */ @JsonIgnoreProperties(ignoreUnknown = true) public record Respuesta(List teams) { } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/ResumenSincronizacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/ResumenSincronizacion.java index d63c85f..102c7ea 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/ResumenSincronizacion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/ResumenSincronizacion.java @@ -3,7 +3,11 @@ import java.util.ArrayList; import java.util.List; -/** Lo que ha cambiado en una sincronización, para mostrarlo al creador de apuestas. */ +/** + * Lo que ha cambiado en una sincronización, para mostrarlo al creador de apuestas. + * La rellena {@link SincronizacionService} mientras sincroniza: los métodos que cuentan son de paquete para que + * solo pueda hacerlo él. En Gestión se muestra como mensaje y los errores aparte (HU-21). + */ public class ResumenSincronizacion { private int eventosNuevos; @@ -28,6 +32,7 @@ void equipoNuevo() { equiposNuevos++; } + /** Fallo en un partido o en una competición; la sincronización sigue con el resto. */ void error(String mensaje) { errores.add(mensaje); } @@ -52,6 +57,7 @@ public List getErrores() { return errores; } + /** Texto del mensaje de Gestión y del registro: "2 eventos nuevos, 1 actualizados, …". */ @Override public String toString() { return eventosNuevos + " eventos nuevos, " + eventosActualizados + " actualizados, " + resultados diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionPlantillas.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionPlantillas.java index 012925b..784ee08 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionPlantillas.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionPlantillas.java @@ -26,6 +26,9 @@ * Una petición por competición. Crea los jugadores nuevos, actualiza los datos de los que ya estaban * (sin tocar su nota, que es del creador) y quita los que han dejado el equipo. Los jugadores dados de * alta a mano no se tocan. + * + *

Implementa HU-50. La lanzan {@link SincronizacionProgramada} (una vez al día) y el botón + * Descargar plantillas de Gestión. */ @Service public class SincronizacionPlantillas { @@ -55,13 +58,22 @@ public String toString() { } } + /** + * Descarga y aplica las plantillas de cada liga de {@link ApiProperties#ligasDePlantillas()}. + * Si una liga falla, se apunta en los errores del resumen y se sigue con las demás. + * + * @return cuántos equipos se han actualizado y cuántos jugadores han entrado, cambiado o salido + * @throws IllegalStateException si no está la clave de la API + */ @Transactional public Resumen sincronizar() { if (!propiedades.configurada()) { throw new IllegalStateException( "Falta la clave de la API: define la variable de entorno FOOTBALL_DATA_TOKEN"); } + // Equipos ya actualizados: si un equipo llega por dos competiciones, solo cuenta la primera Set procesados = new HashSet<>(); + // Contadores que rellena actualizar(): [nuevos, actualizados, retirados] int[] cuenta = new int[3]; List errores = new ArrayList<>(); for (String codigo : propiedades.ligasDePlantillas()) { @@ -76,6 +88,7 @@ public Resumen sincronizar() { actualizar(equipo, datos.squad(), cuenta); } } catch (RestClientException e) { + // Sin conexión, límite de peticiones…: se apunta y se pasa a la liga siguiente LOG.warn("No se pudieron descargar las plantillas de {}: {}", codigo, e.getMessage()); errores.add(codigo + ": " + e.getMessage()); } @@ -94,6 +107,7 @@ private void actualizar(Equipo equipo, List plantilla, continue; // entrenadores y jugadores sin posición conocida } enLaPlantilla.add(datos.id()); + // Por el identificador de la API: así se reconoce aunque cambie de nombre o venga de otro equipo Jugador jugador = jugadores.findByIdExterno(datos.id()).orElse(null); if (jugador == null) { jugador = new Jugador(equipo, datos.name(), posicion); @@ -102,10 +116,13 @@ private void actualizar(Equipo equipo, List plantilla, } else { cuenta[1]++; } + // Solo los datos que da la API; la nota la pone el creador y no se toca (CA-50.1) jugador.actualizar(equipo, datos.name(), posicion, datos.shirtNumber(), datos.nationality(), fecha(datos.dateOfBirth())); jugadores.save(jugador); } + // Los de la API que ya no salen en la plantilla han dejado el equipo; los dados de alta a mano + // (sin identificador de la API) se quedan (CA-50.2) for (Jugador jugador : jugadores.findByEquipo(equipo)) { if (jugador.isDeLaApi() && !enLaPlantilla.contains(jugador.getIdExterno())) { jugadores.delete(jugador); @@ -114,7 +131,10 @@ private void actualizar(Equipo equipo, List plantilla, } } - /** Por su identificador en la API o por un nombre equivalente. No crea equipos nuevos. */ + /** + * Por su identificador en la API o por un nombre equivalente. No crea equipos nuevos. + * El nombre se compara con {@link Nombres#clave(String)}, que quita tildes, mayúsculas y palabras como "FC". + */ private Equipo buscar(PlantillaApi datos) { if (datos.id() == null) { return null; @@ -132,6 +152,7 @@ private Equipo buscar(PlantillaApi datos) { .orElse(null)); } + /** Fecha de nacimiento a partir de los 10 primeros caracteres (AAAA-MM-DD); null si falta o no es válida. */ private static LocalDate fecha(String texto) { if (texto == null || texto.length() < 10) { return null; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionProgramada.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionProgramada.java index bfa8abb..9ae56e6 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionProgramada.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionProgramada.java @@ -5,7 +5,17 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -/** Sincroniza con la API cada cierto tiempo (apuestas.api.intervalo), solo si hay clave configurada. */ +/** + * Sincroniza con la API cada cierto tiempo (apuestas.api.intervalo y apuestas.api.intervalo-plantillas), + * solo si hay clave configurada. + * Son dos tareas: partidos y resultados ({@link SincronizacionService}, cada 30 minutos) y plantillas + * ({@link SincronizacionPlantillas}, una vez al día). Así los resultados llegan y las apuestas se pagan sin que + * el creador tenga que hacer nada (HU-21, HU-25, HU-50). + * + *

Los tiempos van en formato ISO-8601 (PT30S = 30 segundos, PT2M = 2 minutos, PT30M = 30 minutos, + * P1D = 1 día) y se pueden cambiar en application.properties. Con {@code fixedDelay} el intervalo se cuenta + * desde que termina la ejecución anterior, así que una tarea nunca se solapa consigo misma. + */ @Component public class SincronizacionProgramada { @@ -26,16 +36,19 @@ public SincronizacionProgramada(SincronizacionService sincronizacion, Sincroniza @Scheduled(initialDelayString = "${apuestas.api.retraso-inicial-plantillas:PT2M}", fixedDelayString = "${apuestas.api.intervalo-plantillas:P1D}") public void sincronizarPlantillas() { + // Sin clave no se hace nada (sincronizar() lanzaría una excepción cada vez) if (!propiedades.configurada()) { return; } try { plantillas.sincronizar(); } catch (RuntimeException e) { + // Se apunta en el registro; la próxima ejecución se intenta igual LOG.error("Error al descargar las plantillas", e); } } + /** Partidos y resultados: 30 segundos después de arrancar y luego cada 30 minutos. */ @Scheduled(initialDelayString = "${apuestas.api.retraso-inicial:PT30S}", fixedDelayString = "${apuestas.api.intervalo:PT30M}") public void sincronizar() { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionService.java index 40d92bf..e223763 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/SincronizacionService.java @@ -32,6 +32,10 @@ * Sincroniza eventos y resultados con la API de datos deportivos (fila 13 del backlog, HU-21): * crea los partidos que faltan, actualiza fechas, introduce resultados (lo que resuelve las apuestas), * suspende los aplazados y anula los cancelados. + * + *

La lanzan {@link SincronizacionProgramada} (cada 30 minutos) y el botón Sincronizar de Gestión. + * Los partidos llegan de {@link FuenteDatosDeportivos}; los cambios de estado de los eventos y el pago de las + * apuestas los hace {@link ResolucionService}, igual que cuando los hace el creador a mano (HU-04, HU-05, HU-25). */ @Service public class SincronizacionService { @@ -70,12 +74,22 @@ public SincronizacionService(FuenteDatosDeportivos fuente, ApiProperties propied this.eventos = eventos; this.resolucion = resolucion; this.reloj = reloj; + // TransactionTemplate en vez de @Transactional: permite abrir una transacción por partido dentro del bucle this.transacciones = new TransactionTemplate(transacciones); } /** * Cada partido se guarda en su propia transacción: si uno falla, se apunta el error y los demás * se sincronizan (y se pagan) igual. + * + *

Con una sola transacción para todo, el fallo de un partido desharía también los demás. Además, así + * no queda ninguna transacción abierta mientras se espera la respuesta de la API. + * + *

Busca los partidos de cada competición configurada desde hace {@code diasAtras} días (para recoger + * resultados) hasta dentro de {@code diasAdelante} días (para dar de alta los próximos). + * + * @return lo que ha cambiado y los errores, para mostrarlo al creador + * @throws IllegalStateException si no está la clave de la API */ public ResumenSincronizacion sincronizar() { if (!propiedades.configurada()) { @@ -83,7 +97,9 @@ public ResumenSincronizacion sincronizar() { "Falta la clave de la API: define la variable de entorno FOOTBALL_DATA_TOKEN"); } ResumenSincronizacion resumen = new ResumenSincronizacion(); + // "Hoy" según el reloj de la aplicación (hora de Madrid), no el del servidor LocalDate hoy = LocalDate.now(reloj); + // Una petición por competición (PD y CL por defecto) for (String codigo : propiedades.competiciones()) { try { List partidos = fuente.partidos(codigo, hoy.minusDays(propiedades.diasAtras()), @@ -92,11 +108,13 @@ public ResumenSincronizacion sincronizar() { try { transacciones.executeWithoutResult(t -> procesar(partido, competicion(codigo), resumen)); } catch (RuntimeException e) { + // Se deshacen solo los cambios de este partido y se sigue con el siguiente LOG.warn("No se pudo sincronizar el partido {}: {}", partido.id(), e.getMessage()); resumen.error("Partido " + partido.id() + ": " + e.getMessage()); } } } catch (RestClientException e) { + // La API dio error o no respondió para esta competición: se pasa a la siguiente LOG.warn("No se pudo sincronizar {}: {}", codigo, e.getMessage()); resumen.error(codigo + ": " + e.getMessage()); } @@ -105,6 +123,7 @@ public ResumenSincronizacion sincronizar() { return resumen; } + /** Busca o crea el evento de un partido de la API, actualiza su fecha y le aplica el estado del partido. */ private void procesar(PartidoApi partido, Competicion competicion, ResumenSincronizacion resumen) { if (partido.homeTeam() == null || partido.homeTeam().id() == null || partido.awayTeam() == null || partido.awayTeam().id() == null) { @@ -112,9 +131,12 @@ private void procesar(PartidoApi partido, Competicion competicion, ResumenSincro } Equipo local = equipo(partido.homeTeam(), competicion, resumen); Equipo visitante = equipo(partido.awayTeam(), competicion, resumen); + // La API da la hora en UTC; los eventos se guardan en la zona de la aplicación (Madrid) LocalDateTime fecha = LocalDateTime.ofInstant(Instant.parse(partido.utcDate()), ZoneId.of(propiedades.zonaHoraria())); + // Emparejamiento: primero por el identificador de la API; si no, un evento con los mismos equipos + // dentro de ±2 días (uno creado a mano o por DatosIniciales, quizá con otra hora) Evento evento = eventos.findByIdExterno(partido.id()) .or(() -> eventos.findByLocalAndVisitanteAndFechaHoraBetween(local, visitante, fecha.minusDays(2), fecha.plusDays(2)).stream().findFirst()) @@ -123,22 +145,30 @@ private void procesar(PartidoApi partido, Competicion competicion, ResumenSincro evento = eventos.save(new Evento(competicion, local, visitante, fecha)); resumen.eventoNuevo(); } else if (!fecha.equals(evento.getFechaHora()) || evento.getIdExterno() == null) { + // Ha cambiado la hora o es la primera vez que se enlaza. Evento solo cambia la fecha si aún no se ha jugado evento.cambiarFecha(fecha); resumen.eventoActualizado(); } + // Enlazado: en las siguientes sincronizaciones se encuentra directamente por el identificador evento.setIdExterno(partido.id()); + // La jornada solo se pone si no tiene fase: no pisa la que haya puesto el creador if (evento.getFase() == null && partido.matchday() != null) { evento.setFase("Jornada " + partido.matchday()); } aplicarEstado(partido, evento, resumen); } + /** + * Traduce el estado del partido en la API a un cambio en el evento, a través de {@link ResolucionService}. + * Solo actúa si el evento no está ya así, para no repetir el mismo cambio en cada sincronización. + */ private void aplicarEstado(PartidoApi partido, Evento evento, ResumenSincronizacion resumen) { if (evento.getEstado() == EstadoEvento.ANULADO) { return; // lo anuló el creador: la API no lo resucita ni le pone resultado } String estado = partido.status() == null ? "" : partido.status(); switch (estado) { + // Terminado: las apuestas se deciden con el resultado de los 90 minutos case "FINISHED" -> resultado(partido).ifPresent(r -> { PartidoApi.MarcadorApi marcador = partido.score(); PartidoApi.GolesApi goles = marcador.marcador90(); @@ -148,6 +178,8 @@ private void aplicarEstado(PartidoApi partido, Evento evento, ResumenSincronizac + evento.getVisitante().getNombre() + "): hubo prórroga y la API no da el marcador de " + "los 90 minutos; introdúcelo a mano en Gestión"); } else if (goles != null && evento.getDeporte().isAdmiteEmpate()) { + // Con marcador se resuelven el 1X2, los goles y ambos marcan (HU-52). + // Solo si es nuevo o distinto del guardado (una corrección vuelve a resolver las apuestas) int local = goles.home(); int visitante = goles.away(); if (evento.getEstado() != EstadoEvento.FINALIZADO @@ -157,16 +189,20 @@ private void aplicarEstado(PartidoApi partido, Evento evento, ResumenSincronizac resumen.resultado(); } } else if (evento.getEstado() != EstadoEvento.FINALIZADO || evento.getResultado() != r) { + // Solo se sabe el ganador: se resuelve el 1X2 y las de goles y ambos marcan se anulan resolucion.introducirResultado(evento.getId(), r); resumen.resultado(); } }); case "POSTPONED", "SUSPENDED" -> { + // Aplazado: deja de admitir apuestas. Queda marcado como suspendido por la API para que la + // sincronización lo reactive sola cuando vuelva a tener fecha if (evento.getEstado() == EstadoEvento.PROGRAMADO) { resolucion.suspenderPorApi(evento.getId()); } } case "CANCELLED" -> { + // Cancelado: se anula y se devuelve lo apostado (HU-05) if (evento.getEstado() == EstadoEvento.PROGRAMADO || evento.getEstado() == EstadoEvento.SUSPENDIDO) { resolucion.anular(evento.getId()); } @@ -183,6 +219,10 @@ private void aplicarEstado(PartidoApi partido, Evento evento, ResumenSincronizac } } + /** + * Ganador según la API; vacío si aún no lo hay. Si hubo prórroga o penaltis, es el ganador final, + * no el de los 90 minutos. + */ private static Optional resultado(PartidoApi partido) { if (partido.score() == null || partido.score().winner() == null) { return Optional.empty(); @@ -195,6 +235,7 @@ private static Optional resultado(PartidoApi partido) { }; } + /** Nuestra competición para un código de la API (PD → LaLiga); si no existe, se crea como de fútbol. */ private Competicion competicion(String codigo) { String nombre = COMPETICIONES.getOrDefault(codigo, codigo); return competiciones.findByNombre(nombre) @@ -215,22 +256,30 @@ private Equipo equipo(PartidoApi.EquipoApi datos, Competicion competicion, Resum equipo = equipos.save(new Equipo(nombre, Deporte.FUTBOL, CALIDAD_INICIAL)); resumen.equipoNuevo(); } + // Enlazado con la API: la próxima vez se encuentra por el identificador if (equipo.getIdExterno() == null) { equipo.setIdExterno(datos.id()); } + // El escudo solo se pone si no tiene uno (no pisa el de Gestión) y solo con direcciones https:// if (equipo.getEscudoUrl() == null && datos.crest() != null && datos.crest().startsWith("https://")) { equipo.setEscudoUrl(datos.crest()); } + // Se apunta en la competición si aún no estaba (p. ej. un equipo de LaLiga que juega la Champions) if (!equipo.participaEn(competicion.getId())) { equipo.participaEn(competicion); } return equipo; } + /** + * Equipo de fútbol aún sin enlazar cuyo nombre equivale al largo o al corto de la API. + * {@link Nombres#clave(String)} quita tildes, mayúsculas y palabras como "Club" o "FC". + */ private Equipo porNombre(PartidoApi.EquipoApi datos) { String clave = Nombres.clave(datos.name()); String claveCorta = Nombres.clave(datos.shortName()); return equipos.findByDeporte(Deporte.FUTBOL).stream() + // Un equipo ya enlazado con otro de la API no es este, aunque el nombre se parezca .filter(e -> e.getIdExterno() == null) .filter(e -> { String nuestra = Nombres.clave(e.getNombre()); @@ -240,6 +289,7 @@ private Equipo porNombre(PartidoApi.EquipoApi datos) { .orElse(null); } + /** Nombre de un equipo nuevo: el largo; si no hay, el corto; si tampoco, "Equipo" y su identificador. */ private static String nombreVisible(PartidoApi.EquipoApi datos) { if (datos.name() != null && !datos.name().isBlank()) { return datos.name(); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/api/TiemposDeEsperaConfig.java b/src/main/java/es/ucm/fdi/is1/apuestas/api/TiemposDeEsperaConfig.java index 25d93fe..4393653 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/api/TiemposDeEsperaConfig.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/api/TiemposDeEsperaConfig.java @@ -14,9 +14,16 @@ @Configuration public class TiemposDeEsperaConfig { + /** Máximo para conectar con el servidor de la API. */ static final Duration ESPERA_CONEXION = Duration.ofSeconds(5); + /** Máximo esperando la respuesta, una vez conectados. */ static final Duration ESPERA_RESPUESTA = Duration.ofSeconds(20); + /** + * Spring aplica este ajuste a los {@code RestClient.Builder} que crea, como el que recibe + * {@link FootballDataCliente}. Si se pasa el tiempo, la llamada lanza una {@code RestClientException} + * y la sincronización la apunta como un error más. + */ @Bean public RestClientCustomizer tiemposDeEspera() { return builder -> { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Apuesta.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Apuesta.java index be33a2c..ff0aef0 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Apuesta.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Apuesta.java @@ -29,6 +29,11 @@ * Apuesta de un usuario: una selección (simple) o varias de eventos distintos (combinada, HU-28). * La cuota total es el producto de las cuotas de las selecciones (HU-29). * Las apuestas a largo plazo (HU-44) tienen una sola selección y no se combinan. + * + *

Entidad JPA: guarda el importe, la cuota total, el estado y lo ya pagado al usuario. Sus + * {@link Seleccion}es se guardan y se borran con ella (cascade). El estado se recalcula cada vez que se + * resuelve o se anula una selección ({@code reevaluar}), y aquí se hacen los pagos, devoluciones y + * cambios de importe sobre el saldo del {@link Usuario}. El cobro inicial lo hace {@link ApuestaService}. */ @Entity public class Apuesta { @@ -36,6 +41,7 @@ public class Apuesta { /** * Cuota total máxima de una combinada. Sin tope, 10 selecciones de cuota 50 darían una cuota de 50^10: * no cabe en la base de datos y un acierto improbable descuadraría el ranking. + * Se comprueba en {@link ApuestaService} antes de apostar y al añadir al boleto. */ public static final BigDecimal CUOTA_TOTAL_MAXIMA = new BigDecimal("1000"); @@ -50,13 +56,18 @@ public class Apuesta { @OrderBy("id") private List selecciones = new ArrayList<>(); + /** Moneditas apostadas: se descuentan del saldo al apostar. */ @Column(nullable = false, precision = 12, scale = 2) private BigDecimal importe; - /** Producto de las cuotas de todas las selecciones en el momento de apostar. */ + /** + * Producto de las cuotas de todas las selecciones en el momento de apostar, redondeado hacia abajo + * a 2 decimales. Se recalcula si el usuario cambia el importe (HU-27). + */ @Column(nullable = false, precision = 12, scale = 2) private BigDecimal cuota; + /** Empieza activa; cambia al resolverse o anularse sus selecciones, o al cancelarla el usuario. */ @Column(nullable = false) @Enumerated(EnumType.STRING) private EstadoApuesta estado = EstadoApuesta.ACTIVA; @@ -72,6 +83,7 @@ protected Apuesta() { // requerido por JPA } + /** Apuesta vacía; la cuota empieza en 1,00 y se multiplica con cada selección que se añade. */ public Apuesta(Usuario usuario, BigDecimal importe, LocalDateTime fecha) { this.usuario = usuario; this.importe = importe; @@ -89,6 +101,7 @@ public void anadir(Evento evento, Especial especial, BigDecimal cuotaSeleccion) anadir(evento, new Seleccion(this, evento, especial, cuotaSeleccion)); } + /** Lanza IllegalArgumentException si ya hay una a largo plazo o una del mismo evento. */ private void anadir(Evento evento, Seleccion nueva) { if (selecciones.stream().anyMatch(Seleccion::isLargoPlazo)) { throw new IllegalArgumentException("Las apuestas a largo plazo no se pueden combinar"); @@ -97,6 +110,7 @@ private void anadir(Evento evento, Seleccion nueva) { throw new IllegalArgumentException("No se pueden combinar dos selecciones del mismo evento"); } selecciones.add(nueva); + // Multiplicador total (HU-29): producto de las cuotas de todas las selecciones cuota = producto(selecciones.stream().map(Seleccion::getCuota).toList()); } @@ -129,6 +143,7 @@ public void anular(Mercado mercado) { /** Resultado de uno de sus eventos: se resuelve la selección y se recalcula la apuesta (HU-25, HU-30). */ public void resolver(Evento evento, Resultado resultado) { + // Una apuesta cancelada ya devolvió el importe: no se resuelve (igual en los otros resolver y anular) if (estado == EstadoApuesta.CANCELADA) { return; } @@ -155,6 +170,7 @@ private void reevaluar() { boolean algunaPendiente = selecciones.stream().anyMatch(s -> s.getEstado() == EstadoSeleccion.PENDIENTE); boolean todasAnuladas = selecciones.stream().allMatch(s -> s.getEstado() == EstadoSeleccion.ANULADA); + // El orden importa: una fallada pierde la combinada aunque queden otras pendientes (CA-30.1) if (algunaFallada) { estado = EstadoApuesta.PERDIDA; } else if (algunaPendiente) { @@ -162,14 +178,18 @@ private void reevaluar() { } else if (todasAnuladas) { estado = EstadoApuesta.ANULADA; } else { + // Acertadas, o acertadas y anuladas: las anuladas cuentan con cuota 1,00 (CA-30.3) estado = EstadoApuesta.GANADA; } + // Lo que le corresponde al usuario en el estado actual; la ganancia ya incluye el importe apostado BigDecimal debePagar = switch (estado) { case GANADA -> getGananciaPotencial(); case ANULADA -> importe; default -> BigDecimal.ZERO; }; + // Solo se abona la diferencia con lo pagado antes. Si se corrige un resultado y una ganada pasa a + // perdida, la diferencia es negativa y se le retira lo cobrado (el saldo puede quedar negativo) usuario.ajustar(debePagar.subtract(pagado)); pagado = debePagar; } @@ -182,7 +202,11 @@ public boolean cancelable(LocalDateTime ahora) { return estado == EstadoApuesta.ACTIVA && selecciones.stream().allMatch(s -> s.admiteCancelacion(ahora)); } - /** Cancela la apuesta y devuelve el importe al usuario. */ + /** + * Cancela la apuesta y devuelve el importe al usuario (HU-26). + * + * @throws IllegalStateException si ya no es cancelable (ver {@link #cancelable}) + */ public void cancelar(LocalDateTime ahora) { if (!cancelable(ahora)) { throw new IllegalStateException("La apuesta ya no se puede cancelar"); @@ -194,11 +218,15 @@ public void cancelar(LocalDateTime ahora) { /** * Cambia el importe mientras se pueda cancelar (HU-27): se cobra o se devuelve la diferencia * y se aplican las cuotas actuales, una por selección y en el mismo orden. + * + * @throws IllegalStateException si ya no se puede cancelar + * @throws es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException si sube el importe y no hay saldo */ public void cambiarImporte(BigDecimal nuevo, List cuotasActuales, LocalDateTime ahora) { if (!cancelable(ahora)) { throw new IllegalStateException("La apuesta ya no se puede modificar"); } + // Sube: se cobra solo la diferencia. Baja: se devuelve la diferencia (CA-27.1, CA-27.2) BigDecimal diferencia = nuevo.subtract(importe); if (diferencia.signum() > 0) { usuario.cargar(diferencia); @@ -206,6 +234,7 @@ public void cambiarImporte(BigDecimal nuevo, List cuotasActuales, Lo usuario.abonar(diferencia.negate()); } importe = nuevo; + // La cuota vista al apostar ya no vale: toda la apuesta pasa a la cuota vigente ahora for (int i = 0; i < selecciones.size(); i++) { selecciones.get(i).actualizarCuota(cuotasActuales.get(i)); } @@ -217,11 +246,15 @@ public BigDecimal getCuotaEfectiva() { return producto(selecciones.stream().map(Seleccion::getCuotaEfectiva).toList()); } - /** Lo que cobra el usuario si acierta: importe × cuota (HU-29). */ + /** + * Lo que cobra el usuario si acierta: importe × cuota (HU-29), con el importe incluido. + * Se redondea hacia abajo para no pagar nunca un céntimo de más. + */ public BigDecimal getGananciaPotencial() { return importe.multiply(getCuotaEfectiva()).setScale(2, RoundingMode.DOWN); } + /** Ganada o perdida. Las anuladas y canceladas no cuentan en las estadísticas (HU-35). */ public boolean isResuelta() { return estado == EstadoApuesta.GANADA || estado == EstadoApuesta.PERDIDA; } @@ -230,7 +263,7 @@ public boolean isCombinada() { return selecciones.size() > 1; } - /** Beneficio neto de una apuesta resuelta: ganancia − importe si se acierta, −importe si no. */ + /** Beneficio neto de una apuesta resuelta: ganancia − importe si se acierta, −importe si no; 0 en otro estado. */ public BigDecimal getBeneficio() { return switch (estado) { case GANADA -> getGananciaPotencial().subtract(importe); @@ -253,6 +286,10 @@ private Seleccion seleccionDe(Mercado mercado) { .orElseThrow(() -> new IllegalArgumentException("La apuesta no incluye ese mercado")); } + /** + * Producto de varias cuotas redondeado hacia abajo a 2 decimales (1,00 si no hay ninguna). + * Lo usan también los servicios para calcular la cuota total antes de apostar. + */ static BigDecimal producto(List cuotas) { return cuotas.stream().reduce(BigDecimal.ONE, BigDecimal::multiply).setScale(2, RoundingMode.DOWN); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaController.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaController.java index 0ce49c5..1ade255 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaController.java @@ -25,6 +25,12 @@ import es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException; import jakarta.validation.Valid; +/** + * Controlador web de las apuestas del jugador: apuesta simple desde la página del partido (HU-23, HU-52), + * Mis apuestas (HU-24, HU-34, HU-35), cancelar (HU-26) y cambiar el importe (HU-27), además del ranking + * (HU-36) y el perfil público de un jugador (HU-48). Delega en {@link ApuestaService} y {@link RankingService} + * y convierte sus excepciones en mensajes para el usuario. + */ @Controller public class ApuestaController { @@ -49,7 +55,12 @@ public ApuestaController(ApuestaService apuestas, CatalogoService catalogo, Calc this.reloj = reloj; } - /** Requiere sesión iniciada: un visitante es redirigido al login (HU-08). */ + /** + * GET /eventos/{id}/apostar: página del partido con sus cuotas y el formulario de apuesta simple. + * Puede llegar con el pronóstico ya marcado ({@code resultado} o {@code especial}). + * Requiere sesión iniciada: un visitante es redirigido al login (HU-08). + * Si el evento ya no admite apuestas, 404 ({@link es.ucm.fdi.is1.apuestas.eventos.EventoNoDisponibleException}). + */ @GetMapping("/eventos/{id}/apostar") public String formulario(@PathVariable Long id, @RequestParam(required = false) Resultado resultado, @RequestParam(required = false) Especial especial, Model model) { @@ -60,11 +71,16 @@ public String formulario(@PathVariable Long id, @RequestParam(required = false) return vista(catalogo.eventoDisponible(id), model); } + /** + * POST /eventos/{id}/apostar: registra la apuesta simple. Si sale bien, lleva a Mis apuestas con la + * ganancia potencial; si no, vuelve a la página del partido con el error junto al campo afectado. + */ @PostMapping("/eventos/{id}/apostar") public String apostar(@PathVariable Long id, @Valid @ModelAttribute("apuesta") ApuestaForm form, BindingResult errores, Principal principal, Model model, RedirectAttributes redireccion) { Evento evento = catalogo.eventoDisponible(id); + // Primero las validaciones del formulario (@Valid); solo si pasan se intenta apostar if (!errores.hasErrors()) { try { Apuesta apuesta = apuestas.apostar(principal.getName(), List.of( @@ -85,30 +101,42 @@ public String apostar(@PathVariable Long id, @Valid @ModelAttribute("apuesta") A errores.rejectValue("opcion", "cuotaCambiada", "La cuota ha cambiado mientras decidías. Revisa la nueva y confirma otra vez."); } catch (IllegalArgumentException e) { + // El resto de reglas (cuota total, combinar, creador…); va la última porque + // ImporteFueraDeLimitesException y JuegoResponsableException son subclases suyas errores.rejectValue("opcion", "invalido", e.getMessage()); } } return vista(evento, model); } + /** + * GET /apuestas: Mis apuestas. Las activas con el total comprometido (HU-24), el historial (HU-34) + * y las estadísticas (HU-35). + */ @GetMapping("/apuestas") public String misApuestas(Principal principal, Model model) { List activas = apuestas.activas(principal.getName()); + // Total comprometido: suma de los importes de las apuestas activas BigDecimal comprometido = activas.stream().map(Apuesta::getImporte).reduce(BigDecimal.ZERO, BigDecimal::add); model.addAttribute("activas", activas); model.addAttribute("comprometido", comprometido); + // La vista llama a cancelable(ahora) para mostrar o no los botones de cancelar y cambiar importe model.addAttribute("ahora", LocalDateTime.now(reloj)); model.addAttribute("historial", apuestas.historial(principal.getName())); model.addAttribute("estadisticas", apuestas.estadisticas(principal.getName())); return "apuestas"; } - /** Ranking público: top 20 y, si no estás entre ellos, también tu posición (HU-36). */ + /** + * GET /ranking?orden=...: ranking público, por saldo si no se indica criterio. Muestra el top 20 y, + * si no estás entre ellos, también tu posición (HU-36, CA-36.4). Sin sesión no hay "tu posición". + */ @GetMapping("/ranking") public String ranking(@RequestParam(defaultValue = "SALDO") CriterioRanking orden, Principal principal, Model model) { List todos = rankings.ranking(principal == null ? null : principal.getName(), orden); model.addAttribute("primeros", todos.stream().limit(20).toList()); + // Tu fila aparte solo si no sale ya en el top 20 model.addAttribute("mio", todos.stream().filter(PuestoRanking::soyYo) .filter(p -> p.posicion() > 20).findFirst().orElse(null)); model.addAttribute("total", todos.size()); @@ -117,13 +145,20 @@ public String ranking(@RequestParam(defaultValue = "SALDO") CriterioRanking orde return "ranking"; } - /** Perfil público de un jugador desde el ranking (HU-48). */ + /** + * GET /jugadores/{id}: perfil público de un jugador desde el ranking (HU-48), con sus estadísticas + * pero sin sus apuestas. 404 si no es un jugador del ranking ({@link JugadorNoEncontradoException}). + */ @GetMapping("/jugadores/{id}") public String jugador(@PathVariable Long id, Principal principal, Model model) { model.addAttribute("perfil", rankings.perfil(id, principal == null ? null : principal.getName())); return "jugador"; } + /** + * POST /apuestas/{id}/cancelar: cancela la apuesta y devuelve el importe (HU-26). Vuelve a Mis apuestas + * con un mensaje. Si la apuesta no es del usuario, 404 ({@link ApuestaNoEncontradaException}). + */ @PostMapping("/apuestas/{id}/cancelar") public String cancelar(@PathVariable Long id, Principal principal, RedirectAttributes redireccion) { try { @@ -136,10 +171,14 @@ public String cancelar(@PathVariable Long id, Principal principal, RedirectAttri return "redirect:/apuestas"; } - /** Cambiar el importe de una apuesta activa (HU-27). */ + /** + * POST /apuestas/{id}/importe: cambia el importe de una apuesta activa (HU-27) y vuelve a Mis apuestas + * con la nueva cuota y la nueva ganancia potencial, o con el error. + */ @PostMapping("/apuestas/{id}/importe") public String modificarImporte(@PathVariable Long id, @RequestParam(required = false) BigDecimal importe, Principal principal, RedirectAttributes redireccion) { + // Las mismas reglas que @DecimalMin y @Digits de ApuestaForm, a mano porque aquí no hay formulario if (importe == null || importe.signum() <= 0 || importe.stripTrailingZeros().scale() > 2) { redireccion.addFlashAttribute("error", "Introduce un importe válido, con 2 decimales como mucho"); return "redirect:/apuestas"; @@ -160,6 +199,10 @@ public String modificarImporte(@PathVariable Long id, @RequestParam(required = f return "redirect:/apuestas"; } + /** + * Rellena la página "apostar": cuotas 1X2, otros tipos de apuesta (solo en fútbol, HU-52), + * límites de importe y cara a cara de los dos equipos (HU-33). + */ private String vista(Evento evento, Model model) { model.addAttribute("evento", evento); model.addAttribute("cuotas", calculadora.calcular(evento)); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaForm.java index 3fe9f4a..7cd3ec9 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaForm.java @@ -12,6 +12,9 @@ /** * Pronóstico e importe de una apuesta simple. El pronóstico es un resultado (1X2) o un tipo especial (HU-52); * en la página se eligen con un único grupo de opciones ({@code opcion}). + * + *

Formulario de la página del partido ({@link ApuestaController}). Las anotaciones de validación se + * comprueban con {@code @Valid}; los límites del creador y el saldo, después, en {@link ApuestaService}. */ public class ApuestaForm { @@ -27,6 +30,7 @@ public class ApuestaForm { @Digits(integer = 10, fraction = 2, message = "Como mucho 2 decimales") private BigDecimal importe; + /** Validación: hay que elegir un resultado o un tipo especial. */ @AssertTrue(message = "Elige un pronóstico") public boolean isOpcionElegida() { return resultado != null || especial != null; @@ -40,12 +44,17 @@ public String getOpcion() { return resultado != null ? resultado.name() : null; } + /** + * Traduce la opción elegida al resultado o al tipo especial que le corresponde. Un valor desconocido + * deja los dos a null y lo rechaza {@link #isOpcionElegida()}. + */ public void setOpcion(String opcion) { resultado = null; especial = null; if (opcion == null || opcion.isBlank()) { return; } + // Se busca a mano en vez de valueOf para no lanzar excepción con un valor manipulado for (Resultado r : Resultado.values()) { if (r.name().equals(opcion)) { resultado = r; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaNoEncontradaException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaNoEncontradaException.java index a21f22b..2f6db98 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaNoEncontradaException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaNoEncontradaException.java @@ -3,6 +3,11 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * La apuesta no existe o no es del usuario que la pide (cancelar o cambiar el importe, HU-26, HU-27). + * Así no se revela si existe una apuesta ajena. Si nadie la captura, Spring responde con un 404 + * gracias a {@code @ResponseStatus}. + */ @ResponseStatus(HttpStatus.NOT_FOUND) public class ApuestaNoEncontradaException extends RuntimeException { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaRepository.java index 7fc28dd..dd5af03 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaRepository.java @@ -10,14 +10,19 @@ import es.ucm.fdi.is1.apuestas.usuarios.Usuario; +/** Repositorio de {@link Apuesta}: consultas para Mis apuestas, el ranking, el juego responsable y el panel de la casa. */ public interface ApuestaRepository extends JpaRepository { + /** Apuestas del usuario en un estado, de la más reciente a la más antigua (activas, HU-24). */ List findByUsuarioAndEstadoOrderByFechaDesc(Usuario usuario, EstadoApuesta estado); + /** Igual, pero con varios estados a la vez (historial, HU-34). */ List findByUsuarioAndEstadoInOrderByFechaDesc(Usuario usuario, Collection estados); + /** Apuestas de todos los usuarios en esos estados (panel de la casa, HU-54). */ List findByEstadoIn(Collection estados); + /** Cuántas apuestas tiene el usuario en ese estado (apuestas en juego del perfil público, HU-48). */ long countByUsuarioAndEstado(Usuario usuario, EstadoApuesta estado); /** diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaService.java index c019351..d105843 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ApuestaService.java @@ -24,6 +24,16 @@ import es.ucm.fdi.is1.apuestas.usuarios.Usuario; import es.ucm.fdi.is1.apuestas.usuarios.UsuarioRepository; +/** + * Servicio de apuestas del jugador: apostar (simple, combinada o a largo plazo), cancelar, cambiar el importe + * y consultar las activas, el historial y las estadísticas (HU-10, HU-23, HU-24, HU-26, HU-27, HU-28, HU-29, + * HU-34, HU-35, HU-44, HU-52). + * + *

Aquí se comprueban las reglas antes de tocar el saldo: eventos disponibles, límites del creador (HU-07), + * juego responsable (HU-10) y cuotas vistas. Cada método público es una transacción: si algo falla no se cobra + * nada. Si dos operaciones cambian el saldo del mismo usuario a la vez, el {@code @Version} de + * {@link Usuario} hace fallar la segunda al confirmar la transacción. + */ @Service public class ApuestaService { @@ -61,6 +71,15 @@ public Apuesta apostar(String email, Long eventoId, Resultado resultado, BigDeci * apuestas, que no se repiten, los límites de apuesta (HU-07) y el saldo, y descuenta el importe. * Las cuotas se calculan en el servidor. Si el usuario vio una cuota distinta de la actual, no se apuesta * y se le pide que acepte las nuevas (HU-29). + * + * @throws EventoNoDisponibleException si algún evento no existe o ya no admite apuestas + * @throws ResultadoNoValidoException si algún pronóstico no se puede apostar en su evento + * @throws CuotasCambiadasException si alguna cuota vista no coincide con la actual + * @throws ImporteFueraDeLimitesException si el importe no respeta los límites del creador + * @throws JuegoResponsableException si el usuario está en pausa o supera sus límites personales + * @throws es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException si no tiene saldo + * @throws IllegalArgumentException si el boleto está vacío, tiene demasiadas selecciones, repite evento, + * la cuota total pasa del máximo o el usuario es el creador */ @Transactional public Apuesta apostar(String email, List pedidas, BigDecimal importe) { @@ -70,6 +89,7 @@ public Apuesta apostar(String email, List pedidas, BigDecimal i Limites limitesActuales = limites.actuales(); limitesActuales.comprobarSelecciones(pedidas.size()); limitesActuales.comprobarImporte(importe); + // Una sola hora para toda la operación: todas las selecciones se comprueban con el mismo "ahora" LocalDateTime ahora = LocalDateTime.now(reloj); Usuario usuario = usuarios.findByEmail(email).orElseThrow(); comprobarQueJuega(usuario); @@ -79,7 +99,9 @@ public Apuesta apostar(String email, List pedidas, BigDecimal i Evento evento = eventos.findById(pedida.eventoId()) .filter(e -> e.admiteApuestas(ahora)) .orElseThrow(() -> new EventoNoDisponibleException(pedida.eventoId())); + // La cuota se calcula aquí, nunca se fía de la que envía la página BigDecimal cuota = cuota(evento, pedida.resultado(), pedida.especial()); + // Se apuntan todas las cambiadas antes de avisar, para enseñarlas todas de una vez if (pedida.cuotaVista() != null && pedida.cuotaVista().compareTo(cuota) != 0) { cambiadas.add(evento.getId()); } @@ -94,18 +116,24 @@ public Apuesta apostar(String email, List pedidas, BigDecimal i } comprobarCuotaTotal(apuesta.getCuota()); comprobarJuegoResponsable(usuario, importe, ahora); + // Se cobra tras todas las comprobaciones; sin saldo lanza SaldoInsuficienteException y no se guarda nada (CA-23.2) usuario.cargar(importe); + // Las selecciones se guardan con la apuesta (cascade); el usuario, al confirmar la transacción return apuestas.save(apuesta); } /** * Apuesta a largo plazo (HU-44): al candidato de un mercado abierto, con la cuota que ha fijado el creador. * Si el usuario vio otra cuota (el creador la ha cambiado mientras tanto), no se apuesta. + * + * @throws MercadoNoDisponibleException si el candidato no es de ese mercado o el mercado no admite apuestas + * @throws CuotasCambiadasException si la cuota vista no es la actual (con la lista de eventos vacía) */ @Transactional public Apuesta apostarMercado(String email, Long mercadoId, Long candidatoId, BigDecimal cuotaVista, BigDecimal importe) { LocalDateTime ahora = LocalDateTime.now(reloj); + // El candidato tiene que ser del mercado de la URL y ese mercado tiene que seguir abierto Candidato candidato = candidatos.findById(candidatoId) .filter(c -> c.getMercado().getId().equals(mercadoId)) .filter(c -> c.getMercado().admiteApuestas(ahora)) @@ -127,12 +155,16 @@ public Apuesta apostarMercado(String email, Long mercadoId, Long candidatoId, Bi * Cancela una apuesta activa del usuario antes de que empiece el evento (o, a largo plazo, mientras el * mercado siga abierto) y le devuelve el importe (HU-26). * Si la apuesta no es suya se trata como inexistente. + * + * @throws ApuestaNoEncontradaException si no existe o es de otro usuario + * @throws IllegalStateException si ya no se puede cancelar */ @Transactional public Apuesta cancelar(String email, Long apuestaId) { Apuesta apuesta = apuestas.findById(apuestaId) .filter(a -> a.getUsuario().getEmail().equals(email)) .orElseThrow(() -> new ApuestaNoEncontradaException(apuestaId)); + // No hace falta save: la apuesta está gestionada por JPA y los cambios se guardan al terminar la transacción apuesta.cancelar(LocalDateTime.now(reloj)); return apuesta; } @@ -140,6 +172,11 @@ public Apuesta cancelar(String email, Long apuestaId) { /** * Cambia el importe de una apuesta activa del usuario antes de que empiece el evento (HU-27). * Se aplican los límites de apuesta y las cuotas actuales, y se cobra o devuelve la diferencia. + * + * @throws ApuestaNoEncontradaException si no existe o es de otro usuario + * @throws IllegalStateException si alguno de sus eventos ya ha empezado + * @throws IllegalArgumentException si el nuevo importe o la nueva cuota total no respetan los límites + * @throws es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException si sube el importe y no hay saldo */ @Transactional public Apuesta modificarImporte(String email, Long apuestaId, BigDecimal nuevo) { @@ -148,10 +185,12 @@ public Apuesta modificarImporte(String email, Long apuestaId, BigDecimal nuevo) .orElseThrow(() -> new ApuestaNoEncontradaException(apuestaId)); limites.actuales().comprobarImporte(nuevo); LocalDateTime ahora = LocalDateTime.now(reloj); + // Para el juego responsable solo cuenta lo que se añade; bajar el importe siempre se permite BigDecimal aumento = nuevo.subtract(apuesta.getImporte()); if (aumento.signum() > 0) { comprobarJuegoResponsable(apuesta.getUsuario(), aumento, ahora); } + // Cuotas vigentes, en el mismo orden que las selecciones (Apuesta.cambiarImporte cuenta con ello) List cuotas = apuesta.getSelecciones().stream().map(this::cuotaActual).toList(); comprobarCuotaTotal(Apuesta.producto(cuotas)); apuesta.cambiarImporte(nuevo, cuotas, ahora); @@ -165,6 +204,7 @@ private static void comprobarQueJuega(Usuario usuario) { } } + /** Lanza IllegalArgumentException si la cuota total pasa de {@link Apuesta#CUOTA_TOTAL_MAXIMA}. */ static void comprobarCuotaTotal(BigDecimal cuota) { if (cuota.compareTo(Apuesta.CUOTA_TOTAL_MAXIMA) > 0) { throw new IllegalArgumentException("La cuota total no puede pasar de 1.000: quita alguna selección"); @@ -184,15 +224,19 @@ private void comprobarJuegoResponsable(Usuario usuario, BigDecimal importe, Loca throw new JuegoResponsableException("Has pausado tus apuestas hasta el " + FECHA.format(usuario.getPausaHasta()) + "."); } + // Ventanas móviles (las últimas 24 h y los últimos 7 días), no el día o la semana del calendario comprobarLimite(usuario, usuario.getLimiteDiario(), ahora.minusDays(1), importe, "diario", "24 horas"); comprobarLimite(usuario, usuario.getLimiteSemanal(), ahora.minusDays(7), importe, "semanal", "7 días"); } + /** Lanza JuegoResponsableException si lo apostado desde {@code desde} más {@code importe} supera el límite. */ private void comprobarLimite(Usuario usuario, BigDecimal limite, LocalDateTime desde, BigDecimal importe, String nombre, String periodo) { + // Sin límite fijado (null): no hay nada que comprobar if (limite == null) { return; } + // Cuentan también las apuestas ya resueltas; las canceladas no (ApuestaRepository.apostadoDesde) BigDecimal apostado = apuestas.apostadoDesde(usuario, desde); if (apostado.add(importe).compareTo(limite) > 0) { throw new JuegoResponsableException("Superarías tu límite " + nombre + " de " + monedas(limite) @@ -204,6 +248,7 @@ private static String monedas(BigDecimal cantidad) { return cantidad.setScale(2, RoundingMode.DOWN).toPlainString().replace('.', ','); } + /** Cuota vigente de una selección ya apostada: la del candidato o la que calcula ahora la calculadora. */ private BigDecimal cuotaActual(Seleccion seleccion) { if (seleccion.isLargoPlazo()) { return seleccion.getCandidato().getCuota(); @@ -211,8 +256,13 @@ private BigDecimal cuotaActual(Seleccion seleccion) { return calculadora.cuota(seleccion.getEvento(), seleccion.getPronostico(), seleccion.getEspecial()); } - /** Cuota actual de un resultado o de un tipo especial (HU-52); exactamente uno de los dos. */ + /** + * Cuota actual de un resultado o de un tipo especial (HU-52); exactamente uno de los dos. + * + * @throws ResultadoNoValidoException si vienen los dos, ninguno, o no hay cuota para eso en el evento + */ BigDecimal cuota(Evento evento, Resultado resultado, Especial especial) { + // Los dos null o los dos rellenos: no es un pronóstico válido BigDecimal cuota = (resultado == null) == (especial == null) ? null : calculadora.cuota(evento, resultado, especial); if (cuota == null) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/AvisosApuestas.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/AvisosApuestas.java index 587f5ec..a149f74 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/AvisosApuestas.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/AvisosApuestas.java @@ -9,7 +9,11 @@ import es.ucm.fdi.is1.apuestas.notificaciones.NotificacionService; -/** Avisa al usuario cuando una de sus apuestas se gana, se pierde o se anula (HU-37). */ +/** + * Avisa al usuario cuando una de sus apuestas se gana, se pierde o se anula (HU-37). La usan + * {@link ResolucionService} y {@link ResolucionMercadosService} después de resolver cada apuesta; + * el aviso se guarda con {@link NotificacionService}. Solo es visible dentro del paquete. + */ @Component class AvisosApuestas { @@ -25,6 +29,7 @@ void siCambia(Apuesta apuesta, EstadoApuesta antes) { if (ahora == antes || ahora == EstadoApuesta.ACTIVA || ahora == EstadoApuesta.CANCELADA) { return; } + // Si ya estaba resuelta, es una corrección del resultado (HU-04) y se dice así String correccion = antes == EstadoApuesta.ACTIVA ? "" : "Resultado corregido. "; String texto = switch (ahora) { case GANADA -> correccion + "¡Has acertado " + descripcion(apuesta) + "! Cobras " @@ -33,6 +38,7 @@ void siCambia(Apuesta apuesta, EstadoApuesta antes) { default -> correccion + "Se ha anulado " + descripcion(apuesta) + ". Te hemos devuelto " + monedas(apuesta.getImporte()) + " monedas."; }; + // El tipo del aviso es el estado en minúsculas: "ganada", "perdida" o "anulada" notificaciones.avisar(apuesta.getUsuario(), ahora.name().toLowerCase(Locale.ROOT), texto); } @@ -45,6 +51,7 @@ private static String descripcion(Apuesta apuesta) { return "tu apuesta a " + seleccion.getDescripcionPronostico() + " (" + seleccion.getTitulo() + ")"; } + /** Importe en formato español: "1.234,50". */ private static String monedas(BigDecimal cantidad) { DecimalFormat formato = new DecimalFormat("#,##0.00", DecimalFormatSymbols.getInstance(Locale.forLanguageTag("es-ES"))); formato.setGroupingSize(3); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaController.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaController.java index 4775635..a47cd75 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaController.java @@ -25,6 +25,10 @@ public BajaController(BajaService bajas, Sesiones sesiones) { this.sesiones = sesiones; } + /** + * POST /cuenta/eliminar: pide la contraseña para confirmar. Si es correcta, elimina la cuenta, cierra + * todas las sesiones del usuario y lleva a la portada; si no, vuelve a Mi cuenta con el error. + */ @PostMapping("/cuenta/eliminar") public String eliminar(@RequestParam(required = false) String password, Principal principal, HttpServletRequest peticion, RedirectAttributes redireccion) throws ServletException { @@ -37,6 +41,7 @@ public String eliminar(@RequestParam(required = false) String password, Principa redireccion.addFlashAttribute("errorBaja", e.getMessage()); return "redirect:/cuenta#eliminar-cuenta"; } + // Se cierran las sesiones abiertas en otros navegadores y después la actual (CA-18.2) sesiones.cerrarOtras(principal.getName(), peticion.getSession(false)); peticion.logout(); return "redirect:/?cuentaEliminada"; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaService.java index a5c985b..8289ab1 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BajaService.java @@ -19,6 +19,9 @@ * Eliminar la cuenta (HU-18). Se cancelan las apuestas que aún se pueden cancelar, se borran los avisos * y se anonimiza el usuario: el email y el nombre desaparecen, el saldo queda a cero y ya no se puede entrar. * La fila se conserva para que las apuestas ya hechas sigan cuadrando; desaparece del ranking. + * + *

Está en este paquete y no en el de usuarios porque necesita cancelar apuestas; así el paquete de + * usuarios no depende del de apuestas. */ @Service public class BajaService { @@ -40,6 +43,13 @@ public BajaService(UsuarioService usuarioService, UsuarioRepository usuarios, Ap this.eventos = eventos; } + /** + * Elimina la cuenta tras confirmar la contraseña (CA-18.2). Todo en una transacción: si algo falla, + * la cuenta queda como estaba. + * + * @throws PasswordIncorrectaException si la contraseña no es la del usuario + * @throws IllegalStateException si es la cuenta del creador de apuestas + */ @Transactional public void eliminarCuenta(String email, String password) { if (!usuarioService.passwordCorrecta(email, password)) { @@ -50,10 +60,13 @@ public void eliminarCuenta(String email, String password) { throw new IllegalStateException("La cuenta del creador de apuestas no se puede eliminar"); } LocalDateTime ahora = LocalDateTime.now(reloj); + // Las que aún se pueden cancelar devuelven el importe (que se pierde al poner el saldo a cero). + // Las demás se resuelven igual más adelante, pero ya no se le abona nada (Usuario.ajustar) apuestas.findByUsuarioAndEstadoOrderByFechaDesc(usuario, EstadoApuesta.ACTIVA).stream() .filter(a -> a.cancelable(ahora)) .forEach(a -> a.cancelar(ahora)); notificaciones.borrarDe(usuario); + // Antes de anonimizar, para que otros paquetes (por ejemplo, las ligas) limpien lo suyo eventos.publishEvent(new CuentaEliminada(usuario.getId())); usuarioService.anonimizar(usuario); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Boleto.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Boleto.java index 2b04bfe..66d3dc5 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Boleto.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Boleto.java @@ -16,6 +16,11 @@ /** * Boleto en construcción del usuario (HU-28): las selecciones que va eligiendo en el catálogo * antes de confirmar. Vive en la sesión; no se guarda en la base de datos hasta confirmar. + * + *

Con {@code @SessionScope} Spring crea un boleto por sesión HTTP. En {@link BoletoController} se inyecta + * un proxy que, en cada petición, apunta al boleto de la sesión actual. Es {@link Serializable} para poder guardarse + * con la sesión. Las reglas (límites, cuotas, eventos disponibles) se aplican en {@link BoletoService}; + * aquí solo se impide repetir partido (CA-28.2). */ @Component @SessionScope @@ -31,6 +36,7 @@ public class Boleto implements Serializable { public record Linea(Long eventoId, Resultado resultado, Especial especial, BigDecimal cuotaVista) implements Serializable { + /** Línea 1X2. */ public Linea(Long eventoId, Resultado resultado, BigDecimal cuotaVista) { this(eventoId, resultado, null, cuotaVista); } @@ -51,6 +57,7 @@ void anadir(Long eventoId, Especial especial, BigDecimal cuotaVista) { anadir(new Linea(eventoId, null, especial, cuotaVista)); } + /** Lanza IllegalArgumentException si ya hay una selección de ese partido (CA-28.2). */ private void anadir(Linea linea) { if (contiene(linea.eventoId())) { throw new IllegalArgumentException("Ya tienes una selección de ese partido en el boleto"); @@ -62,6 +69,7 @@ void quitar(Long eventoId) { lineas.removeIf(l -> l.eventoId().equals(eventoId)); } + /** Cambia la cuota vista de la línea de ese evento; como Linea es un record, se sustituye por otra. */ void actualizarCuota(Long eventoId, BigDecimal cuota) { lineas.replaceAll(l -> l.eventoId().equals(eventoId) ? new Linea(eventoId, l.resultado(), l.especial(), cuota) : l); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoController.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoController.java index c66f20f..7ad9ad3 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoController.java @@ -21,12 +21,17 @@ import es.ucm.fdi.is1.apuestas.web.RutaSegura; import jakarta.servlet.http.HttpServletRequest; -/** Boleto de apuestas: añadir selecciones desde el catálogo, revisarlas y confirmar (HU-28, HU-29). */ +/** + * Boleto de apuestas: añadir selecciones desde el catálogo, revisarlas y confirmar (HU-28, HU-29). + * Todas las rutas cuelgan de {@code /boleto} y exigen sesión. La lógica está en {@link BoletoService}; + * aquí se traducen las excepciones a mensajes flash y se redirige. + */ @Controller @RequestMapping("/boleto") public class BoletoController { private final BoletoService boletos; + /** Proxy al boleto de la sesión actual (ver {@link Boleto}). */ private final Boleto boleto; private final LimitesService limites; @@ -36,6 +41,10 @@ public BoletoController(BoletoService boletos, Boleto boleto, LimitesService lim this.limites = limites; } + /** + * GET /boleto: las selecciones con su cuota actual, el multiplicador y los límites de importe. + * Quita las que ya no admiten apuestas y oculta la barra del boleto, que aquí sobra. + */ @GetMapping public String ver(Model model) { model.addAttribute("vista", boletos.vista(boleto)); @@ -48,6 +57,7 @@ public String ver(Model model) { * Se llega pulsando una cuota del catálogo, o con el botón "Añadir al boleto" de la página del partido, * que envía la opción elegida ({@code opcion}); vuelve a la página de la que se venía. * Es un POST porque cambia el boleto. + * POST /boleto/anadir. El creador de apuestas no puede añadir nada. */ @PostMapping("/anadir") public String anadir(@RequestParam("evento") Long eventoId, @RequestParam(required = false) Resultado resultado, @@ -59,6 +69,7 @@ public String anadir(@RequestParam("evento") Long eventoId, @RequestParam(requir redireccion.addFlashAttribute("errorBoleto", "El creador de apuestas no puede apostar"); return "redirect:" + RutaSegura.de(referer, "/eventos"); } + // "opcion" llega del grupo de opciones de la página del partido; se traduce igual que en ApuestaForm if (opcion != null) { ApuestaForm elegida = new ApuestaForm(); elegida.setOpcion(opcion); @@ -73,15 +84,17 @@ public String anadir(@RequestParam("evento") Long eventoId, @RequestParam(requir } catch (EventoNoDisponibleException e) { redireccion.addFlashAttribute("errorBoleto", "Ese partido ya no admite apuestas"); } + // Solo se vuelve al Referer si es una ruta de esta web (evita redirecciones a otros sitios) return "redirect:" + RutaSegura.de(referer, "/boleto"); } - /** Un enlace antiguo (o volver tras iniciar sesión) no añade nada: lleva a la página del partido. */ + /** GET /boleto/anadir: un enlace antiguo (o volver tras iniciar sesión) no añade nada: lleva a la página del partido. */ @GetMapping("/anadir") public String anadirDesdeEnlace(@RequestParam("evento") Long eventoId) { return "redirect:/eventos/" + eventoId + "/apostar"; } + /** POST /boleto/quitar/{eventoId}: quita la selección de ese partido (CA-28.3). */ @PostMapping("/quitar/{eventoId}") public String quitar(@PathVariable Long eventoId) { boletos.quitar(boleto, eventoId); @@ -89,14 +102,17 @@ public String quitar(@PathVariable Long eventoId) { } /** + * POST /boleto/confirmar: convierte el boleto en una apuesta simple o combinada y lleva a Mis apuestas. * {@code cuotaVista}: las cuotas que el usuario tenía delante al confirmar ("evento:cuota"). Si alguna ha * cambiado desde entonces, no se apuesta y se le enseñan las nuevas (HU-29). Así el botón * "Aceptar nuevas cuotas" apuesta a la primera con las cuotas que acaba de ver. + * Si algo falla, vuelve al boleto con el error y el importe que había escrito. */ @PostMapping("/confirmar") public String confirmar(@RequestParam(required = false) BigDecimal importe, @RequestParam(required = false) List cuotaVista, Principal principal, RedirectAttributes redireccion) { + // Positivo y con 2 decimales como mucho; los límites del creador se comprueban en el servicio if (importe == null || importe.signum() <= 0 || importe.stripTrailingZeros().scale() > 2) { redireccion.addFlashAttribute("error", "Introduce un importe válido, con 2 decimales como mucho"); return "redirect:/boleto"; @@ -118,6 +134,7 @@ public String confirmar(@RequestParam(required = false) BigDecimal importe, } catch (IllegalArgumentException | ResultadoNoValidoException e) { redireccion.addFlashAttribute("error", e.getMessage()); } catch (EventoNoDisponibleException e) { + // Se quita del boleto al volver a GET /boleto, que llama a BoletoService.vista redireccion.addFlashAttribute("error", "Uno de los partidos ya no admite apuestas (ha empezado o se ha suspendido) y se ha quitado del boleto."); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoService.java index c4ea316..b67cd24 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoService.java @@ -16,7 +16,11 @@ import es.ucm.fdi.is1.apuestas.eventos.EventoNoDisponibleException; import es.ucm.fdi.is1.apuestas.eventos.EventoRepository; -/** Operaciones sobre el boleto del usuario: añadir, quitar, ver y confirmar (HU-28, HU-29). */ +/** + * Operaciones sobre el boleto del usuario: añadir, quitar, ver y confirmar (HU-28, HU-29). + * El {@link Boleto} vive en la sesión; este servicio consulta eventos y cuotas y, al confirmar, + * delega en {@link ApuestaService}, que es quien registra la apuesta y cobra. + */ @Service public class BoletoService { @@ -35,12 +39,21 @@ public BoletoService(EventoRepository eventos, CalculadoraCuotas calculadora, Ap this.reloj = reloj; } + /** Añade un resultado 1X2 al boleto. */ @Transactional(readOnly = true) public void anadir(Boleto boleto, Long eventoId, Resultado resultado) { anadir(boleto, eventoId, resultado, null); } - /** Añade un resultado (1X2) o un tipo especial (HU-52): exactamente uno de los dos. */ + /** + * Añade un resultado (1X2) o un tipo especial (HU-52): exactamente uno de los dos. Guarda la cuota + * actual como la que ha visto el usuario. Se comprueban ya aquí el máximo de selecciones (HU-07) y la + * cuota total máxima, para avisar antes de llegar a confirmar. + * + * @throws EventoNoDisponibleException si el evento no existe o ya no admite apuestas + * @throws ResultadoNoValidoException si ese pronóstico no se puede apostar en el evento + * @throws IllegalArgumentException si se supera un límite o el partido ya está en el boleto + */ @Transactional(readOnly = true) public void anadir(Boleto boleto, Long eventoId, Resultado resultado, Especial especial) { Evento evento = disponible(eventoId); @@ -49,6 +62,7 @@ public void anadir(Boleto boleto, Long eventoId, Resultado resultado, Especial e if (boleto.getTamano() >= maximo) { throw new IllegalArgumentException("El boleto admite como máximo " + maximo + " selecciones"); } + // Multiplicador que tendría el boleto con la nueva selección List conLaNueva = new ArrayList<>(boleto.getLineas().stream().map(Boleto.Linea::cuotaVista).toList()); conLaNueva.add(cuota); ApuestaService.comprobarCuotaTotal(Apuesta.producto(conLaNueva)); @@ -59,6 +73,7 @@ public void anadir(Boleto boleto, Long eventoId, Resultado resultado, Especial e } } + /** Quita la selección de ese partido; si queda una sola, el boleto pasa a ser una simple (CA-28.3). */ public void quitar(Boleto boleto, Long eventoId) { boleto.quitar(eventoId); } @@ -84,6 +99,7 @@ public BoletoVista resumen(Boleto boleto) { private BoletoVista vista(Boleto boleto, boolean quitarNoDisponibles) { LocalDateTime ahora = LocalDateTime.now(reloj); List lineas = new ArrayList<>(); + // Se recorre una copia porque el bucle puede quitar líneas del boleto for (Boleto.Linea linea : List.copyOf(boleto.getLineas())) { Evento evento = eventos.findById(linea.eventoId()).filter(e -> e.admiteApuestas(ahora)).orElse(null); if (evento == null) { @@ -92,10 +108,12 @@ private BoletoVista vista(Boleto boleto, boolean quitarNoDisponibles) { } continue; } + // La cuota puede haber cambiado desde que se añadió (por ejemplo, por el ajuste por volumen) BigDecimal cuota = calculadora.cuota(evento, linea.resultado(), linea.especial()); lineas.add(new BoletoVista.LineaVista(evento, linea.resultado(), linea.especial(), cuota, cuota.compareTo(linea.cuotaVista()) != 0)); } + // Multiplicador total con las cuotas actuales (CA-29.1) BigDecimal total = Apuesta.producto(lineas.stream().map(BoletoVista.LineaVista::cuota).toList()); return new BoletoVista(lineas, total); } @@ -103,6 +121,8 @@ private BoletoVista vista(Boleto boleto, boolean quitarNoDisponibles) { /** * Confirma el boleto como apuesta simple o combinada. Si alguna cuota ha cambiado, actualiza las * cuotas vistas y relanza la excepción para que el usuario acepte las nuevas (HU-29). + * Si sale bien, vacía el boleto. Las demás excepciones de {@link ApuestaService#apostar} llegan + * tal cual al controlador. */ @Transactional public Apuesta confirmar(String email, Boleto boleto, BigDecimal importe) { @@ -114,6 +134,8 @@ public Apuesta confirmar(String email, Boleto boleto, BigDecimal importe) { boleto.vaciar(); return apuesta; } catch (CuotasCambiadasException e) { + // La transacción se deshace (no se cobra nada), pero el boleto está en la sesión, no en la base + // de datos: las cuotas nuevas se quedan guardadas para el siguiente intento aceptarCuotasActuales(boleto); throw e; } @@ -131,12 +153,14 @@ public void fijarCuotasVistas(Boleto boleto, List cuotas) { } } + /** Pone como cuota vista la actual en cada línea (y quita las que ya no admiten apuestas). */ private void aceptarCuotasActuales(Boleto boleto) { for (BoletoVista.LineaVista linea : vista(boleto).lineas()) { boleto.actualizarCuota(linea.evento().getId(), linea.cuota()); } } + /** El evento si existe y admite apuestas; si no, EventoNoDisponibleException. */ private Evento disponible(Long eventoId) { return eventos.findById(eventoId) .filter(e -> e.admiteApuestas(LocalDateTime.now(reloj))) diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoVista.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoVista.java index 465674e..09a56ec 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoVista.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/BoletoVista.java @@ -8,12 +8,23 @@ import es.ucm.fdi.is1.apuestas.equipos.Equipo; import es.ucm.fdi.is1.apuestas.eventos.Evento; -/** El boleto listo para mostrar: cada selección con su cuota actual y el multiplicador total (HU-29). */ +/** + * El boleto listo para mostrar: cada selección con su cuota actual y el multiplicador total (HU-29). + * Lo construye {@link BoletoService} a partir del {@link Boleto} de la sesión; no se guarda. + * + * @param cuotaTotal producto de las cuotas actuales (CA-29.1) + */ public record BoletoVista(List lineas, BigDecimal cuotaTotal) { + /** + * Una selección con su cuota actual. + * + * @param cambiada true si la cuota actual no coincide con la que vio el usuario (CA-29.3) + */ public record LineaVista(Evento evento, Resultado resultado, Especial especial, BigDecimal cuota, boolean cambiada) { + /** Línea 1X2. */ public LineaVista(Evento evento, Resultado resultado, BigDecimal cuota, boolean cambiada) { this(evento, resultado, null, cuota, cambiada); } @@ -24,10 +35,12 @@ public Equipo getEquipo() { ? evento.getVisitante() : evento.getLocal(); } + /** "1", "X", "2" o el del tipo especial ("1X", "+2,5"…). */ public String getSimbolo() { return especial != null ? especial.getSimbolo() : resultado.getSimbolo(); } + /** Nombre del equipo elegido, "Empate" o la descripción del tipo especial (como en {@link Seleccion}). */ public String getDescripcionPronostico() { if (especial != null) { return especial.getDescripcion(); @@ -40,10 +53,12 @@ public String getDescripcionPronostico() { } } + /** Con una sola línea el boleto es una apuesta simple (CA-28.3). */ public boolean isCombinada() { return lineas.size() > 1; } + /** Si hay que pedir al usuario que acepte las cuotas nuevas antes de confirmar (CA-29.3). */ public boolean isAlgunaCambiada() { return lineas.stream().anyMatch(LineaVista::cambiada); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CriterioRanking.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CriterioRanking.java index 3814332..643f7c1 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CriterioRanking.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CriterioRanking.java @@ -1,10 +1,13 @@ package es.ucm.fdi.is1.apuestas.apuesta; -/** Criterios de ordenación del ranking (HU-36). */ +/** Criterios de ordenación del ranking (HU-36); el nombre es el texto que se muestra en la página. */ public enum CriterioRanking { + /** Saldo actual de cada jugador. */ SALDO("Saldo"), + /** Beneficio neto de las apuestas resueltas: pagado − apostado. */ GANANCIAS("Ganancias"), + /** Porcentaje de apuestas ganadas; primero los que tienen un mínimo de apuestas resueltas. */ ACIERTOS("% de aciertos"); private final String nombre; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasa.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasa.java index fe20d76..5b699ae 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasa.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasa.java @@ -6,9 +6,12 @@ /** * Lo que se ha apostado, pagado y ganado la casa (HU-54), en total y desglosado. + * Lo construye {@link CuentasCasaService} y lo pinta el panel de la casa. * - * @param porTipo simples, combinadas y a largo plazo - * @param porDeporte solo las simples de partidos, por deporte + * @param porTipo simples 1X2, simples de doble oportunidad y goles, combinadas y a largo plazo + * @param porDeporte solo las simples de partidos, por deporte + * @param jugadores usuarios distintos que han apostado + * @param margenTeorico margen que debería quedarse la casa según la fórmula de cuotas, en % */ public record CuentasCasa(Fila total, List porTipo, List porDeporte, long jugadores, BigDecimal margenTeorico) { @@ -19,11 +22,13 @@ public record CuentasCasa(Fila total, List porTipo, List porDeporte, */ public record Fila(String nombre, int apuestas, BigDecimal enJuego, BigDecimal apostado, BigDecimal pagado) { + /** Suma las apuestas de una fila; las activas van aparte porque aún no se sabe qué se pagará. */ static Fila de(String nombre, List apuestas) { BigDecimal enJuego = BigDecimal.ZERO; BigDecimal apostado = BigDecimal.ZERO; BigDecimal pagado = BigDecimal.ZERO; for (Apuesta a : apuestas) { + // pagado incluye el importe devuelto en las anuladas: en ellas la casa ni gana ni pierde if (a.getEstado() == EstadoApuesta.ACTIVA) { enJuego = enJuego.add(a.getImporte()); } else { @@ -39,7 +44,10 @@ public BigDecimal getBeneficio() { return apostado.subtract(pagado); } - /** Beneficio / apostado, en tanto por ciento con un decimal. Null si aún no hay nada cerrado. */ + /** + * Margen real: beneficio / apostado, en tanto por ciento con un decimal. Se compara con el margen + * teórico (CA-54.1). Null si aún no hay nada cerrado. + */ public BigDecimal getMargen() { if (apostado.signum() == 0) { return null; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasaService.java index 66aaf14..125443e 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasaService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuentasCasaService.java @@ -27,10 +27,13 @@ public CuentasCasaService(ApuestaRepository apuestas) { this.apuestas = apuestas; } + /** Calcula el panel completo: total, desglose por tipo (CA-54.2) y por deporte, y los márgenes. */ @Transactional(readOnly = true) public CuentasCasa cuentas() { + // Todos los estados menos CANCELADA List todas = apuestas.findByEstadoIn(EnumSet.complementOf(EnumSet.of(EstadoApuesta.CANCELADA))); + // Tres grupos sin solapes: largo plazo, combinadas de partidos y simples de partidos List largoPlazo = todas.stream().filter(Apuesta::isLargoPlazo).toList(); List combinadas = todas.stream().filter(a -> !a.isLargoPlazo() && a.isCombinada()).toList(); List simples = todas.stream().filter(a -> !a.isLargoPlazo() && !a.isCombinada()).toList(); @@ -43,6 +46,7 @@ public CuentasCasa cuentas() { CuentasCasa.Fila.de("Combinadas", combinadas), CuentasCasa.Fila.de("Largo plazo", largoPlazo)); + // Solo las simples: una combinada puede mezclar deportes. TreeMap ordena por el orden del enum Deporte Map> deportes = new TreeMap<>(); for (Apuesta a : simples) { deportes.computeIfAbsent(a.getSelecciones().get(0).getEvento().getDeporte(), d -> new ArrayList<>()).add(a); @@ -52,6 +56,7 @@ public CuentasCasa cuentas() { .toList(); long jugadores = todas.stream().map(a -> a.getUsuario().getId()).distinct().count(); + // Margen teórico de una simple (1 − 1/margen de la casa), en % con un decimal BigDecimal margenTeorico = BigDecimal.valueOf(CalculadoraCuotas.margenTeorico() * 100) .setScale(1, RoundingMode.HALF_UP); return new CuentasCasa(CuentasCasa.Fila.de("Total", todas), porTipo, porDeporte, jugadores, margenTeorico); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuotasCambiadasException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuotasCambiadasException.java index 0cadf3a..2954976 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuotasCambiadasException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/CuotasCambiadasException.java @@ -2,9 +2,13 @@ import java.util.List; -/** Alguna cuota del boleto ha cambiado desde que el usuario la vio (HU-29). */ +/** + * Alguna cuota del boleto ha cambiado desde que el usuario la vio (HU-29). La lanza {@link ApuestaService} + * antes de cobrar nada; el controlador la captura y enseña las cuotas nuevas para que el usuario decida. + */ public class CuotasCambiadasException extends RuntimeException { + /** Ids de los eventos cuya cuota ha cambiado; vacía en las apuestas a largo plazo. */ private final List eventos; public CuotasCambiadasException(List eventos) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Estadisticas.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Estadisticas.java index 6d17bc8..5c1fa40 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Estadisticas.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Estadisticas.java @@ -5,7 +5,8 @@ import java.util.List; /** - * Estadísticas de un conjunto de apuestas resueltas (HU-35). + * Estadísticas de un conjunto de apuestas resueltas (HU-35). Se usan en Mis apuestas, en el ranking + * (HU-36) y en el perfil público (HU-48). Las anuladas y canceladas no cuentan. * * @param resueltas número de apuestas ganadas o perdidas * @param ganadas número de apuestas ganadas @@ -14,6 +15,7 @@ */ public record Estadisticas(int resueltas, int ganadas, BigDecimal apostado, BigDecimal beneficio) { + /** A partir de las apuestas de un usuario; se queda solo con las ganadas y perdidas. */ public static Estadisticas de(List apuestas) { List resueltas = apuestas.stream().filter(Apuesta::isResuelta).toList(); int ganadas = (int) resueltas.stream().filter(a -> a.getEstado() == EstadoApuesta.GANADA).count(); @@ -22,11 +24,15 @@ public static Estadisticas de(List apuestas) { return new Estadisticas(resueltas.size(), ganadas, apostado, beneficio); } - /** A partir de los totales de la base de datos: beneficio = pagado − apostado. */ + /** + * A partir de los totales de la base de datos ({@link ApuestaRepository#resumenResueltasPorUsuario}): + * beneficio = pagado − apostado. Da lo mismo que sumar {@link Apuesta#getBeneficio} una a una. + */ static Estadisticas de(long resueltas, long ganadas, BigDecimal apostado, BigDecimal pagado) { return new Estadisticas((int) resueltas, (int) ganadas, apostado, pagado.subtract(apostado)); } + /** Para los jugadores sin ninguna apuesta resuelta. */ static final Estadisticas VACIAS = new Estadisticas(0, 0, BigDecimal.ZERO, BigDecimal.ZERO); /** Porcentaje de apuestas acertadas (0 si no hay ninguna resuelta). */ @@ -34,10 +40,14 @@ public BigDecimal getPorcentajeAciertos() { if (resueltas == 0) { return BigDecimal.ZERO; } + // ganadas × 100 / resueltas, con un decimal return BigDecimal.valueOf(ganadas * 100L).divide(BigDecimal.valueOf(resueltas), 1, RoundingMode.HALF_UP); } - /** Rentabilidad: beneficio / apostado, en porcentaje (0 si no hay nada apostado). */ + /** + * Rentabilidad: beneficio / apostado, en porcentaje con un decimal (0 si no hay nada apostado). + * Negativa si el balance es de pérdidas; −100 % si se ha perdido todo lo apostado. + */ public BigDecimal getRentabilidad() { if (apostado.signum() == 0) { return BigDecimal.ZERO; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoApuesta.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoApuesta.java index 93fd4d1..54c9502 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoApuesta.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoApuesta.java @@ -1,9 +1,17 @@ package es.ucm.fdi.is1.apuestas.apuesta; +/** + * Estado de una {@link Apuesta}. Lo calcula la propia apuesta a partir de sus selecciones, + * salvo {@link #CANCELADA}, que solo se alcanza a petición del usuario. + */ public enum EstadoApuesta { + /** Recién hecha o con selecciones pendientes; el importe está comprometido (HU-24). */ ACTIVA, + /** Todas las selecciones acertadas (las anuladas cuentan con cuota 1,00): se abona la ganancia (HU-25, HU-30). */ GANADA, + /** Ha fallado al menos una selección; en una combinada basta con una (HU-30). */ PERDIDA, + /** Se anularon todas sus selecciones (evento o mercado anulado): se devolvió el importe (HU-05). */ ANULADA, /** Cancelada por el usuario antes de empezar el evento; se le devolvió el importe (HU-26). */ CANCELADA diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoSeleccion.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoSeleccion.java index eb1227f..e1233c3 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoSeleccion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/EstadoSeleccion.java @@ -1,9 +1,14 @@ package es.ucm.fdi.is1.apuestas.apuesta; +/** Estado de una {@link Seleccion} dentro de una apuesta. La apuesta deduce el suyo a partir de estos. */ public enum EstadoSeleccion { + /** Aún no se conoce el resultado del evento o el ganador del mercado. */ PENDIENTE, ACERTADA, FALLADA, - /** El evento se anuló: la selección cuenta con cuota 1,00 (HU-30). */ + /** + * El evento o el mercado se anuló, o es de goles y solo se conoce el ganador (HU-52): + * la selección cuenta con cuota 1,00 (HU-30). + */ ANULADA } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ImporteFueraDeLimitesException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ImporteFueraDeLimitesException.java index a98844b..d439c6b 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ImporteFueraDeLimitesException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ImporteFueraDeLimitesException.java @@ -2,13 +2,17 @@ import java.math.BigDecimal; -/** El importe de la apuesta no respeta los límites que ha fijado el creador (HU-07). */ +/** + * El importe de la apuesta no respeta los límites que ha fijado el creador (HU-07). La lanza + * {@link Limites#comprobarImporte}; el mensaje indica el rango permitido (CA-07.1). + */ public class ImporteFueraDeLimitesException extends IllegalArgumentException { public ImporteFueraDeLimitesException(BigDecimal minimo, BigDecimal maximo) { super("El importe debe estar entre " + texto(minimo) + " y " + texto(maximo) + " monedas"); } + /** "1" en lugar de "1.00" y "2,5" en lugar de "2.50": formato español sin ceros de sobra. */ private static String texto(BigDecimal cantidad) { return cantidad.stripTrailingZeros().toPlainString().replace('.', ','); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JuegoResponsableException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JuegoResponsableException.java index 4a8c5a5..7c2e463 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JuegoResponsableException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JuegoResponsableException.java @@ -1,6 +1,9 @@ package es.ucm.fdi.is1.apuestas.apuesta; -/** La apuesta supera los límites que se ha puesto el usuario o tiene una pausa activa (HU-10). */ +/** + * La apuesta supera los límites que se ha puesto el usuario o tiene una pausa activa (HU-10). + * La lanza {@link ApuestaService}; el mensaje ya explica al usuario qué límite ha alcanzado. + */ public class JuegoResponsableException extends IllegalArgumentException { public JuegoResponsableException(String mensaje) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JugadorNoEncontradoException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JugadorNoEncontradoException.java index 650e045..f7ba411 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JugadorNoEncontradoException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/JugadorNoEncontradoException.java @@ -3,6 +3,10 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * El perfil pedido no es de un jugador del ranking (HU-48): no existe, es el creador o la cuenta se eliminó. + * Spring responde con un 404 gracias a {@code @ResponseStatus}. + */ @ResponseStatus(HttpStatus.NOT_FOUND) public class JugadorNoEncontradoException extends RuntimeException { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Limites.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Limites.java index 87ff96a..4d8cb17 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Limites.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Limites.java @@ -7,11 +7,16 @@ import jakarta.persistence.Id; /** - * Límites de apuesta que fija el creador (HU-07). Hay una sola fila, creada por la migración V3. + * Límites de apuesta que fija el creador (HU-07). Hay una sola fila, creada por la migración + * (V3 en H2, V8 en PostgreSQL); no se crean ni se borran filas desde el código. + * + *

Entidad JPA. La leen {@link ApuestaService} y {@link BoletoService} antes de apostar, y la cambia + * el creador desde Gestión a través de {@link LimitesService}. */ @Entity public class Limites { + /** Id de la única fila. */ static final Long ID = 1L; @Id @@ -38,6 +43,12 @@ protected Limites() { // requerido por JPA } + /** + * Fija los nuevos límites. + * + * @throws IllegalArgumentException si el mínimo no es positivo, el máximo es menor que el mínimo + * o se permiten menos de 2 selecciones (no habría combinadas) + */ public void cambiar(BigDecimal minimo, BigDecimal maximo, int selecciones) { if (minimo.signum() <= 0) { throw new IllegalArgumentException("El importe mínimo debe ser mayor que 0"); @@ -53,13 +64,14 @@ public void cambiar(BigDecimal minimo, BigDecimal maximo, int selecciones) { maxSelecciones = selecciones; } - /** Lanza una excepción si el importe no está entre el mínimo y el máximo. */ + /** Lanza ImporteFueraDeLimitesException si el importe no está entre el mínimo y el máximo (ambos incluidos). */ public void comprobarImporte(BigDecimal importe) { if (importe.compareTo(importeMinimo) < 0 || importe.compareTo(importeMaximo) > 0) { throw new ImporteFueraDeLimitesException(importeMinimo, importeMaximo); } } + /** Lanza IllegalArgumentException si la combinada tiene más selecciones de las permitidas. */ public void comprobarSelecciones(int selecciones) { if (selecciones > maxSelecciones) { throw new IllegalArgumentException("Una combinada admite como máximo " + maxSelecciones + " selecciones"); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesRepository.java index 789b65f..e3107d2 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesRepository.java @@ -2,5 +2,6 @@ import org.springframework.data.jpa.repository.JpaRepository; +/** Repositorio de {@link Limites}. Solo hay una fila (id {@code Limites.ID}); se usa desde {@link LimitesService}. */ public interface LimitesRepository extends JpaRepository { } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesService.java index 6385850..9a5d091 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/LimitesService.java @@ -5,7 +5,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** Consulta y cambio de los límites de apuesta (HU-07). */ +/** + * Consulta y cambio de los límites de apuesta (HU-07) y del interruptor de juego responsable (HU-10). + * Lo usan los servicios de apuestas, el panel de gestión y las páginas que muestran el importe mínimo y máximo. + */ @Service public class LimitesService { @@ -15,6 +18,11 @@ public LimitesService(LimitesRepository limites) { this.limites = limites; } + /** + * La única fila de límites. + * + * @throws IllegalStateException si falta (la base de datos no tiene aplicadas las migraciones) + */ @Transactional(readOnly = true) public Limites actuales() { return limites.findById(Limites.ID) @@ -27,9 +35,15 @@ public void activarJuegoResponsable(boolean activo) { actuales().activarJuegoResponsable(activo); } + /** + * Cambia los límites (HU-07). Las apuestas ya hechas no se tocan: los límites se comprueban al apostar. + * + * @throws IllegalArgumentException si los valores no son coherentes (ver {@link Limites#cambiar}) + */ @Transactional public Limites cambiar(BigDecimal importeMinimo, BigDecimal importeMaximo, int maxSelecciones) { Limites actuales = actuales(); + // Sin save: la entidad está gestionada y JPA guarda el cambio al terminar la transacción actuales.cambiar(importeMinimo, importeMaximo, maxSelecciones); return actuales; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/MercadosController.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/MercadosController.java index 3ae9596..ff9f814 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/MercadosController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/MercadosController.java @@ -15,7 +15,10 @@ import es.ucm.fdi.is1.apuestas.mercados.MercadoService; import es.ucm.fdi.is1.apuestas.usuarios.SaldoInsuficienteException; -/** Apuestas a largo plazo: Balón de Oro, campeón de liga… (HU-44). La página es pública; apostar exige sesión. */ +/** + * Apuestas a largo plazo: Balón de Oro, campeón de liga… (HU-44). La página es pública; apostar exige sesión. + * Los mercados los gestiona el creador desde otro controlador del paquete de gestión (HU-45). + */ @Controller public class MercadosController { @@ -29,6 +32,7 @@ public MercadosController(MercadoService mercados, ApuestaService apuestas, Limi this.limites = limites; } + /** GET /mercados: los mercados abiertos con sus candidatos y cuotas (CA-44.1) y los ya terminados. */ @GetMapping("/mercados") public String mercados(Model model) { model.addAttribute("abiertos", mercados.abiertos()); @@ -37,11 +41,17 @@ public String mercados(Model model) { return "mercados"; } + /** + * POST /mercados/{id}/apostar: apuesta a un candidato (CA-44.2). {@code cuota} es la que veía el usuario; + * si el creador la ha cambiado, no se apuesta. Si sale bien lleva a Mis apuestas; si no, vuelve al + * mercado con el error. + */ @PostMapping("/mercados/{id}/apostar") public String apostar(@PathVariable Long id, @RequestParam(required = false) Long candidato, @RequestParam(required = false) BigDecimal cuota, @RequestParam(required = false) BigDecimal importe, Principal principal, RedirectAttributes redireccion) { + // El ancla lleva de vuelta al mercado en el que se estaba String volver = "redirect:/mercados#mercado-" + id; if (candidato == null) { redireccion.addFlashAttribute("error", "Elige un candidato"); @@ -62,6 +72,7 @@ public String apostar(@PathVariable Long id, @RequestParam(required = false) Lon } catch (SaldoInsuficienteException e) { redireccion.addFlashAttribute("error", "No tienes saldo suficiente para esta apuesta"); } catch (MercadoNoDisponibleException | IllegalArgumentException e) { + // Mercado cerrado (CA-44.3), límites de importe, juego responsable o creador que intenta apostar redireccion.addFlashAttribute("error", e.getMessage()); } return volver; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PerfilJugador.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PerfilJugador.java index 2d5d105..91a1d97 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PerfilJugador.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PerfilJugador.java @@ -1,5 +1,12 @@ package es.ucm.fdi.is1.apuestas.apuesta; -/** Lo que se ve de otro jugador (HU-48): su puesto, sus cifras y cuántas apuestas tiene en juego. */ +/** + * Lo que se ve de otro jugador (HU-48): su puesto, sus cifras y cuántas apuestas tiene en juego. + * Lo construye {@link RankingService#perfil}; no incluye sus apuestas. + * + * @param puesto su fila en el ranking por saldo + * @param jugadores total de jugadores del ranking, para mostrar "puesto X de N" + * @param apuestasEnJuego número de apuestas activas + */ public record PerfilJugador(PuestoRanking puesto, int jugadores, long apuestasEnJuego) { } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PuestoRanking.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PuestoRanking.java index f57cf73..4871542 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PuestoRanking.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/PuestoRanking.java @@ -2,7 +2,12 @@ import java.math.BigDecimal; -/** Una fila del ranking: posición, nombre visible, cifras y si es el usuario que lo consulta. */ +/** + * Una fila del ranking: posición, nombre visible, cifras y si es el usuario que lo consulta. + * + * @param posicion empieza en 1 + * @param soyYo true en la fila del usuario con sesión, para destacarla (CA-36.4) + */ public record PuestoRanking(int posicion, Long usuarioId, String nombre, BigDecimal saldo, Estadisticas estadisticas, boolean soyYo) { } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/RankingService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/RankingService.java index 5b966e6..5e8fb49 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/RankingService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/RankingService.java @@ -16,7 +16,11 @@ import es.ucm.fdi.is1.apuestas.usuarios.Usuario; import es.ucm.fdi.is1.apuestas.usuarios.UsuarioRepository; -/** Ranking de jugadores por saldo, ganancias o porcentaje de aciertos (HU-36). */ +/** + * Ranking de jugadores por saldo, ganancias o porcentaje de aciertos (HU-36). También da el perfil público + * (HU-48) y el ranking de una liga privada (HU-51, desde {@code LigaService}). Solo aparecen los usuarios con + * rol de jugador y sin cuenta eliminada. Se calcula en cada consulta, así siempre está al día (CA-36.3). + */ @Service public class RankingService { @@ -31,6 +35,7 @@ public RankingService(UsuarioRepository usuarios, ApuestaRepository apuestas) { this.apuestas = apuestas; } + /** Ranking de todos los jugadores; {@code emailActual} (null sin sesión) marca la fila propia. */ @Transactional(readOnly = true) public List ranking(String emailActual, CriterioRanking criterio) { return ranking(emailActual, criterio, null); @@ -39,15 +44,19 @@ public List ranking(String emailActual, CriterioRanking criterio) /** Ranking solo entre algunos jugadores, como los miembros de una liga privada (HU-51). Null = todos. */ @Transactional(readOnly = true) public List ranking(String emailActual, CriterioRanking criterio, Set soloUsuarios) { + // Los emails se guardan en minúsculas: se normaliza para compararlo String yo = emailActual == null ? null : emailActual.trim().toLowerCase(Locale.ROOT); + // Una sola consulta agregada para todos: fila = [id, resueltas, ganadas, apostado, pagado] Map porUsuario = new HashMap<>(); for (Object[] fila : apuestas.resumenResueltasPorUsuario()) { + // El tipo exacto de count y sum (Long, Integer…) depende de la consulta: se leen como Number porUsuario.put((Long) fila[0], Estadisticas.de(((Number) fila[1]).longValue(), ((Number) fila[2]).longValue(), (BigDecimal) fila[3], (BigDecimal) fila[4])); } record Fila(Usuario usuario, Estadisticas estadisticas) { } + // Los jugadores sin apuestas resueltas también salen, con estadísticas vacías List filas = new ArrayList<>(usuarios.findByRolOrderBySaldoDescNombreAsc(Rol.USUARIO).stream() .filter(u -> !u.isEliminado()) .filter(u -> soloUsuarios == null || soloUsuarios.contains(u.getId())) @@ -63,8 +72,10 @@ record Fila(Usuario usuario, Estadisticas estadisticas) { .thenComparing(Comparator.comparing((Fila f) -> f.estadisticas().isTieneDatos()).reversed()) .thenComparing(Comparator.comparing((Fila f) -> f.estadisticas().getPorcentajeAciertos()).reversed()); }; + // A igualdad, por nombre, para que el orden sea siempre el mismo filas.sort(orden.thenComparing(f -> f.usuario().getNombre())); + // La posición es el índice + 1: no hay puestos compartidos aunque haya empates List puestos = new ArrayList<>(); for (int i = 0; i < filas.size(); i++) { Fila f = filas.get(i); @@ -77,6 +88,8 @@ record Fila(Usuario usuario, Estadisticas estadisticas) { /** * Perfil público de un jugador (HU-48): su puesto en el ranking por saldo y sus estadísticas, * sin sus apuestas. El creador de apuestas no juega y no tiene perfil. + * + * @throws JugadorNoEncontradoException si no es un jugador del ranking */ @Transactional(readOnly = true) public PerfilJugador perfil(Long usuarioId, String emailActual) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionMercadosService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionMercadosService.java index ccbaa09..f43dd57 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionMercadosService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionMercadosService.java @@ -10,7 +10,11 @@ import es.ucm.fdi.is1.apuestas.mercados.Mercado; import es.ucm.fdi.is1.apuestas.mercados.MercadoService; -/** Ganador y anulación de los mercados a largo plazo, y pago de sus apuestas (HU-45). */ +/** + * Ganador y anulación de los mercados a largo plazo, y pago de sus apuestas (HU-45). Lo usa el panel de + * gestión del creador. El {@link Mercado} cambia de estado y cada {@link Apuesta} afectada se recalcula y + * ajusta el saldo de su usuario; todo en la misma transacción. + */ @Service public class ResolucionMercadosService { @@ -25,7 +29,13 @@ public ResolucionMercadosService(MercadoService mercados, SeleccionRepository se this.avisos = avisos; } - /** Marca (o corrige) el ganador y paga las apuestas acertadas. Devuelve cuántas se han resuelto. */ + /** + * Marca (o corrige) el ganador y paga las apuestas acertadas; el resto pasan a perdidas (CA-45.3). + * Si se corrige, la apuesta solo abona o retira la diferencia con lo ya pagado. Devuelve cuántas se han resuelto. + * + * @throws IllegalArgumentException si el candidato no es de este mercado + * @throws IllegalStateException si el mercado está anulado + */ @Transactional public int resolver(Long mercadoId, Long candidatoId) { Mercado mercado = mercados.mercado(mercadoId); @@ -37,6 +47,7 @@ public int resolver(Long mercadoId, Long candidatoId) { List afectadas = selecciones.findByCandidatoMercado(mercado); for (Seleccion seleccion : afectadas) { Apuesta apuesta = seleccion.getApuesta(); + // Se guarda el estado anterior para avisar solo si cambia (HU-37) EstadoApuesta antes = apuesta.getEstado(); apuesta.resolver(mercado, ganador); avisos.siCambia(apuesta, antes); @@ -44,7 +55,11 @@ public int resolver(Long mercadoId, Long candidatoId) { return afectadas.size(); } - /** Anula el mercado y devuelve el importe de todas sus apuestas. */ + /** + * Anula el mercado y devuelve el importe de todas sus apuestas (las canceladas ya lo recuperaron). + * + * @throws IllegalStateException si el mercado ya estaba resuelto o anulado + */ @Transactional public int anular(Long mercadoId) { Mercado mercado = mercados.mercado(mercadoId); @@ -59,7 +74,7 @@ public int anular(Long mercadoId) { return afectadas.size(); } - /** Número de apuestas e importe apostado a cada candidato, sin contar las canceladas. */ + /** Número de apuestas e importe apostado a cada candidato, sin contar las canceladas ni las anuladas. */ @Transactional(readOnly = true) public List volumen(Long mercadoId) { Mercado mercado = mercados.mercado(mercadoId); @@ -78,6 +93,7 @@ public List volumen(Long mercadoId) { }).toList(); } + /** Una fila del volumen de un mercado: el candidato, lo apostado a él y en cuántas apuestas. */ public record VolumenCandidato(Candidato candidato, BigDecimal importe, int apuestas) { } } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionService.java index e1eb0f4..65fdd35 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResolucionService.java @@ -16,7 +16,12 @@ import es.ucm.fdi.is1.apuestas.eventos.EventoNoDisponibleException; import es.ucm.fdi.is1.apuestas.eventos.EventoRepository; -/** Acciones del creador de apuestas sobre un evento y sus apuestas (HU-04, HU-05, HU-06, HU-25). */ +/** + * Acciones del creador de apuestas sobre un evento y sus apuestas (HU-04, HU-05, HU-06, HU-25). + * Lo usan el panel de gestión y la sincronización con la API de resultados. El {@link Evento} cambia de + * estado y cada {@link Apuesta} afectada se recalcula y paga; {@link AvisosApuestas} avisa al usuario (HU-37). + * Cada método es una transacción: o se resuelven todas las apuestas o ninguna. + */ @Service public class ResolucionService { @@ -33,6 +38,7 @@ public ResolucionService(EventoRepository eventos, SeleccionRepository seleccion this.calculadora = calculadora; } + /** El evento en cualquier estado; EventoNoDisponibleException si no existe. */ @Transactional(readOnly = true) public Evento evento(Long id) { return eventos.findById(id).orElseThrow(() -> new EventoNoDisponibleException(id)); @@ -41,6 +47,10 @@ public Evento evento(Long id) { /** * Introduce o corrige el resultado: el evento pasa a finalizado y todas sus apuestas * se resuelven y se pagan. Si se corrige, los saldos se reajustan (HU-04, HU-25). + * Devuelve cuántas selecciones se han resuelto. + * + * @throws IllegalStateException si el evento está anulado + * @throws IllegalArgumentException si es un empate en un deporte sin empate */ @Transactional public int introducirResultado(Long eventoId, Resultado resultado) { @@ -51,7 +61,9 @@ public int introducirResultado(Long eventoId, Resultado resultado) { /** * Igual, pero con el marcador final de un partido de fútbol: además del 1X2 se resuelven las apuestas - * de goles y de ambos marcan (HU-52). Sin marcador, esas se anulan. + * de goles y de ambos marcan (HU-52). Con {@link #introducirResultado} no hay marcador y esas se anulan. + * + * @throws IllegalArgumentException si los goles son negativos o el deporte no es fútbol */ @Transactional public int introducirMarcador(Long eventoId, int golesLocal, int golesVisitante) { @@ -60,10 +72,16 @@ public int introducirMarcador(Long eventoId, int golesLocal, int golesVisitante) return resolverApuestas(evento); } + /** + * Resuelve todas las selecciones del evento. Una combinada solo cambia de estado cuando falla una + * selección o se conocen todas (HU-30); mientras tanto sigue activa. + */ private int resolverApuestas(Evento evento) { + // Por selección y no por apuesta: así salen también las combinadas que incluyen este evento List afectadas = selecciones.findByEvento(evento); for (Seleccion seleccion : afectadas) { Apuesta apuesta = seleccion.getApuesta(); + // Se guarda el estado anterior para avisar solo si cambia (HU-37) EstadoApuesta antes = apuesta.getEstado(); apuesta.resolver(evento, evento.getResultado()); avisos.siCambia(apuesta, antes); @@ -71,6 +89,7 @@ private int resolverApuestas(Evento evento) { return afectadas.size(); } + /** Deja de admitir apuestas nuevas hasta que se reactive; las ya hechas siguen igual (HU-05). */ @Transactional public void suspender(Long eventoId) { evento(eventoId).suspender(); @@ -82,17 +101,24 @@ public void suspenderPorApi(Long eventoId) { evento(eventoId).suspenderPorApi(); } + /** Vuelve a admitir apuestas un evento suspendido (HU-05). */ @Transactional public void reactivar(Long eventoId) { evento(eventoId).reactivar(); } - /** Anula el evento y devuelve el importe de todas sus apuestas activas (HU-05). */ + /** + * Anula el evento y devuelve el importe de sus apuestas simples activas (HU-05). En una combinada, la + * selección cuenta con cuota 1,00 y el resto sigue en juego (CA-30.3). + * + * @throws IllegalStateException si el evento ya estaba finalizado o anulado + */ @Transactional public int anular(Long eventoId) { Evento evento = evento(eventoId); evento.anular(); List afectadas = selecciones.findByEvento(evento); + // Cada apuesta decide: devuelve el importe si es simple, o sigue con cuota 1,00 si es combinada for (Seleccion seleccion : afectadas) { Apuesta apuesta = seleccion.getApuesta(); EstadoApuesta antes = apuesta.getEstado(); @@ -110,6 +136,7 @@ public int anular(Long eventoId) { @Transactional(readOnly = true) public List volumen(Long eventoId) { Evento evento = evento(eventoId); + // Las canceladas y las anuladas no cuentan: se devolvió el importe List delEvento = selecciones.findByEvento(evento).stream() .filter(s -> s.getApuesta().getEstado() != EstadoApuesta.CANCELADA && s.getApuesta().getEstado() != EstadoApuesta.ANULADA) @@ -123,9 +150,11 @@ public List volumen(Long eventoId) { volumen.add(fila(resultado.getSimbolo(), resultado.getDescripcion(), cuotas.de(resultado), delEvento.stream().filter(s -> s.getPronostico() == resultado).toList())); } + // Vacío si no es fútbol Map especiales = calculadora.especiales(evento); for (Especial especial : Especial.values()) { List aEste = delEvento.stream().filter(s -> s.getEspecial() == especial).toList(); + // Se muestra si se ofrece o si alguien le ha apostado (entonces puede ir sin cuota) if (especiales.containsKey(especial) || !aEste.isEmpty()) { volumen.add(fila(especial.getSimbolo(), especial.getDescripcion(), especiales.get(especial), aEste)); } @@ -133,6 +162,7 @@ public List volumen(Long eventoId) { return volumen; } + /** Suma el importe completo de las apuestas con esas selecciones y las cuenta. */ private static VolumenResultado fila(String simbolo, String descripcion, BigDecimal cuota, List aEsta) { BigDecimal total = aEsta.stream().map(s -> s.getApuesta().getImporte()) diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResultadoNoValidoException.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResultadoNoValidoException.java index 8528c84..bcd653a 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResultadoNoValidoException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/ResultadoNoValidoException.java @@ -3,12 +3,18 @@ import es.ucm.fdi.is1.apuestas.cuotas.Especial; import es.ucm.fdi.is1.apuestas.cuotas.Resultado; +/** + * El pronóstico pedido no se puede apostar en ese evento: un empate en un deporte sin empate, un tipo + * especial fuera del fútbol (HU-52) o no se ha elegido nada. La lanza {@link ApuestaService} al calcular la cuota. + */ public class ResultadoNoValidoException extends RuntimeException { + /** Empate en un deporte que no lo admite. */ public ResultadoNoValidoException(Resultado resultado) { super("El resultado " + resultado.getDescripcion() + " no es uno de los resultados posibles en este evento"); } + /** Doble oportunidad, goles o ambos marcan en un deporte que no es fútbol. */ public ResultadoNoValidoException(Especial especial) { super("La apuesta \"" + especial.getDescripcion() + "\" solo está disponible en los partidos de fútbol"); } @@ -18,6 +24,7 @@ public ResultadoNoValidoException() { super("Elige un pronóstico:"); } + /** La excepción adecuada cuando no hay cuota para esa combinación de resultado y tipo especial. */ static ResultadoNoValidoException de(Resultado resultado, Especial especial) { if (resultado != null && especial != null || resultado == null && especial == null) { return new ResultadoNoValidoException(); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Seleccion.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Seleccion.java index 6bb4061..a877163 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Seleccion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/Seleccion.java @@ -21,6 +21,9 @@ * Un pronóstico dentro de una apuesta, con la cuota fijada al apostar: el resultado de un evento (1/X/2), * otro tipo de apuesta del partido (doble oportunidad, goles o ambos marcan, HU-52) o el ganador de un mercado * a largo plazo (HU-44). Tiene evento y pronóstico, evento y especial, o candidato. + * + *

Entidad JPA. Solo la crea su {@link Apuesta} (los constructores no son públicos), que decide su + * propio estado a partir del de sus selecciones. */ @Entity public class Seleccion { @@ -32,21 +35,27 @@ public class Seleccion { @ManyToOne(optional = false) private Apuesta apuesta; + /** Partido al que se apuesta; null en las apuestas a largo plazo. */ @ManyToOne private Evento evento; + /** Resultado 1X2 elegido; null si es de un tipo especial o a largo plazo. */ @Enumerated(EnumType.STRING) private Resultado pronostico; + /** Doble oportunidad, goles o ambos marcan (HU-52); null en las demás. */ @Enumerated(EnumType.STRING) private Especial especial; + /** Candidato elegido de un mercado; solo en las apuestas a largo plazo. */ @ManyToOne private Candidato candidato; + /** Cuota fijada al apostar; solo cambia si se modifica el importe (HU-27). */ @Column(nullable = false, precision = 8, scale = 2) private BigDecimal cuota; + /** Pendiente hasta que se conoce el resultado del evento o el ganador del mercado. */ @Column(nullable = false) @Enumerated(EnumType.STRING) private EstadoSeleccion estado = EstadoSeleccion.PENDIENTE; @@ -80,10 +89,12 @@ public boolean isLargoPlazo() { return candidato != null; } + /** Se compara por id porque {@link Evento} no redefine {@code equals}. */ boolean esDe(Evento otro) { return evento != null && evento.getId().equals(otro.getId()); } + /** Si es una apuesta a largo plazo a un candidato de ese mercado. */ boolean esDe(Mercado mercado) { return candidato != null && candidato.getMercado().getId().equals(mercado.getId()); } @@ -96,21 +107,26 @@ boolean admiteCancelacion(LocalDateTime ahora) { return evento.getEstado() == EstadoEvento.PROGRAMADO && evento.getFechaHora().isAfter(ahora); } + /** Acertada si el candidato elegido es el ganador del mercado; fallada si no. */ void resolver(Candidato ganador) { estado = candidato.getId().equals(ganador.getId()) ? EstadoSeleccion.ACERTADA : EstadoSeleccion.FALLADA; } /** Con el resultado del evento y, si se conoce, su marcador (las de goles se anulan sin él). */ void resolver(Resultado resultado, Integer golesLocal, Integer golesVisitante) { + // Una 1X2 se acierta si coincide el resultado; una especial lo decide Especial.acierta, + // que devuelve null si hace falta el marcador y solo se conoce el ganador Boolean acierto = especial != null ? especial.acierta(resultado, golesLocal, golesVisitante) : Boolean.valueOf(pronostico == resultado); if (acierto == null) { + // No se puede decidir: se anula y cuenta con cuota 1,00 (CA-52.3) estado = EstadoSeleccion.ANULADA; } else { estado = acierto ? EstadoSeleccion.ACERTADA : EstadoSeleccion.FALLADA; } } + /** El evento o el mercado se ha anulado: la selección cuenta con cuota 1,00 (HU-05, HU-30). */ void anular() { estado = EstadoSeleccion.ANULADA; } @@ -120,7 +136,7 @@ void actualizarCuota(BigDecimal nueva) { cuota = nueva; } - /** Cuota que cuenta para el pago: 1,00 si el evento se anuló. */ + /** Cuota que cuenta para el pago: 1,00 si la selección está anulada (evento o mercado anulado, o goles sin marcador). */ BigDecimal getCuotaEfectiva() { return estado == EstadoSeleccion.ANULADA ? BigDecimal.ONE : cuota; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionPedida.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionPedida.java index 05d347a..13b2be4 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionPedida.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionPedida.java @@ -7,11 +7,16 @@ /** * Selección que pide el usuario al confirmar el boleto: un resultado (1X2) o un tipo especial (HU-52). + * Es lo que recibe {@link ApuestaService#apostar(String, java.util.List, BigDecimal)}; la cuota real + * se calcula siempre en el servidor. * + * @param resultado pronóstico 1X2; null si se pide un tipo especial + * @param especial tipo especial; null si se pide un resultado * @param cuotaVista cuota que vio el usuario; null si no hay que comprobarla */ public record SeleccionPedida(Long eventoId, Resultado resultado, Especial especial, BigDecimal cuotaVista) { + /** Selección 1X2. */ public SeleccionPedida(Long eventoId, Resultado resultado, BigDecimal cuotaVista) { this(eventoId, resultado, null, cuotaVista); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionRepository.java index b3ddb89..45554d0 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/SeleccionRepository.java @@ -8,12 +8,19 @@ import es.ucm.fdi.is1.apuestas.eventos.Evento; import es.ucm.fdi.is1.apuestas.mercados.Mercado; +/** + * Repositorio de {@link Seleccion}. Se busca por selección y no por apuesta porque al resolver un evento + * o un mercado hay que encontrar todas las apuestas que lo incluyen, sean simples o combinadas. + */ public interface SeleccionRepository extends JpaRepository { + /** Todas las selecciones de un evento, de apuestas en cualquier estado. */ List findByEvento(Evento evento); + /** Si alguien ha apostado alguna vez al evento: entonces no se puede borrar ni cambiar de equipos (HU-46). */ boolean existsByEvento(Evento evento); + /** Selecciones a cualquier candidato del mercado (navega {@code candidato.mercado}). */ List findByCandidatoMercado(Mercado mercado); /** diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenApuestas.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenApuestas.java index 5ab0467..25f9167 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenApuestas.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenApuestas.java @@ -20,10 +20,15 @@ * y cancelar es gratis, sin este límite un solo usuario podía mover las cuotas a su favor (apostar mucho a * un resultado, apostar al contrario con la cuota inflada y cancelar lo primero). * + * + *

Implementa la interfaz {@link VolumenApostado} del paquete de cuotas: así + * {@link es.ucm.fdi.is1.apuestas.cuotas.CalculadoraCuotas} obtiene el volumen sin depender de las apuestas. + * Solo cuentan las selecciones 1X2: los otros tipos de apuesta no tienen ajuste por volumen. */ @Component class VolumenApuestas implements VolumenApostado { + /** Máximo que cuenta de un mismo usuario en un mismo resultado, en moneditas. */ static final BigDecimal APORTACION_MAXIMA = new BigDecimal("100"); private final SeleccionRepository selecciones; @@ -32,20 +37,25 @@ class VolumenApuestas implements VolumenApostado { this.selecciones = selecciones; } + /** Importe que cuenta en cada resultado del evento; los resultados sin apuestas no aparecen en el mapa. */ @Override public Map importes(Evento evento) { Map importes = new EnumMap<>(Resultado.class); + // Un evento sin guardar (sin id) aún no puede tener apuestas if (evento.getId() == null) { return importes; } record Clave(Resultado resultado, Long usuario) { } Map porUsuario = new HashMap<>(); + // fila = [pronóstico, id del usuario, importe de la apuesta, número de selecciones] for (Object[] fila : selecciones.seleccionesActivasPorPronostico(evento)) { + // Parte de la apuesta que corresponde a esta selección: importe / número de selecciones BigDecimal parte = ((BigDecimal) fila[2]) .divide(BigDecimal.valueOf(((Number) fila[3]).longValue()), MathContext.DECIMAL64); porUsuario.merge(new Clave((Resultado) fila[0], (Long) fila[1]), parte, BigDecimal::add); } + // Primero se suma por usuario y resultado; después se aplica el tope y se suma por resultado porUsuario.forEach((clave, importe) -> importes.merge(clave.resultado(), importe.min(APORTACION_MAXIMA), BigDecimal::add)); return importes; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenResultado.java b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenResultado.java index bfcfcc0..7ad6348 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenResultado.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/apuesta/VolumenResultado.java @@ -4,7 +4,11 @@ /** * Cuánto se ha apostado a una opción de un evento y en cuántas apuestas (HU-06): un resultado (1X2) - * o uno de los otros tipos de apuesta (HU-52), con su cuota actual. + * o uno de los otros tipos de apuesta (HU-52), con su cuota actual. Lo calcula {@link ResolucionService#volumen}. + * + * @param cuota cuota actual; null si la opción ya no se ofrece pero tiene apuestas + * @param importe suma de los importes completos de las apuestas (también de las combinadas) + * @param apuestas número de apuestas que incluyen esta opción */ public record VolumenResultado(String simbolo, String descripcion, BigDecimal cuota, BigDecimal importe, long apuestas) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/config/DatosIniciales.java b/src/main/java/es/ucm/fdi/is1/apuestas/config/DatosIniciales.java index a1bc7b9..df860c4 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/config/DatosIniciales.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/config/DatosIniciales.java @@ -27,8 +27,11 @@ /** * Datos iniciales: usuarios de prueba, competiciones, equipos, partidos reales de LaLiga y Champions 2026/27 * y mercados a largo plazo. - * Solo se añade lo que falta (por nombre), así que no duplica ni borra lo que el creador - * de apuestas haya introducido desde el panel de gestión. + * De usuarios, competiciones y equipos solo se añade lo que falta (por email o por nombre), así que no duplica + * ni borra lo que el creador de apuestas haya introducido desde el panel de gestión. Los partidos y los mercados + * solo se cargan si la base de datos no tiene ninguno (ver {@code cargarPartidos}). + * + *

Como {@link ApplicationRunner}, Spring lo ejecuta una vez en cada arranque, cuando la aplicación ya está lista. */ @Component public class DatosIniciales implements ApplicationRunner { @@ -52,8 +55,9 @@ public class DatosIniciales implements ApplicationRunner { /** * Las contraseñas de los usuarios de prueba se pueden cambiar con las variables de entorno - * CREADOR_PASSWORD y USUARIO_PASSWORD. En el perfil {@code nube} la del creador es obligatoria - * y el usuario de prueba solo se crea si se le da contraseña. + * CREADOR_PASSWORD y USUARIO_PASSWORD. En los perfiles {@code compartir} y {@code nube} la del creador es + * obligatoria y se le aplica aunque ya exista ({@code passwordCreadorFija}); en {@code nube} el usuario de + * prueba solo se crea si se le da contraseña. */ public DatosIniciales(CompeticionRepository competiciones, EquipoRepository equipos, EventoRepository eventos, MercadoRepository mercados, UsuarioRepository usuarioRepository, UsuarioService usuarios, @@ -71,7 +75,10 @@ public DatosIniciales(CompeticionRepository competiciones, EquipoRepository equi this.passwordCreadorFija = passwordCreadorFija; } - /** Identificadores de los escudos en el servicio público de football-data.org. */ + /** + * Identificadores de los escudos en el servicio público de football-data.org. Son los mismos que usa su API, + * así que también sirven para enlazar cada equipo con ella ({@code idExterno}). + */ private static final Map ESCUDOS = Map.ofEntries( Map.entry("Real Madrid", 86), Map.entry("FC Barcelona", 81), Map.entry("Atlético de Madrid", 78), Map.entry("Athletic Club", 77), @@ -102,25 +109,35 @@ public DatosIniciales(CompeticionRepository competiciones, EquipoRepository equi Map.entry("Slovan Bratislava", 7509), Map.entry("LASK", 2016), Map.entry("Viking FK", 5720), Map.entry("Sabah FK", 10233)); + /** + * Carga los datos que falten. Va en una sola transacción: si algo falla, no queda nada a medias. + * + * @throws IllegalStateException si no hay contraseña para el creador; entonces la aplicación no arranca + */ @Override @Transactional public void run(ApplicationArguments args) { + // En local tiene valor por defecto; en los perfiles compartir y nube solo existe si se define CREADOR_PASSWORD if (passwordCreador.isBlank()) { throw new IllegalStateException("Falta la contraseña del creador de apuestas: define la variable de entorno " + "CREADOR_PASSWORD"); } + // Si el usuario ya existe no se crea ni se cambia su contraseña usuario("creador@apuestas.es", "Creador de apuestas", passwordCreador, Rol.CREADOR); if (passwordCreadorFija) { // Perfiles públicos (compartir, nube): manda la contraseña de la variable, no la que ya hubiera usuarios.fijarPassword("creador@apuestas.es", passwordCreador); } + // Sin contraseña (perfil nube sin USUARIO_PASSWORD) no se crea el usuario de prueba if (!passwordUsuario.isBlank()) { usuario("usuario@apuestas.es", "Usuario de prueba", passwordUsuario, Rol.USUARIO); } + // Se decide antes de crear nada: con la base de datos vacía se cargan partidos y mercados de ejemplo cargarPartidos = eventos.count() == 0; cargarMercados = mercados.count() == 0; + // Competiciones (se crean si no existen) Competicion laLiga = competicion("LaLiga", Deporte.FUTBOL); Competicion champions = competicion("Champions League", Deporte.FUTBOL); Competicion acb = competicion("Liga ACB", Deporte.BALONCESTO); @@ -336,7 +353,10 @@ private void mercadosALargoPlazo() { "Charles Leclerc;9.00", "Lewis Hamilton;15.00", "Fernando Alonso;40.00", "Carlos Sainz;50.00"); } - /** Candidatos con el formato "Nombre;cuota". Si el nombre es un equipo o deportista, se enlaza con él. */ + /** + * Candidatos con el formato "Nombre;cuota". Si el nombre es un equipo o deportista, se enlaza con él. + * Si ya hay un mercado con ese nombre, no se toca. + */ private void mercado(String nombre, Deporte deporte, LocalDateTime cierre, String... candidatos) { if (mercados.findByNombre(nombre).isPresent()) { return; @@ -350,6 +370,7 @@ private void mercado(String nombre, Deporte deporte, LocalDateTime cierre, Strin mercados.save(mercado); } + /** Crea el usuario si no hay ninguno con ese email. */ private void usuario(String email, String nombre, String password, Rol rol) { if (!usuarioRepository.existsByEmail(email)) { usuarios.crear(email, nombre, password, rol); @@ -361,6 +382,7 @@ private Competicion competicion(String nombre, Deporte deporte) { .orElseGet(() -> competiciones.save(new Competicion(nombre, deporte))); } + /** Crea el equipo con su calificación y sus competiciones si no hay ninguno con ese nombre (no pisa cambios de Gestión). */ private void equipo(String nombre, Deporte deporte, double calidad, Competicion... enCompeticiones) { if (equipos.findByNombre(nombre).isPresent()) { return; @@ -372,6 +394,10 @@ private void equipo(String nombre, Deporte deporte, double calidad, Competicion. equipos.save(equipo); } + /** + * Crea un partido, en hora de Madrid, solo si la base de datos no tenía eventos. Los dos equipos tienen que + * existir ya: si no, {@code orElseThrow} lanza una excepción y el arranque falla. + */ private void partido(Competicion competicion, String fase, String local, String visitante, int anio, int mes, int dia, int hora, int minuto) { if (!cargarPartidos) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/config/RelojConfig.java b/src/main/java/es/ucm/fdi/is1/apuestas/config/RelojConfig.java index cc76e24..5472667 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/config/RelojConfig.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/config/RelojConfig.java @@ -15,6 +15,10 @@ @Configuration public class RelojConfig { + /** + * Reloj del sistema en la zona de apuestas.zona-horaria (Europe/Madrid si no se indica). + * Los servicios piden la fecha con {@code LocalDateTime.now(reloj)} en vez de {@code LocalDateTime.now()}. + */ @Bean public Clock reloj(@Value("${apuestas.zona-horaria:Europe/Madrid}") String zonaHoraria) { return Clock.system(ZoneId.of(zonaHoraria)); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/config/SeguridadConfig.java b/src/main/java/es/ucm/fdi/is1/apuestas/config/SeguridadConfig.java index 3716533..72202b7 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/config/SeguridadConfig.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/config/SeguridadConfig.java @@ -12,15 +12,23 @@ import es.ucm.fdi.is1.apuestas.usuarios.RecargaAlEntrarHandler; +/** + * Configuración de Spring Security: quién puede entrar en cada página, inicio y cierre de sesión, + * sesiones abiertas y cifrado de contraseñas (HU-08, HU-11, HU-12, HU-18, HU-47). + * Hay tres niveles: visitante sin sesión (catálogo público), usuario con sesión (apostar, cuenta…) + * y creador de apuestas (rol CREADOR: panel de Gestión). + */ @Configuration public class SeguridadConfig { + /** Reglas de acceso. Se comprueban en orden y se aplica la primera que encaja con la dirección. */ @Bean public SecurityFilterChain filtroSeguridad(HttpSecurity http, RecargaAlEntrarHandler alEntrar, SessionRegistry registroSesiones) throws Exception { http .authorizeHttpRequests(peticiones -> peticiones // Páginas públicas: cualquier visitante puede ver el catálogo (HU-08) + // ("/equipos/*" y "/jugadores/*" abarcan un solo nivel: la ficha, no sus subpáginas) .requestMatchers("/", "/eventos", "/equipos", "/equipos/*", "/mercados", "/ranking", "/jugadores/*", "/registro", "/login", "/recuperar", "/recuperar/nueva", "/css/**", "/error").permitAll() // Consola de la base de datos (solo existe en local): únicamente para el creador @@ -29,23 +37,31 @@ public SecurityFilterChain filtroSeguridad(HttpSecurity http, RecargaAlEntrarHan .requestMatchers("/gestion/**").hasRole("CREADOR") // El resto (apostar, cuenta…) exige haber iniciado sesión .anyRequest().authenticated()) + // Formulario propio en /login; el campo del usuario se llama email. Al entrar, RecargaAlEntrarHandler + // aplica la recarga gratuita pendiente (HU-14) y vuelve a la página que se pedía .formLogin(login -> login .loginPage("/login") .usernameParameter("email") .successHandler(alEntrar) .permitAll()) + // Al cerrar sesión se vuelve a la portada .logout(logout -> logout.logoutSuccessUrl("/")) // Registro de sesiones: al eliminar la cuenta o cambiar la contraseña se cierran las de otros navegadores + // (-1 = sin límite de sesiones por usuario; una sesión cerrada así lleva a /login?expirada) .sessionManagement(sesiones -> sesiones .maximumSessions(-1) .sessionRegistry(registroSesiones) .expiredUrl("/login?expirada")) + // CSRF activado: cada formulario POST lleva un token oculto (Thymeleaf lo añade solo), así otra web + // no puede enviar formularios en nombre del usuario. // La consola de H2 (solo desarrollo) usa frames y no envía token CSRF .csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")) + // Frames solo desde la propia web (los necesita la consola de H2); otras webs no pueden incrustarla .headers(cabeceras -> cabeceras.frameOptions(opciones -> opciones.sameOrigin())); return http.build(); } + /** Sesiones abiertas de cada usuario; la usa {@code usuarios.Sesiones} para cerrar las de otros navegadores. */ @Bean public SessionRegistry registroSesiones() { return new SessionRegistryImpl(); @@ -57,6 +73,7 @@ public HttpSessionEventPublisher publicadorSesiones() { return new HttpSessionEventPublisher(); } + /** Las contraseñas se guardan con BCrypt: un hash con sal, nunca en claro (HU-11, HU-12). */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/CalculadoraCuotas.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/CalculadoraCuotas.java index df05ace..908efaf 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/CalculadoraCuotas.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/CalculadoraCuotas.java @@ -31,23 +31,45 @@ * {@value #GOLES_MEDIOS} × e^(±{@value #GOLES_POR_PUNTO} × diferencia). Más de 2,5 = 1 − P(2 goles o menos); ambos marcan = P(local marca) × P(visitante marca). *

  • Se aplica el mismo margen y los mismos límites; aquí no hay ajuste por volumen.
  • * + * + *

    Las cuotas no se guardan en el evento: se calculan cada vez que se piden. Lo usan el catálogo, el boleto y + * {@code ApuestaService}, que copia la cuota en la selección al apostar. El dinero apostado lo da + * {@link VolumenApostado}. Hay un ejemplo resuelto en {@code docs/wiki/Algoritmo-de-cuotas.md}. */ @Service public class CalculadoraCuotas { + /** Puntos de nivel que gana el local por jugar en casa: medio punto en la escala de calidad 0–10. */ static final double VENTAJA_LOCAL = 0.5; + /** Lo que vale cada punto de forma (de −2 a +2): como mucho mueve el nivel ±0,8, menos que un punto de calidad. */ static final double VALOR_FORMA = 0.4; + /** + * Pendiente de la curva logística: cuanto mayor, más rápido se va la probabilidad hacia el favorito. + * Con 0,45, un punto de diferencia reparte lo que no es empate ≈ 61 % / 39 %. + */ static final double PENDIENTE = 0.45; + /** Probabilidad de empate entre equipos igualados (diferencia 0). */ static final double EMPATE_MAXIMO = 0.30; + /** Suelo del empate: por grande que sea la diferencia, nunca baja del 10 %. */ static final double EMPATE_MINIMO = 0.10; + /** Lo que baja la probabilidad de empate por cada punto de diferencia; llega al mínimo con 10 puntos. */ static final double EMPATE_POR_PUNTO = 0.02; + /** Peso máximo del dinero apostado frente al nivel de los equipos. No se alcanza nunca: el nivel pesa siempre más del 70 %. */ static final double PESO_MAXIMO_VOLUMEN = 0.30; /** Con este importe total apostado, el volumen pesa la mitad de su máximo. */ static final double VOLUMEN_REFERENCIA = 1000; + /** + * Margen de la casa: las probabilidades se inflan un 7 %, así la suma de 1/cuota ronda 1,07 y la casa + * se queda a la larga ≈ 6,5 % de lo apostado (HU-03, HU-54). + */ static final double MARGEN_CASA = 1.07; + /** Cuota mínima (CA-03.3): por mucho que se apueste a un resultado, acertarlo siempre da algo de ganancia. */ static final BigDecimal CUOTA_MINIMA = new BigDecimal("1.01"); + /** Cuota máxima: limita lo que paga la casa por un resultado muy improbable. */ static final BigDecimal CUOTA_MAXIMA = new BigDecimal("50.00"); + /** Goles de media de cada equipo en un partido igualado (2,6 entre los dos). */ static final double GOLES_MEDIOS = 1.30; + /** Cuánto cambia la media de goles por punto de diferencia: el mejor marca más (× e^(0,1 × dif)) y el rival menos. */ static final double GOLES_POR_PUNTO = 0.10; private final VolumenApostado volumen; @@ -56,6 +78,10 @@ public CalculadoraCuotas(VolumenApostado volumen) { this.volumen = volumen; } + /** + * Cuotas 1X2 actuales de un evento: según el nivel de sus equipos, si el deporte admite empate y el dinero + * apostado a cada resultado. La de empate es null si el deporte no lo admite. + */ public Cuotas calcular(Evento evento) { return calcular(nivelLocal(evento), nivelVisitante(evento), evento.getDeporte().isAdmiteEmpate(), volumen.importes(evento)); @@ -77,19 +103,28 @@ public BigDecimal cuota(Evento evento, Resultado resultado, Especial especial) { return resultado == null ? null : calcular(evento).de(resultado); } + /** Cuotas especiales de fútbol a partir del nivel de los dos equipos (HU-52). */ public Map especiales(double nivelLocal, double nivelVisitante) { + // Probabilidades 1X2 por nivel, siempre con empate (es fútbol) y sin ajuste por volumen Map p = probabilidades(nivelLocal, nivelVisitante, true); + // La misma diferencia que en el 1X2, con la ventaja del local double diferencia = nivelLocal + VENTAJA_LOCAL - nivelVisitante; + // Media de goles de cada equipo: con diferencia 0 los dos marcan 1,3; el de más nivel marca más double golesLocal = GOLES_MEDIOS * Math.exp(GOLES_POR_PUNTO * diferencia); double golesVisitante = GOLES_MEDIOS * Math.exp(-GOLES_POR_PUNTO * diferencia); + // La suma de dos Poisson independientes es otra Poisson cuya media es la suma de las medias double total = golesLocal + golesVisitante; + // Poisson: P(0, 1 o 2 goles en total) = e^(−total) × (1 + total + total² / 2) double dosOMenos = Math.exp(-total) * (1 + total + total * total / 2); + // P(un equipo marca) = 1 − P(0 goles) = 1 − e^(−media). Se multiplican: los goles de uno no influyen en los del otro double ambos = (1 - Math.exp(-golesLocal)) * (1 - Math.exp(-golesVisitante)); Map cuotas = new EnumMap<>(Especial.class); + // Doble oportunidad: se suman las probabilidades de los dos resultados que cubre cuotas.put(Especial.DOBLE_1X, cuota(p.get(Resultado.LOCAL) + p.get(Resultado.EMPATE))); cuotas.put(Especial.DOBLE_X2, cuota(p.get(Resultado.EMPATE) + p.get(Resultado.VISITANTE))); cuotas.put(Especial.DOBLE_12, cuota(p.get(Resultado.LOCAL) + p.get(Resultado.VISITANTE))); + // Más y menos de 2,5 goles son contrarios: sus probabilidades suman 1 cuotas.put(Especial.MAS_2_5, cuota(1 - dosOMenos)); cuotas.put(Especial.MENOS_2_5, cuota(dosOMenos)); cuotas.put(Especial.AMBOS_SI, cuota(ambos)); @@ -115,6 +150,7 @@ public static double margenTeorico() { /** Calificación ajustada con la forma reciente (de −2 a +2). */ static double nivel(double calidad, int forma) { + // Paso 1: nivel = calidad + 0,4 × forma return calidad + VALOR_FORMA * forma; } @@ -123,28 +159,42 @@ public Cuotas calcular(double nivelLocal, double nivelVisitante, boolean admiteE return calcular(nivelLocal, nivelVisitante, admiteEmpate, Map.of()); } + /** + * Cuotas 1X2 a partir del nivel de los equipos y del dinero apostado a cada resultado (pueden faltar + * resultados sin apuestas). Si no se admite empate, la cuota de empate es null. + */ public Cuotas calcular(double nivelLocal, double nivelVisitante, boolean admiteEmpate, Map apostado) { + // Pasos 2 a 4: probabilidad de cada resultado según el nivel Map probabilidades = probabilidades(nivelLocal, nivelVisitante, admiteEmpate); + // Paso 5: se mezcla con el reparto del dinero apostado ajustarPorVolumen(probabilidades, apostado); + // Paso 6: margen de la casa, redondeo hacia abajo y límites return new Cuotas( cuota(probabilidades.get(Resultado.LOCAL)), admiteEmpate ? cuota(probabilidades.get(Resultado.EMPATE)) : null, cuota(probabilidades.get(Resultado.VISITANTE))); } + /** Probabilidades 1X2 por nivel, sin margen; suman 1. Sin empate, el mapa no tiene clave EMPATE. */ private static Map probabilidades(double nivelLocal, double nivelVisitante, boolean admiteEmpate) { + // Paso 2: diferencia de nivel; el local suma la ventaja de jugar en casa double diferencia = nivelLocal + VENTAJA_LOCAL - nivelVisitante; Map probabilidades = new EnumMap<>(Resultado.class); double probabilidadEmpate = 0; if (admiteEmpate) { + // Paso 3: 0,30 con equipos igualados; baja 0,02 por punto de diferencia (a favor de cualquiera + // de los dos, por eso el valor absoluto) y nunca baja de 0,10 probabilidadEmpate = Math.max(EMPATE_MINIMO, EMPATE_MAXIMO - EMPATE_POR_PUNTO * Math.abs(diferencia)); probabilidades.put(Resultado.EMPATE, probabilidadEmpate); } + // Paso 4: curva logística. Con diferencia 0 da 0,5; tiende a 1 si el local es mucho mejor y a 0 si lo es + // el visitante, pero nunca llega a 0 ni a 1: ningún resultado es imposible double repartoLocal = 1 / (1 + Math.exp(-PENDIENTE * diferencia)); + // Lo que no es empate se reparte entre local y visitante, así las tres probabilidades suman 1 probabilidades.put(Resultado.LOCAL, (1 - probabilidadEmpate) * repartoLocal); probabilidades.put(Resultado.VISITANTE, (1 - probabilidadEmpate) * (1 - repartoLocal)); return probabilidades; @@ -155,20 +205,28 @@ private static Map probabilidades(double nivelLocal, double n * Las dos partes suman 1, así que el margen de la casa se mantiene. */ private static void ajustarPorVolumen(Map probabilidades, Map apostado) { + // Solo cuenta el dinero de los resultados posibles (en un deporte sin empate no hay clave EMPATE) double total = probabilidades.keySet().stream() .mapToDouble(r -> apostado.getOrDefault(r, BigDecimal.ZERO).doubleValue()) .sum(); + // Sin apuestas, las cuotas dependen solo del nivel if (total <= 0) { return; } + // Crece con el dinero en juego: casi 0 con poco dinero, 0,15 con 1.000 moneditas y tiende a 0,30 sin + // llegar nunca. Así unas pocas apuestas apenas mueven las cuotas double peso = PESO_MAXIMO_VOLUMEN * total / (total + VOLUMEN_REFERENCIA); + // Media ponderada entre la probabilidad por nivel y la parte del dinero que hay en cada resultado probabilidades.replaceAll((resultado, p) -> (1 - peso) * p + peso * apostado.getOrDefault(resultado, BigDecimal.ZERO).doubleValue() / total); } /** Cuota = 1 / (probabilidad × margen), redondeada hacia abajo a 2 decimales y acotada. */ private static BigDecimal cuota(double probabilidad) { + // Multiplicar por el margen (1,07) baja todas las cuotas; redondear hacia abajo (1,789 → 1,78) + // las baja un poco más, nunca a favor del apostante BigDecimal cuota = BigDecimal.valueOf(1 / (probabilidad * MARGEN_CASA)).setScale(2, RoundingMode.DOWN); + // Entre 1,01 (CA-03.3) y 50 return cuota.max(CUOTA_MINIMA).min(CUOTA_MAXIMA); } } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Cuotas.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Cuotas.java index 5a734ce..25a1c0e 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Cuotas.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Cuotas.java @@ -5,6 +5,7 @@ /** * Cuotas decimales de un evento. {@code empate} es null en deportes sin empate. + * Las crea {@link CalculadoraCuotas} cada vez que se piden (HU-03, HU-20); no se guardan en la base de datos. */ public record Cuotas(BigDecimal local, BigDecimal empate, BigDecimal visitante) { @@ -17,8 +18,12 @@ public BigDecimal de(Resultado resultado) { }; } - /** Suma de probabilidades implícitas (1/cuota). Mayor que 1 significa margen para la casa. */ + /** + * Suma de probabilidades implícitas (1/cuota). Mayor que 1 significa margen para la casa. + * Las pruebas la usan para comprobar CA-03.2. + */ public BigDecimal sumaProbabilidadesImplicitas() { + // Con MathContext: sin él, una división con infinitos decimales (1/3) lanzaría ArithmeticException BigDecimal suma = BigDecimal.ONE.divide(local, MathContext.DECIMAL64) .add(BigDecimal.ONE.divide(visitante, MathContext.DECIMAL64)); if (empate != null) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Especial.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Especial.java index 95a1a69..e4c23ae 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Especial.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Especial.java @@ -3,6 +3,8 @@ /** * Otros tipos de apuesta de un partido de fútbol, además del 1X2 (HU-52): * doble oportunidad, más/menos de 2,5 goles y ambos marcan. + * Cada uno tiene un símbolo corto, una descripción y el grupo en el que se muestra en la página del partido. + * Sus cuotas las calcula {@link CalculadoraCuotas#especiales(double, double)}. */ public enum Especial { @@ -35,9 +37,11 @@ public boolean isNecesitaMarcador() { */ public Boolean acierta(Resultado resultado, Integer golesLocal, Integer golesVisitante) { if (!isNecesitaMarcador()) { + // Doble oportunidad: se acierta si no sale el único resultado que no cubre return switch (this) { case DOBLE_1X -> resultado != Resultado.VISITANTE; case DOBLE_X2 -> resultado != Resultado.LOCAL; + // DOBLE_12 default -> resultado != Resultado.EMPATE; }; } @@ -46,10 +50,12 @@ public Boolean acierta(Resultado resultado, Integer golesLocal, Integer golesVis } int total = golesLocal + golesVisitante; boolean ambos = golesLocal > 0 && golesVisitante > 0; + // Los goles son enteros: más de 2,5 es 3 o más y menos de 2,5 es 2 o menos return switch (this) { case MAS_2_5 -> total > 2; case MENOS_2_5 -> total < 3; case AMBOS_SI -> ambos; + // AMBOS_NO default -> !ambos; }; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Resultado.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Resultado.java index fae19c4..770791c 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Resultado.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/Resultado.java @@ -1,6 +1,10 @@ package es.ucm.fdi.is1.apuestas.cuotas; -/** Resultado de un evento sobre el que se apuesta (G/E/P desde el punto de vista del local). */ +/** + * Resultado de un evento sobre el que se apuesta (G/E/P desde el punto de vista del local). + * Es el pronóstico de una selección 1X2 y el resultado que se guarda al finalizar un evento (HU-04, HU-20, HU-23). + * {@code EMPATE} solo es posible en fútbol, el único deporte que admite empate. + */ public enum Resultado { LOCAL("1", "Gana el local"), diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/VolumenApostado.java b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/VolumenApostado.java index 482d2eb..78597eb 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/VolumenApostado.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/cuotas/VolumenApostado.java @@ -5,7 +5,11 @@ import es.ucm.fdi.is1.apuestas.eventos.Evento; -/** Dinero en juego en cada resultado de un evento, para ajustar las cuotas (fila 3 del backlog). */ +/** + * Dinero en juego en cada resultado de un evento, para ajustar las cuotas (fila 3 del backlog). + * Es una interfaz para que el paquete de cuotas no dependa del de apuestas: la implementa + * {@code apuesta.VolumenApuestas}, que limita lo que cuenta cada usuario y reparte el importe de las combinadas. + */ public interface VolumenApostado { /** Importe de las apuestas activas a cada resultado; los resultados sin apuestas pueden faltar. */ diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Alineacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Alineacion.java index 668c188..8c3ecbd 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Alineacion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Alineacion.java @@ -9,7 +9,12 @@ * Alineación probable en 4-3-3: por cada posición salen los de mejor nota; a igual nota, el dorsal * más bajo (los titulares suelen llevar del 1 al 11) y después el nombre. * - * @param lineas de arriba abajo: delanteros, centrocampistas, defensas y portero + *

    Objeto de valor de la ficha del equipo (HU-49). Lo crea {@link PlantillaService#alineacion(Equipo)} + * a partir de las notas que pone el creador a cada {@link Jugador}. + * + * @param sistema siempre "4-3-3" + * @param lineas de arriba abajo: delanteros, centrocampistas, defensas y portero + * @param notaMedia media de las notas de los once titulares, con un decimal */ public record Alineacion(String sistema, List lineas, double notaMedia) { @@ -17,19 +22,27 @@ public record Alineacion(String sistema, List lineas, double notaMedia) { static final Map HUECOS = Map.of( Posicion.DELANTERO, 3, Posicion.CENTROCAMPISTA, 3, Posicion.DEFENSA, 4, Posicion.PORTERO, 1); + /** Una fila del campo: los titulares de una posición, ordenados por dorsal. */ public record Linea(Posicion posicion, List jugadores) { } + /** Criterio para elegir titulares: nota de mayor a menor, después dorsal (sin dorsal al final) y nombre. */ static final Comparator MEJOR_PRIMERO = Comparator.comparingDouble(Jugador::getNota).reversed() .thenComparing(Jugador::getDorsal, Comparator.nullsLast(Comparator.naturalOrder())) .thenComparing(Jugador::getNombre); - /** Null si no hay jugadores suficientes en alguna posición. */ + /** + * Elige los titulares de la plantilla. + * + * @return null si no hay jugadores suficientes en alguna posición + */ public static Alineacion de(List plantilla) { List lineas = new ArrayList<>(); + // Se recorre de la delantera a la portería, que es como se pinta el campo de arriba abajo for (Posicion posicion : List.of(Posicion.DELANTERO, Posicion.CENTROCAMPISTA, Posicion.DEFENSA, Posicion.PORTERO)) { int cuantos = HUECOS.get(posicion); + // Los "cuantos" mejores de esa posición List elegidos = plantilla.stream() .filter(j -> j.getPosicion() == posicion) .sorted(MEJOR_PRIMERO) @@ -43,6 +56,7 @@ public static Alineacion de(List plantilla) { .sorted(Comparator.comparing(Jugador::getDorsal, Comparator.nullsLast(Comparator.naturalOrder()))) .toList())); } + // Media de los once, redondeada a un decimal double media = lineas.stream().flatMap(l -> l.jugadores().stream()).mapToDouble(Jugador::getNota) .average().orElse(0); return new Alineacion("4-3-3", lineas, Math.round(media * 10) / 10.0); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Competicion.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Competicion.java index ac2d8f8..77d912e 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Competicion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Competicion.java @@ -8,7 +8,11 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -/** Competición (LaLiga, Champions, ACB…) en la que participan equipos (HU-01). */ +/** + * Competición (LaLiga, Champions, ACB…) en la que participan equipos (HU-01). + * Entidad JPA: cada {@code Evento} pertenece a una y toma de ella su deporte. La relación con los equipos + * se guarda en {@link Equipo#getCompeticiones()}. + */ @Entity public class Competicion { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/CompeticionRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/CompeticionRepository.java index 7cb1023..feeaa0a 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/CompeticionRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/CompeticionRepository.java @@ -5,8 +5,10 @@ import org.springframework.data.jpa.repository.JpaRepository; +/** Repositorio Spring Data de {@link Competicion}; las consultas salen del nombre de cada método. */ public interface CompeticionRepository extends JpaRepository { + /** Todas, agrupadas por deporte (en el orden del enum) y por nombre dentro de cada deporte. */ List findAllByOrderByDeporteAscNombreAsc(); /** @@ -17,7 +19,9 @@ default Optional findByNombre(String nombre) { return findFirstByNombreOrderByIdAsc(nombre); } + /** La de menor id (la más antigua) con ese nombre exacto. */ Optional findFirstByNombreOrderByIdAsc(String nombre); + /** Para no dar de alta dos competiciones con el mismo nombre escrito con otras mayúsculas. */ boolean existsByNombreIgnoreCase(String nombre); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Deporte.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Deporte.java index 88d321c..3390d14 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Deporte.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Deporte.java @@ -1,11 +1,19 @@ package es.ucm.fdi.is1.apuestas.equipos; +/** + * Deportes de la web, con el nombre y el icono que se muestran y dos propiedades que cambian las reglas: + * si hay empate (mercado 1X2 o solo 1/2) y si compiten deportistas individuales en lugar de equipos. + * El orden de los valores es el de las pestañas del catálogo y del listado de equipos. + */ public enum Deporte { + /** El único con empate; también el único con alineación probable (HU-49) y apuestas de goles (HU-52). */ FUTBOL("Fútbol", "⚽", true, false), BALONCESTO("Baloncesto", "🏀", false, false), TENIS("Tenis", "🎾", false, true), + /** Fórmula 1: cada evento enfrenta a dos pilotos. */ AUTOMOVILISMO("Fórmula 1", "🏎️", false, true), + /** MotoGP: cada evento enfrenta a dos pilotos. */ MOTOCICLISMO("MotoGP", "🏍️", false, true); private final String nombre; @@ -28,7 +36,7 @@ public String getIcono() { return icono; } - /** En baloncesto o tenis no hay empate: solo se apuesta a 1 o 2. */ + /** Solo el fútbol admite empate; en baloncesto, tenis o motor solo se apuesta a 1 o 2. */ public boolean isAdmiteEmpate() { return admiteEmpate; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Equipo.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Equipo.java index d54b18c..231eec6 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Equipo.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Equipo.java @@ -19,6 +19,10 @@ /** * Equipo o deportista sobre el que se puede apostar (HU-01). * La calificación de calidad (0-10) es la base del cálculo de cuotas. + * + *

    Entidad JPA. Participa en varias {@link Competicion}es, juega como local o visitante en los + * {@code Evento}s y puede tener una plantilla de {@link Jugador}es (HU-49). Su ficha pública la + * monta {@code FichaEquipoService} (HU-31). */ @Entity public class Equipo { @@ -34,11 +38,13 @@ public class Equipo { @Enumerated(EnumType.STRING) private Deporte deporte; + /** Calificación de calidad de 0 a 10 (CA-01.2: fuera de rango se rechaza al validar). */ @NotNull @DecimalMin("0.0") @DecimalMax("10.0") private Double calidad; + /** Se cargan siempre junto con el equipo (EAGER). */ @ManyToMany(fetch = FetchType.EAGER) private Set competiciones = new HashSet<>(); @@ -65,10 +71,12 @@ public Equipo(String nombre, Deporte deporte, Double calidad) { this.calidad = calidad; } + /** Lo apunta a una competición; si ya estaba, no cambia nada (es un conjunto). */ public void participaEn(Competicion competicion) { competiciones.add(competicion); } + /** True si juega en la competición con ese id; lo usa el filtro del listado de equipos. */ public boolean participaEn(Long competicionId) { return competiciones.stream().anyMatch(c -> c.getId().equals(competicionId)); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquipoRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquipoRepository.java index 6acc1ea..6be02a9 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquipoRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquipoRepository.java @@ -5,8 +5,10 @@ import org.springframework.data.jpa.repository.JpaRepository; +/** Repositorio Spring Data de {@link Equipo}; las consultas salen del nombre de cada método. */ public interface EquipoRepository extends JpaRepository { + /** Todos, agrupados por deporte (en el orden del enum) y por nombre dentro de cada deporte. */ List findAllByOrderByDeporteAscNombreAsc(); /** @@ -17,10 +19,13 @@ default Optional findByNombre(String nombre) { return findFirstByNombreOrderByIdAsc(nombre); } + /** El de menor id (el más antiguo) con ese nombre exacto. */ Optional findFirstByNombreOrderByIdAsc(String nombre); + /** Para no dar de alta dos equipos con el mismo nombre escrito con otras mayúsculas. */ boolean existsByNombreIgnoreCase(String nombre); + /** Por su identificador en football-data.org, al sincronizar con la API. */ Optional findByIdExterno(Integer idExterno); List findByDeporte(Deporte deporte); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquiposService.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquiposService.java index b55c7d4..e6b2e47 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquiposService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/EquiposService.java @@ -9,7 +9,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** Listado público de equipos y deportistas, por deporte y competición, con buscador. */ +/** + * Listado público de equipos y deportistas, por deporte y competición, con buscador. + * Servicio de solo lectura que usa {@code InicioController} en {@code GET /equipos}; desde cada equipo + * se entra en su ficha (HU-31). + */ @Service public class EquiposService { @@ -21,7 +25,13 @@ public EquiposService(EquipoRepository equipos, CompeticionRepository competicio this.competiciones = competiciones; } - /** Equipos agrupados por deporte y ordenados por nombre; los filtros son opcionales. */ + /** + * Equipos agrupados por deporte y ordenados por nombre; los filtros son opcionales. + * + * @param deporte null para todos los deportes + * @param competicionId null para todas las competiciones + * @param texto parte del nombre, sin distinguir mayúsculas ni tildes; null o vacío para no filtrar + */ @Transactional(readOnly = true) public Map> buscar(Deporte deporte, Long competicionId, String texto) { String buscado = Nombres.normalizar(texto); @@ -29,7 +39,9 @@ public Map> buscar(Deporte deporte, Long competicionId, St .filter(e -> deporte == null || e.getDeporte() == deporte) .filter(e -> competicionId == null || e.participaEn(competicionId)) .filter(e -> buscado.isEmpty() || Nombres.normalizar(e.getNombre()).contains(buscado)) + // Por el nombre normalizado, para que una inicial con tilde ("Á") no quede detrás de la "Z" .sorted(Comparator.comparing(e -> Nombres.normalizar(e.getNombre()))) + // EnumMap: los deportes salen en el orden del enum .collect(Collectors.groupingBy(Equipo::getDeporte, () -> new EnumMap<>(Deporte.class), Collectors.toList())); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Forma.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Forma.java index 74a305c..0221c33 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Forma.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Forma.java @@ -2,12 +2,14 @@ /** * Factor manual de forma reciente de un equipo (HU-02): rachas, moral, lesiones… - * Lo fija el creador de apuestas y se suma a la calificación al calcular las cuotas. + * Lo fija el creador de apuestas y, al calcular las cuotas, su valor multiplicado por un peso se suma a la + * calificación (ver {@code CalculadoraCuotas}). Cada valor lleva el texto y el símbolo que se muestran. */ public enum Forma { MUY_MALA(-2, "Muy mala racha", "▼▼"), MALA(-1, "Mala racha", "▼"), + /** Valor por defecto: no cambia la calificación. */ NORMAL(0, "Normal", "–"), BUENA(1, "Buena racha", "▲"), MUY_BUENA(2, "Muy buena racha", "▲▲"); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Jugador.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Jugador.java index def993c..82a2b75 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Jugador.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Jugador.java @@ -14,10 +14,14 @@ /** * Jugador de la plantilla de un equipo. Llega de la API (con su identificador) o lo da de alta el creador. * La nota (0-10) la pone el creador y decide quién sale en la alineación probable. + * + *

    Entidad JPA de las plantillas (HU-49, HU-50). La gestiona {@link PlantillaService}; la + * sincronización con la API crea y actualiza los que tienen {@code idExterno} sin tocar su nota. */ @Entity public class Jugador { + /** Nota con la que empieza todo jugador nuevo, hasta que el creador la cambie. */ public static final double NOTA_INICIAL = 6.0; @Id @@ -40,6 +44,7 @@ public class Jugador { private LocalDate fechaNacimiento; + /** De 0 a 10, con un decimal. */ @Column(nullable = false) private double nota = NOTA_INICIAL; @@ -57,7 +62,10 @@ public Jugador(Equipo equipo, String nombre, Posicion posicion) { this.posicion = posicion; } - /** Datos que vienen de la API; la nota no se toca, es del creador. */ + /** + * Datos que vienen de la API; la nota no se toca, es del creador. + * Cambia también el equipo, por si el jugador ha fichado por otro. + */ public void actualizar(Equipo nuevoEquipo, String nuevoNombre, Posicion nuevaPosicion, Integer nuevoDorsal, String nuevaNacionalidad, LocalDate nuevaFecha) { equipo = nuevoEquipo; @@ -68,7 +76,13 @@ public void actualizar(Equipo nuevoEquipo, String nuevoNombre, Posicion nuevaPos fechaNacimiento = nuevaFecha; } + /** + * El creador puntúa al jugador (HU-50). La nota se redondea a un decimal. + * + * @throws IllegalArgumentException si no está entre 0 y 10 + */ public void cambiarNota(double nueva) { + // NaN no cumple ninguna comparación, así que se comprueba aparte if (Double.isNaN(nueva) || nueva < 0 || nueva > 10) { throw new IllegalArgumentException("La nota debe estar entre 0 y 10"); } @@ -80,6 +94,7 @@ public Integer getEdad() { return fechaNacimiento == null ? null : Period.between(fechaNacimiento, LocalDate.now()).getYears(); } + /** True si viene de la API; los añadidos a mano no los borra la sincronización (CA-50.2). */ public boolean isDeLaApi() { return idExterno != null; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/JugadorRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/JugadorRepository.java index ce06470..fd933b2 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/JugadorRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/JugadorRepository.java @@ -5,10 +5,12 @@ import org.springframework.data.jpa.repository.JpaRepository; +/** Repositorio Spring Data de {@link Jugador}; las consultas salen del nombre de cada método. */ public interface JugadorRepository extends JpaRepository { List findByEquipo(Equipo equipo); + /** Por su identificador en football-data.org, al descargar las plantillas (HU-50). */ Optional findByIdExterno(Integer idExterno); long countByEquipo(Equipo equipo); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Nombres.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Nombres.java index c4630cc..ae6f788 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Nombres.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Nombres.java @@ -6,7 +6,11 @@ import java.util.Set; import java.util.stream.Collectors; -/** Utilidades para comparar nombres de equipos y textos sin tildes ni mayúsculas. */ +/** + * Utilidades para comparar nombres de equipos y textos sin tildes ni mayúsculas. + * Las usan los buscadores (HU-22) y la sincronización con la API, para reconocer un equipo que allí + * se llama de otra forma. Clase de métodos estáticos: no se instancia. + */ public final class Nombres { /** Palabras que no distinguen a un club: "FC Barcelona" y "Barcelona" son el mismo. */ @@ -16,11 +20,12 @@ public final class Nombres { private Nombres() { } - /** "Atlético" → "atletico". */ + /** "Atlético" → "atletico". Quita los espacios de los extremos; null se convierte en "". */ public static String normalizar(String texto) { if (texto == null) { return ""; } + // NFD separa cada letra de su tilde ("é" → "e" + "´") y \p{M} borra esas marcas return Normalizer.normalize(texto.trim(), Normalizer.Form.NFD) .replaceAll("\\p{M}", "") .toLowerCase(Locale.ROOT); @@ -28,6 +33,7 @@ public static String normalizar(String texto) { /** Clave para reconocer un club con nombres distintos: "Club Atlético de Madrid" → "atletico madrid". */ public static String clave(String nombre) { + // Se parte por todo lo que no sea letra o número y se quitan las palabras genéricas return Arrays.stream(normalizar(nombre).split("[^a-z0-9]+")) .filter(p -> !p.isBlank() && !GENERICAS.contains(p)) .collect(Collectors.joining(" ")); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/PlantillaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/PlantillaService.java index e3c0725..3739da0 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/PlantillaService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/PlantillaService.java @@ -9,7 +9,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -/** Plantillas de los equipos: consulta pública y alta, nota y baja de jugadores por el creador. */ +/** + * Plantillas de los equipos: consulta pública y alta, nota y baja de jugadores por el creador. + * Implementa la parte de plantilla y alineación de HU-49 y la gestión a mano de HU-50 (la descarga desde + * la API está en el paquete {@code api}). Lo usan {@code FichaEquipoService} y {@code GestionController}. + */ @Service public class PlantillaService { @@ -24,6 +28,8 @@ public PlantillaService(JugadorRepository jugadores, EquipoRepository equipos) { /** Jugadores agrupados por posición (de portero a delantero) y ordenados por dorsal. */ @Transactional(readOnly = true) public Map> plantilla(Equipo equipo) { + // Primero por dorsal (sin dorsal al final) y, si empatan, por nombre. + // El EnumMap ordena los grupos como el enum Posicion: portero, defensa, centrocampista, delantero return jugadores.findByEquipo(equipo).stream() .sorted(Comparator.comparing(Jugador::getDorsal, Comparator.nullsLast(Comparator.naturalOrder())) .thenComparing(Jugador::getNombre)) @@ -31,7 +37,12 @@ public Map> plantilla(Equipo equipo) { Collectors.toList())); } - /** Alineación probable; solo en fútbol y si hay jugadores suficientes en cada posición. */ + /** + * Alineación probable; solo en fútbol y si hay jugadores suficientes en cada posición. + * Cómo se eligen los titulares está en {@link Alineacion#de(List)}. + * + * @return null en otro deporte o si faltan jugadores + */ @Transactional(readOnly = true) public Alineacion alineacion(Equipo equipo) { if (equipo.getDeporte() != Deporte.FUTBOL) { @@ -40,6 +51,14 @@ public Alineacion alineacion(Equipo equipo) { return Alineacion.de(jugadores.findByEquipo(equipo)); } + /** + * El creador añade un jugador a mano (HU-50). Sin {@code idExterno}, así que la sincronización con la + * API no lo borra. Dorsal, nota y nacionalidad son opcionales; sin nota se queda con + * {@link Jugador#NOTA_INICIAL}. + * + * @throws IllegalArgumentException si el equipo no existe o algún dato no es válido; el mensaje + * se muestra tal cual al creador + */ @Transactional public Jugador anadir(Long equipoId, String nombre, Posicion posicion, Integer dorsal, Double nota, String nacionalidad) { @@ -58,6 +77,7 @@ public Jugador anadir(Long equipoId, String nombre, Posicion posicion, Integer d } Jugador jugador = new Jugador(equipo, nombre.trim(), posicion); jugador.setDorsal(dorsal); + // Una nacionalidad en blanco se guarda como null jugador.setNacionalidad(nacionalidad == null || nacionalidad.isBlank() ? null : nacionalidad.trim()); if (nota != null) { jugador.cambiarNota(nota); @@ -65,6 +85,13 @@ public Jugador anadir(Long equipoId, String nombre, Posicion posicion, Integer d return jugadores.save(jugador); } + /** + * El creador cambia la nota de un jugador (HU-50); con ello puede cambiar la alineación probable. + * No hace falta {@code save}: el jugador está gestionado por JPA y el cambio se guarda al terminar + * la transacción. + * + * @throws IllegalArgumentException si el jugador no existe o la nota no está entre 0 y 10 + */ @Transactional public Jugador cambiarNota(Long jugadorId, double nota) { Jugador jugador = jugador(jugadorId); @@ -72,7 +99,12 @@ public Jugador cambiarNota(Long jugadorId, double nota) { return jugador; } - /** Devuelve el jugador borrado, para volver a la página de su equipo. */ + /** + * Quita un jugador de la plantilla (HU-50), venga de la API o se añadiera a mano. + * Devuelve el jugador borrado, para volver a la página de su equipo. + * + * @throws IllegalArgumentException si el jugador no existe + */ @Transactional public Jugador borrar(Long jugadorId) { Jugador jugador = jugador(jugadorId); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Posicion.java b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Posicion.java index ebbe129..89d1ff8 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Posicion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/equipos/Posicion.java @@ -2,7 +2,12 @@ import java.util.Locale; -/** Demarcación de un jugador de fútbol, de la portería a la delantera. */ +/** + * Demarcación de un jugador de fútbol, de la portería a la delantera. + * El orden de los valores es el de los grupos de la plantilla (HU-49). Cada valor lleva su nombre, + * el plural para el título del grupo y la abreviatura que lleva en la camiseta de la alineación si no + * tiene dorsal. + */ public enum Posicion { PORTERO("Portero", "Porteros", "POR"), @@ -29,6 +34,7 @@ public static Posicion desdeApi(String posicion) { return null; } String texto = posicion.toLowerCase(Locale.ROOT); + // Se busca por trozos de palabra, así valen tanto la posición general como la detallada if (texto.contains("goalkeeper")) { return PORTERO; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Balance.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Balance.java index 25d9d1e..fefa4f7 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Balance.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Balance.java @@ -2,9 +2,14 @@ import java.util.Collection; -/** Victorias, empates y derrotas de un equipo en una serie de partidos (HU-31, HU-33). */ +/** + * Victorias, empates y derrotas de un equipo en una serie de partidos (HU-31, HU-33). + * Objeto de valor inmutable: lo calculan {@link FichaEquipoService} para la ficha, la clasificación + * y el cara a cara. + */ public record Balance(int victorias, int empates, int derrotas) { + /** Cuenta cuántos resultados de cada tipo hay en la colección. */ public static Balance de(Collection resultados) { int v = 0; int e = 0; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CaraACara.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CaraACara.java index ab40867..de31f67 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CaraACara.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CaraACara.java @@ -4,11 +4,19 @@ /** * Comparación de los dos rivales de un evento (HU-33): enfrentamientos anteriores entre ellos - * y la racha reciente de cada uno. + * y la racha reciente de cada uno. La construye {@link FichaEquipoService#caraACara(Evento)} y se + * muestra en la página de apostar de un evento. + * + * @param evento el partido que se va a jugar + * @param anteriores últimos partidos finalizados entre los dos, del más reciente al más antiguo + * @param balanceLocal balance de todos esos enfrentamientos visto desde el equipo local del evento + * @param rachaLocal últimos resultados del local, del más antiguo al más reciente + * @param rachaVisitante últimos resultados del visitante, del más antiguo al más reciente */ public record CaraACara(Evento evento, List anteriores, Balance balanceLocal, List rachaLocal, List rachaVisitante) { + /** True si hay algo que comparar: enfrentamientos previos o resultados recientes de alguno de los dos. */ public boolean hayDatos() { return !anteriores.isEmpty() || !rachaLocal.isEmpty() || !rachaVisitante.isEmpty(); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CatalogoService.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CatalogoService.java index a86f1cc..8acbb33 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CatalogoService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/CatalogoService.java @@ -14,6 +14,14 @@ import es.ucm.fdi.is1.apuestas.equipos.Deporte; import es.ucm.fdi.is1.apuestas.equipos.Nombres; +/** + * Servicio del catálogo público de eventos (HU-19, HU-22): qué partidos se pueden apostar, + * agrupados y filtrados, y los de la portada. Lo usan {@code EventosController}, {@code InicioController} + * y {@code ApuestaController}. + * + *

    Un evento está disponible si está {@link EstadoEvento#PROGRAMADO} y aún no ha empezado. + * La hora actual sale del {@link Clock} inyectado, para poder fijarla en los tests. + */ @Service public class CatalogoService { @@ -37,6 +45,10 @@ public Map>> eventosDisponiblesPorDeporte() { /** * Búsqueda y filtros del catálogo (HU-22): por deporte y por texto en el nombre de los equipos * o de la competición. Sin distinguir mayúsculas ni tildes. + * + * @param deporte null para no filtrar por deporte + * @param texto null o vacío para no filtrar por texto + * @return deporte → nombre de la competición → eventos, ordenados por fecha */ @Transactional(readOnly = true) public Map>> buscar(Deporte deporte, String texto) { @@ -44,6 +56,9 @@ public Map>> buscar(Deporte deporte, String te return disponibles().stream() .filter(e -> deporte == null || e.getDeporte() == deporte) .filter(e -> buscado.isEmpty() || coincide(e, buscado)) + // EnumMap: los deportes salen en el orden del enum, no en uno aleatorio. + // LinkedHashMap: las competiciones salen en el orden en que aparece su primer evento, + // y como la lista viene ordenada por fecha, cada grupo queda ordenado por fecha. .collect(Collectors.groupingBy(Evento::getDeporte, () -> new EnumMap<>(Deporte.class), Collectors.groupingBy(e -> e.getCompeticion().getNombre(), @@ -60,17 +75,22 @@ public Map recuentoPorDeporte() { Collectors.counting())); } + /** Eventos programados que aún no han empezado, del más próximo al más lejano (CA-19.2). */ private List disponibles() { return eventos.findByEstadoAndFechaHoraAfterOrderByFechaHoraAsc(EstadoEvento.PROGRAMADO, LocalDateTime.now(reloj)); } + /** El texto (ya normalizado) aparece en el nombre del local, del visitante o de la competición. */ private static boolean coincide(Evento evento, String buscado) { return Nombres.normalizar(evento.getLocal().getNombre()).contains(buscado) || Nombres.normalizar(evento.getVisitante().getNombre()).contains(buscado) || Nombres.normalizar(evento.getCompeticion().getNombre()).contains(buscado); } - /** Partido destacado de la portada: el próximo de fútbol, o el próximo de cualquier deporte. */ + /** + * Partido destacado de la portada: el próximo de fútbol, o el próximo de cualquier deporte. + * Null si no hay ningún evento disponible. + */ @Transactional(readOnly = true) public Evento destacado() { List disponibles = disponibles(); @@ -78,7 +98,7 @@ public Evento destacado() { .orElse(disponibles.isEmpty() ? null : disponibles.get(0)); } - /** Los próximos partidos disponibles, para la portada. */ + /** Los próximos partidos disponibles, para la portada. Como mucho {@code cuantos}. */ @Transactional(readOnly = true) public List proximos(int cuantos) { return eventos.findByEstadoAndFechaHoraAfterOrderByFechaHoraAsc(EstadoEvento.PROGRAMADO, LocalDateTime.now(reloj)) @@ -87,6 +107,12 @@ public List proximos(int cuantos) { .toList(); } + /** + * Evento en el que se puede apostar ahora mismo (página de apostar). + * + * @throws EventoNoDisponibleException si no existe o ya no admite apuestas + * (suspendido, finalizado, anulado o ya empezado); se traduce en un 404 + */ @Transactional(readOnly = true) public Evento eventoDisponible(Long id) { return eventos.findById(id) diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EquipoNoEncontradoException.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EquipoNoEncontradoException.java index 639ab26..0afb7a0 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EquipoNoEncontradoException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EquipoNoEncontradoException.java @@ -3,6 +3,10 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * No existe ningún equipo con ese identificador. La lanzan {@link FichaEquipoService} y la gestión de + * equipos; gracias a {@code @ResponseStatus}, Spring responde con un 404. + */ @ResponseStatus(HttpStatus.NOT_FOUND) public class EquipoNoEncontradoException extends RuntimeException { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EstadoEvento.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EstadoEvento.java index f4f5ca2..de6634f 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EstadoEvento.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EstadoEvento.java @@ -1,8 +1,22 @@ package es.ucm.fdi.is1.apuestas.eventos; +/** + * Estado de un {@link Evento} en su ciclo de vida (HU-04, HU-05). Las transiciones las controla el propio + * evento: + * + *

    + * PROGRAMADO ──suspender──▶ SUSPENDIDO ──reactivar──▶ PROGRAMADO
    + * PROGRAMADO / SUSPENDIDO ──finalizar──▶ FINALIZADO (se puede volver a finalizar para corregir)
    + * PROGRAMADO / SUSPENDIDO ──anular──▶ ANULADO (definitivo)
    + * 
    + */ public enum EstadoEvento { + /** Estado inicial. Admite apuestas mientras no haya empezado. */ PROGRAMADO, + /** Aplazado o en revisión: no admite apuestas nuevas hasta que se reactive (HU-05). */ SUSPENDIDO, + /** Ya tiene resultado y sus apuestas se han resuelto (HU-04). */ FINALIZADO, + /** Cancelado: se devuelve lo apostado y ya no puede tener resultado (HU-05). */ ANULADO } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Evento.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Evento.java index cd70857..2a3f5e3 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Evento.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/Evento.java @@ -15,7 +15,17 @@ import jakarta.persistence.ManyToOne; import jakarta.validation.constraints.NotNull; -/** Partido entre dos equipos de una competición, sobre el que se apuesta (G/E/P). */ +/** + * Partido entre dos equipos de una competición, sobre el que se apuesta (G/E/P). + * + *

    Entidad JPA y pieza central del dominio: las apuestas, las cuotas y las estadísticas de equipos + * se calculan a partir de ella. Lo crea el creador de apuestas a mano o la sincronización con la API + * (HU-21). + * + *

    Guarda su {@link EstadoEvento} y solo permite las transiciones válidas; los métodos que cambian + * el estado lanzan {@link IllegalStateException} si no se puede. El efecto sobre las apuestas + * (resolverlas, devolver el dinero) no se hace aquí, sino en {@code ResolucionService}. + */ @Entity public class Evento { @@ -65,6 +75,7 @@ protected Evento() { // requerido por JPA } + /** Evento nuevo, en estado programado y sin resultado. */ public Evento(Competicion competicion, Equipo local, Equipo visitante, LocalDateTime fechaHora) { this.competicion = competicion; this.local = local; @@ -77,7 +88,14 @@ public boolean admiteApuestas(LocalDateTime ahora) { return estado == EstadoEvento.PROGRAMADO && fechaHora.isAfter(ahora); } - /** Fija (o corrige) el resultado final y marca el evento como finalizado (HU-04). */ + /** + * Fija (o corrige) el resultado final y marca el evento como finalizado (HU-04). + * Vale desde programado, suspendido o finalizado (para corregir). Borra el marcador: con un resultado + * sin goles, las apuestas de goles se anulan. + * + * @throws IllegalStateException si el evento está anulado + * @throws IllegalArgumentException si es empate en un deporte que no lo admite + */ public void finalizar(Resultado resultadoFinal) { if (estado == EstadoEvento.ANULADO) { throw new IllegalStateException("Un evento anulado no puede tener resultado"); @@ -91,20 +109,32 @@ public void finalizar(Resultado resultadoFinal) { estado = EstadoEvento.FINALIZADO; } - /** Fija (o corrige) el marcador final; el resultado sale de él (HU-52). Solo en fútbol. */ + /** + * Fija (o corrige) el marcador final; el resultado sale de él (HU-52). Solo en fútbol. + * + * @throws IllegalArgumentException si algún marcador es negativo o el deporte no admite empate + * @throws IllegalStateException si el evento está anulado + */ public void finalizarConMarcador(int local, int visitante) { if (local < 0 || visitante < 0) { throw new IllegalArgumentException("Los goles no pueden ser negativos"); } + // "Admite empate" equivale aquí a "es fútbol": en el resto no hay goles que apuntar if (!getDeporte().isAdmiteEmpate()) { throw new IllegalArgumentException("En " + getDeporte().getNombre() + " solo se indica el ganador"); } + // finalizar() borra los goles, así que se guardan después finalizar(local > visitante ? Resultado.LOCAL : local < visitante ? Resultado.VISITANTE : Resultado.EMPATE); golesLocal = local; golesVisitante = visitante; } - /** Deja de admitir apuestas nuevas hasta que se reactive (HU-05). */ + /** + * Deja de admitir apuestas nuevas hasta que se reactive (HU-05). Las que ya hay siguen activas. + * Suspendido por el creador: la API no lo reactivará sola. + * + * @throws IllegalStateException si no está programado + */ public void suspender() { if (estado != EstadoEvento.PROGRAMADO) { throw new IllegalStateException("Solo se puede suspender un evento programado"); @@ -123,6 +153,11 @@ public boolean isSuspendidoPorApi() { return suspendidoPorApi; } + /** + * Vuelve a programado y admite apuestas otra vez, si no ha empezado (HU-05). + * + * @throws IllegalStateException si no está suspendido + */ public void reactivar() { if (estado != EstadoEvento.SUSPENDIDO) { throw new IllegalStateException("Solo se puede reactivar un evento suspendido"); @@ -131,7 +166,11 @@ public void reactivar() { suspendidoPorApi = false; } - /** Anula el evento; las apuestas se devuelven desde el servicio (HU-05). */ + /** + * Anula el evento; las apuestas se devuelven desde el servicio (HU-05). Es definitivo. + * + * @throws IllegalStateException si ya está finalizado o anulado + */ public void anular() { if (estado == EstadoEvento.FINALIZADO || estado == EstadoEvento.ANULADO) { throw new IllegalStateException("No se puede anular un evento finalizado o ya anulado"); @@ -139,6 +178,7 @@ public void anular() { estado = EstadoEvento.ANULADO; } + /** True a partir de la hora de inicio, sea cual sea el estado. */ public boolean haEmpezado(LocalDateTime ahora) { return !fechaHora.isAfter(ahora); } @@ -155,10 +195,12 @@ public Integer getGolesVisitante() { return golesVisitante; } + /** True si se finalizó con marcador (HU-52) y no solo con el ganador. */ public boolean isConMarcador() { return golesLocal != null && golesVisitante != null; } + /** El evento no guarda el deporte: es el de su competición. */ public Deporte getDeporte() { return competicion.getDeporte(); } @@ -190,6 +232,8 @@ public EstadoEvento getEstado() { /** * El creador corrige un evento mal dado de alta (HU-46). Solo antes de que empiece y si no está * finalizado ni anulado; las comprobaciones de equipos y apuestas las hace el servicio. + * + * @throws IllegalStateException si el evento no es {@link #editable(LocalDateTime) editable} */ public void modificar(Equipo nuevoLocal, Equipo nuevoVisitante, LocalDateTime nuevaFecha, String nuevaFase, LocalDateTime ahora) { @@ -207,7 +251,10 @@ public boolean editable(LocalDateTime ahora) { return (estado == EstadoEvento.PROGRAMADO || estado == EstadoEvento.SUSPENDIDO) && fechaHora.isAfter(ahora); } - /** La fuente de datos ha cambiado la fecha del partido (solo si aún no se ha jugado). */ + /** + * La fuente de datos ha cambiado la fecha del partido (solo si aún no se ha jugado). + * En un evento finalizado o anulado no hace nada, sin lanzar excepción. + */ public void cambiarFecha(LocalDateTime nuevaFecha) { if (estado == EstadoEvento.PROGRAMADO || estado == EstadoEvento.SUSPENDIDO) { fechaHora = nuevaFecha; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoNoDisponibleException.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoNoDisponibleException.java index 8cebe4c..6e5b68d 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoNoDisponibleException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoNoDisponibleException.java @@ -3,6 +3,11 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; +/** + * El evento pedido no existe o ya no admite apuestas (suspendido, empezado, finalizado o anulado). + * La lanzan {@link CatalogoService} y los servicios de apuestas y de gestión; si nadie la captura, + * Spring responde con un 404 gracias a {@code @ResponseStatus}. + */ @ResponseStatus(HttpStatus.NOT_FOUND) public class EventoNoDisponibleException extends RuntimeException { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoRepository.java index 082fb80..5578a86 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventoRepository.java @@ -10,8 +10,13 @@ import es.ucm.fdi.is1.apuestas.equipos.Competicion; import es.ucm.fdi.is1.apuestas.equipos.Equipo; +/** + * Repositorio Spring Data de {@link Evento}. Spring genera la implementación: las consultas salen del + * nombre de cada método o de su {@code @Query} (JPQL). + */ public interface EventoRepository extends JpaRepository { + /** Eventos en un estado que empiezan después de una fecha, del más próximo al más lejano (catálogo, HU-19). */ List findByEstadoAndFechaHoraAfterOrderByFechaHoraAsc(EstadoEvento estado, LocalDateTime fecha); List findAllByOrderByFechaHoraAsc(); @@ -39,5 +44,6 @@ List findByLocalAndVisitanteAndFechaHoraBetween(Equipo local, Equipo vis order by e.fechaHora desc""") List enfrentamientos(Equipo uno, Equipo otro); + /** Eventos de una competición en un estado; con FINALIZADO sirve para calcular la clasificación (HU-31). */ List findByCompeticionAndEstado(Competicion competicion, EstadoEvento estado); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventosController.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventosController.java index 6889e1d..7088f68 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventosController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/EventosController.java @@ -7,6 +7,10 @@ import es.ucm.fdi.is1.apuestas.equipos.Deporte; +/** + * Controlador MVC del catálogo público de eventos (HU-19, HU-22). Delega en {@link CatalogoService} + * y pinta la plantilla Thymeleaf {@code eventos.html}. + */ @Controller public class EventosController { @@ -16,10 +20,18 @@ public EventosController(CatalogoService catalogo) { this.catalogo = catalogo; } + /** + * {@code GET /eventos}: eventos disponibles agrupados por deporte y competición, con pestañas por + * deporte y buscador. + * + * @param deporte pestaña elegida ({@code ?deporte=FUTBOL}); sin él se muestran todos + * @param q texto buscado en equipos y competición ({@code ?q=madrid}); opcional + */ @GetMapping("/eventos") public String catalogo(@RequestParam(required = false) Deporte deporte, @RequestParam(required = false) String q, Model model) { model.addAttribute("eventosPorDeporte", catalogo.buscar(deporte, q)); + // El recuento de las pestañas no depende de los filtros: siempre se ven todos los deportes model.addAttribute("recuento", catalogo.recuentoPorDeporte()); model.addAttribute("deporteElegido", deporte); model.addAttribute("q", q == null ? "" : q.trim()); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipo.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipo.java index 9f819bf..dd7a7c8 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipo.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipo.java @@ -12,7 +12,18 @@ /** * Ficha de un equipo o deportista (HU-31): últimos resultados, balance, racha, posición en cada competición * según los resultados registrados en la web, próximos partidos, gráfico de evolución (HU-32), - * plantilla y alineación probable (null si no hay jugadores suficientes). + * plantilla y alineación probable (HU-49; null si no hay jugadores suficientes). + * + *

    Objeto de solo lectura que monta {@link FichaEquipoService#ficha(Long)} y se pasa entero a la + * plantilla {@code equipo.html}. + * + * @param ultimos últimos partidos finalizados, del más reciente al más antiguo + * @param balance balance de todos los partidos finalizados del equipo + * @param racha últimos resultados, del más antiguo al más reciente + * @param clasificaciones puesto en cada competición en la que ya ha jugado, por nombre de competición + * @param proximos próximos partidos en los que aún se puede apostar + * @param evolucion null si ha jugado menos de dos partidos + * @param plantilla jugadores agrupados por posición; vacía si no hay jugadores */ public record FichaEquipo(Equipo equipo, List ultimos, Balance balance, List racha, List clasificaciones, List proximos, GraficoEvolucion evolucion, diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipoService.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipoService.java index b33d3ac..73ed625 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipoService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/FichaEquipoService.java @@ -17,12 +17,21 @@ import es.ucm.fdi.is1.apuestas.equipos.EquipoRepository; import es.ucm.fdi.is1.apuestas.equipos.PlantillaService; -/** Banco de estadísticas de equipos: ficha (HU-31) y cara a cara (HU-33). */ +/** + * Banco de estadísticas de equipos: ficha (HU-31, HU-32, HU-49) y cara a cara (HU-33). + * + *

    Servicio de solo lectura. Todas las estadísticas se calculan con los eventos finalizados guardados en + * la base de datos; la plantilla y la alineación las pide a {@link PlantillaService}. Lo usan + * {@code InicioController} (ficha) y {@code ApuestaController} (cara a cara). + */ @Service public class FichaEquipoService { + /** Partidos jugados que se listan en la ficha y en el cara a cara. */ static final int ULTIMOS = 10; + /** Resultados que forman la racha reciente (CA-31.1). */ static final int RACHA = 5; + /** Próximos partidos que se muestran en la ficha. */ static final int PROXIMOS = 5; private final EquipoRepository equipos; @@ -38,34 +47,50 @@ public FichaEquipoService(EquipoRepository equipos, EventoRepository eventos, Pl this.reloj = reloj; } + /** + * Ficha completa de un equipo o deportista (HU-31). + * + * @throws EquipoNoEncontradoException si no existe (404) + */ @Transactional(readOnly = true) public FichaEquipo ficha(Long equipoId) { Equipo equipo = equipos.findById(equipoId).orElseThrow(() -> new EquipoNoEncontradoException(equipoId)); + // Del más reciente al más antiguo List jugados = eventos.partidosDe(equipo, EstadoEvento.FINALIZADO); List ultimos = jugados.stream().limit(ULTIMOS) .map(e -> { + // Se compara por id porque Equipo no redefine equals() boolean enCasa = e.getLocal().getId().equals(equipo.getId()); return new FichaEquipo.PartidoJugado(e, enCasa ? e.getVisitante() : e.getLocal(), enCasa, ResultadoEquipo.de(e, equipo)); }) .toList(); + // El balance cuenta todos los partidos jugados, no solo los últimos Balance balance = Balance.de(jugados.stream().map(e -> ResultadoEquipo.de(e, equipo)).toList()); LocalDateTime ahora = LocalDateTime.now(reloj); + // Próximos: programados y sin empezar, del más cercano al más lejano List proximos = eventos.partidosDe(equipo, EstadoEvento.PROGRAMADO).stream() .filter(e -> e.admiteApuestas(ahora)) .sorted(Comparator.comparing(Evento::getFechaHora)) .limit(PROXIMOS) .toList(); + // Un puesto por competición; se omiten aquellas en las que aún no ha jugado List clasificaciones = equipo.getCompeticiones().stream() .sorted(Comparator.comparing(Competicion::getNombre)) .map(c -> puesto(c, equipo)) .filter(p -> p != null) .toList(); + // El gráfico necesita los partidos en orden cronológico: se invierte la lista return new FichaEquipo(equipo, ultimos, balance, racha(jugados, equipo), clasificaciones, proximos, GraficoEvolucion.de(jugados.reversed(), equipo), plantillas.plantilla(equipo), plantillas.alineacion(equipo)); } + /** + * Cara a cara de los dos rivales de un evento (HU-33): sus últimos enfrentamientos, el balance de + * todos ellos visto desde el local y la racha de cada uno. Las listas pueden venir vacías si no hay + * partidos previos (CA-33.2). + */ @Transactional(readOnly = true) public CaraACara caraACara(Evento evento) { Equipo local = evento.getLocal(); @@ -79,6 +104,7 @@ public CaraACara caraACara(Evento evento) { /** Últimos resultados, del más antiguo al más reciente (el último de la lista es el más reciente). */ private static List racha(List jugados, Equipo equipo) { + // jugados viene del más reciente al más antiguo: se cogen los RACHA primeros y se invierten List recientes = jugados.stream().limit(RACHA).map(e -> ResultadoEquipo.de(e, equipo)).toList(); return recientes.reversed(); } @@ -88,6 +114,7 @@ private static List racha(List jugados, Equipo equipo) * a igualdad de puntos, más victorias. Null si el equipo aún no ha jugado en ella. */ private FichaEquipo.Puesto puesto(Competicion competicion, Equipo equipo) { + // Resultados de cada participante, por id de equipo Map> porEquipo = new HashMap<>(); Map nombres = new HashMap<>(); for (Evento evento : eventos.findByCompeticionAndEstado(competicion, EstadoEvento.FINALIZADO)) { @@ -100,6 +127,7 @@ private FichaEquipo.Puesto puesto(Competicion competicion, Equipo equipo) { if (!porEquipo.containsKey(equipo.getId())) { return null; } + // Orden de la tabla: más puntos, después más victorias y, por último, nombre (orden estable) List> tabla = porEquipo.entrySet().stream() .map(e -> Map.entry(e.getKey(), Balance.de(e.getValue()))) .sorted(Comparator.>comparingInt(e -> e.getValue().puntos()).reversed() @@ -107,6 +135,7 @@ private FichaEquipo.Puesto puesto(Competicion competicion, Equipo equipo) { .reversed()) .thenComparing(e -> nombres.get(e.getKey()))) .toList(); + // La posición es el índice en la tabla más uno for (int i = 0; i < tabla.size(); i++) { if (tabla.get(i).getKey().equals(equipo.getId())) { return new FichaEquipo.Puesto(competicion, i + 1, tabla.size(), tabla.get(i).getValue()); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/GraficoEvolucion.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/GraficoEvolucion.java index 9c77aed..7325e28 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/GraficoEvolucion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/GraficoEvolucion.java @@ -10,12 +10,25 @@ /** * Evolución de un equipo a lo largo de la temporada (HU-32): puntos acumulados partido a partido * (3 por victoria y 1 por empate). Las coordenadas se calculan aquí para dibujar un SVG sin JavaScript. + * + *

    Forma parte de {@link FichaEquipo}; la plantilla {@code equipo.html} solo pinta los datos. + * + * @param ancho ancho del SVG en píxeles + * @param alto alto del SVG en píxeles + * @param linea atributo {@code d} del {@code } que une los puntos ("M x,y L x,y …") + * @param puntos un círculo por partido, con su descripción como texto emergente + * @param ejeY etiquetas de puntos del eje vertical + * @param ejeX etiquetas de fecha del eje horizontal + * @param base coordenada y de la línea de 0 puntos (parte de abajo del área del gráfico) + * @param izquierda coordenada x donde empiezan las líneas horizontales de la rejilla + * @param derecha coordenada x donde terminan las líneas horizontales de la rejilla */ public record GraficoEvolucion(int ancho, int alto, String linea, List puntos, List ejeY, List ejeX, double base, double izquierda, double derecha) { static final int ANCHO = 640; static final int ALTO = 220; + // Márgenes que dejan sitio a las etiquetas de los ejes static final double MARGEN_IZQ = 36; static final double MARGEN_DER = 16; static final double MARGEN_ARRIBA = 16; @@ -34,6 +47,7 @@ static GraficoEvolucion de(List jugados, Equipo equipo) { if (jugados.size() < 2) { return null; } + // Puntos acumulados después de cada partido List acumulados = new ArrayList<>(); int total = 0; for (Evento evento : jugados) { @@ -44,29 +58,37 @@ static GraficoEvolucion de(List jugados, Equipo equipo) { }; acumulados.add(total); } + // Tope del eje Y: el total redondeado hacia arriba a un múltiplo de 3 (como mínimo 3), + // para que las marcas del eje (cada tercio) sean números enteros int maximo = Math.max(3, (int) Math.ceil(total / 3.0) * 3); double altoUtil = ALTO - MARGEN_ARRIBA - MARGEN_ABAJO; double anchoUtil = ANCHO - MARGEN_IZQ - MARGEN_DER; + // Separación horizontal entre partidos: el primero queda en el borde izquierdo y el último en el derecho double paso = anchoUtil / (jugados.size() - 1); List puntos = new ArrayList<>(); List ejeX = new ArrayList<>(); StringBuilder linea = new StringBuilder(); + // Como mucho unas 8 fechas en el eje X, para que no se solapen int cadaCuantos = Math.max(1, (int) Math.ceil(jugados.size() / 8.0)); for (int i = 0; i < jugados.size(); i++) { Evento evento = jugados.get(i); double x = MARGEN_IZQ + i * paso; + // En SVG la y crece hacia abajo: 0 puntos va abajo y el máximo arriba double y = MARGEN_ARRIBA + altoUtil * (1 - acumulados.get(i) / (double) maximo); linea.append(i == 0 ? "M" : " L").append(redondear(x)).append(',').append(redondear(y)); ResultadoEquipo resultado = ResultadoEquipo.de(evento, equipo); Equipo rival = evento.getLocal().getId().equals(equipo.getId()) ? evento.getVisitante() : evento.getLocal(); + // Texto emergente del punto, y la clase CSS (victoria, empate o derrota) para el color puntos.add(new Marca(x, y, FECHA.format(evento.getFechaHora()) + " · " + resultado.getDescripcion() + " contra " + rival.getNombre() + " · " + acumulados.get(i) + " puntos", resultado.name().toLowerCase(Locale.ROOT))); + // La fecha del último partido se muestra siempre if (i % cadaCuantos == 0 || i == jugados.size() - 1) { ejeX.add(new Marca(x, ALTO - 8, FECHA.format(evento.getFechaHora()), "")); } } + // Marcas del eje Y: 0, un tercio, dos tercios y el máximo List ejeY = new ArrayList<>(); for (int valor = 0; valor <= maximo; valor += Math.max(1, maximo / 3)) { ejeY.add(new Marca(MARGEN_IZQ - 8, MARGEN_ARRIBA + altoUtil * (1 - valor / (double) maximo), @@ -76,6 +98,7 @@ static GraficoEvolucion de(List jugados, Equipo equipo) { ALTO - MARGEN_ABAJO, MARGEN_IZQ, ANCHO - MARGEN_DER); } + /** Un decimal y punto como separador, sea cual sea el idioma del servidor (el SVG lo exige). */ private static String redondear(double valor) { return String.format(Locale.ROOT, "%.1f", valor); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/ResultadoEquipo.java b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/ResultadoEquipo.java index 358548d..e7c8e43 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/eventos/ResultadoEquipo.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/eventos/ResultadoEquipo.java @@ -3,7 +3,12 @@ import es.ucm.fdi.is1.apuestas.cuotas.Resultado; import es.ucm.fdi.is1.apuestas.equipos.Equipo; -/** Resultado de un partido visto desde uno de los dos equipos (HU-31). */ +/** + * Resultado de un partido visto desde uno de los dos equipos (HU-31). + * A diferencia de {@link Resultado} (local, empate, visitante), aquí importa quién mira: + * un {@code LOCAL} es victoria para el local y derrota para el visitante. + * Cada valor lleva la letra de la racha (V/E/D) y su descripción. + */ public enum ResultadoEquipo { VICTORIA("V", "Victoria"), @@ -23,6 +28,7 @@ public static ResultadoEquipo de(Evento evento, Equipo equipo) { if (evento.getResultado() == Resultado.EMPATE) { return EMPATE; } + // Gana si es local y ganó el local, o si es visitante y ganó el visitante boolean ganaLocal = evento.getResultado() == Resultado.LOCAL; boolean esLocal = evento.getLocal().getId().equals(equipo.getId()); return ganaLocal == esLocal ? VICTORIA : DERROTA; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CasaController.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CasaController.java index 075c7e4..5159786 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CasaController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CasaController.java @@ -6,7 +6,10 @@ import es.ucm.fdi.is1.apuestas.apuesta.CuentasCasaService; -/** Panel de la casa para el creador de apuestas (HU-54). */ +/** + * Controlador del panel de la casa para el creador de apuestas (HU-54). Los cálculos (total apostado, + * pagado, beneficio y margen real frente al teórico) los hace {@link CuentasCasaService}. + */ @Controller public class CasaController { @@ -16,6 +19,10 @@ public CasaController(CuentasCasaService cuentas) { this.cuentas = cuentas; } + /** + * GET /gestion/casa (solo CREADOR): cuentas de la casa, en total y desglosadas por tipo de apuesta + * y por deporte (CA-54.1, CA-54.2). + */ @GetMapping("/gestion/casa") public String casa(Model model) { model.addAttribute("cuentas", cuentas.cuentas()); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CompeticionForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CompeticionForm.java index 6994d16..dcd5040 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CompeticionForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/CompeticionForm.java @@ -5,6 +5,10 @@ import jakarta.validation.constraints.Size; import jakarta.validation.constraints.NotNull; +/** + * Formulario de alta de una competición (HU-01). Que no se repita el nombre lo comprueba + * {@link GestionService#crearCompeticion}, porque necesita consultar la base de datos. + */ public class CompeticionForm { @NotBlank(message = "Introduce el nombre") diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/DatosInvalidosException.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/DatosInvalidosException.java index 907b19b..62e8b27 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/DatosInvalidosException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/DatosInvalidosException.java @@ -1,6 +1,10 @@ package es.ucm.fdi.is1.apuestas.gestion; -/** Regla de negocio incumplida en un formulario; indica el campo afectado. */ +/** + * Regla de negocio incumplida en un formulario; indica el campo afectado. + * La lanza {@link GestionService} y el controlador la convierte en un error de ese campo, para mostrarlo + * junto a él en lugar de en una página de error. + */ public class DatosInvalidosException extends RuntimeException { private final String campo; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEquipoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEquipoForm.java index 999c7f8..2a6373c 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEquipoForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEquipoForm.java @@ -7,7 +7,10 @@ import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; -/** Datos editables de un equipo ya existente: calificación (HU-01), forma reciente (HU-02) y escudo. */ +/** + * Formulario con los datos editables de un equipo ya existente: calificación (HU-01), forma reciente (HU-02) + * y escudo. El nombre y el deporte no se editan. + */ public class EdicionEquipoForm { @NotNull(message = "Introduce la calificación") @@ -18,7 +21,10 @@ public class EdicionEquipoForm { @NotNull(message = "Elige la forma reciente") private Forma forma = Forma.NORMAL; - /** Vacío para quitar el escudo y volver a mostrar las iniciales. */ + /** + * Vacío para quitar el escudo y volver a mostrar las iniciales. Si no, tiene que ser una dirección + * http o https sin espacios. + */ @Size(max = 500, message = "Máximo 500 caracteres") @Pattern(regexp = "^$|^https?://\\S+$", message = "Debe ser una dirección que empiece por http:// o https://") private String escudoUrl; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEventoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEventoForm.java index 9ab6167..421686d 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEventoForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EdicionEventoForm.java @@ -8,7 +8,11 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -/** Datos editables de un evento que aún no ha empezado (HU-46). La competición no cambia. */ +/** + * Formulario con los datos editables de un evento que aún no ha empezado (HU-46). La competición no cambia. + * Las reglas que dependen del evento (que no haya empezado, que no tenga apuestas si cambian los equipos) + * las comprueba {@link GestionService#editarEvento}. + */ public class EdicionEventoForm { @NotNull(message = "Elige el equipo local") @@ -17,11 +21,13 @@ public class EdicionEventoForm { @NotNull(message = "Elige el equipo visitante") private Long visitanteId; + // La nueva fecha también tiene que ser futura; mismo formato que el campo datetime-local de HTML @NotNull(message = "Introduce la fecha y hora") @Future(message = "La fecha debe ser futura") @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") private LocalDateTime fechaHora; + /** Opcional; vacía deja el evento sin fase. */ @Size(max = 80, message = "Máximo 80 caracteres") private String fase; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EquipoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EquipoForm.java index aba68b6..c94357a 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EquipoForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EquipoForm.java @@ -10,6 +10,10 @@ import jakarta.validation.constraints.Size; import jakarta.validation.constraints.NotNull; +/** + * Formulario de alta de un equipo o deportista con su calificación (HU-01). {@link GestionService#crearEquipo} + * comprueba además que el nombre no exista y que las competiciones sean de su deporte. + */ public class EquipoForm { @NotBlank(message = "Introduce el nombre") @@ -19,11 +23,13 @@ public class EquipoForm { @NotNull(message = "Elige un deporte") private Deporte deporte; + /** Calificación de calidad de 0 a 10, con la que se calculan las cuotas (CA-01.2). */ @NotNull(message = "Introduce la calificación") @DecimalMin(value = "0.0", message = "La calificación debe estar entre 0 y 10") @DecimalMax(value = "10.0", message = "La calificación debe estar entre 0 y 10") private Double calidad; + /** Ids de las competiciones marcadas; puede quedar vacío. */ private Set competicionIds = new HashSet<>(); public String getNombre() { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EventoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EventoForm.java index 5e750be..7bc8ebb 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EventoForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/EventoForm.java @@ -8,6 +8,10 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +/** + * Formulario de alta de un evento a mano (HU-21). {@link GestionService#crearEvento} comprueba además + * que local y visitante sean distintos y que los dos jueguen la competición elegida. + */ public class EventoForm { @NotNull(message = "Elige una competición") @@ -19,11 +23,13 @@ public class EventoForm { @NotNull(message = "Elige el equipo visitante") private Long visitanteId; + // No se puede crear un evento en el pasado. El patrón es el formato del campo datetime-local de HTML @NotNull(message = "Introduce la fecha y hora") @Future(message = "La fecha debe ser futura") @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") private LocalDateTime fechaHora; + /** Opcional (jornada, semifinal...). */ @Size(max = 80, message = "Máximo 80 caracteres") private String fase; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionController.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionController.java index cf6d079..9f60b7c 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionController.java @@ -32,7 +32,16 @@ import es.ucm.fdi.is1.apuestas.mercados.MercadoService; import jakarta.validation.Valid; -/** Panel del creador de apuestas (solo rol CREADOR, ver SeguridadConfig). */ +/** + * Panel del creador de apuestas (solo rol CREADOR, ver SeguridadConfig). Todas sus rutas cuelgan de + * {@code /gestion}: competiciones, equipos y plantillas (HU-01, HU-50), eventos y su resultado + * (HU-04, HU-05, HU-21, HU-46), límites de apuesta (HU-07), juego responsable (HU-10) y sincronización + * con la API. Los mercados a largo plazo tienen su propio controlador, {@link GestionMercadosController}. + *

    + * Patrón de los formularios: si la validación falla o el servicio lanza {@link DatosInvalidosException}, + * se vuelve a mostrar el formulario con el error en su campo; si todo va bien, se redirige con un mensaje + * (atributo flash) para que recargar la página no repita la acción. + */ @Controller @RequestMapping("/gestion") public class GestionController { @@ -62,7 +71,10 @@ public GestionController(GestionService gestion, ResolucionService resolucion, this.reloj = reloj; } - /** Descarga ahora mismo partidos y resultados de la API (HU-21). */ + /** + * POST /gestion/sincronizar: descarga ahora mismo partidos y resultados de la API (HU-21). + * Si la API no está configurada, o algún partido falla, se avisa con un mensaje de error. + */ @PostMapping("/sincronizar") public String sincronizar(RedirectAttributes redireccion) { try { @@ -77,7 +89,7 @@ public String sincronizar(RedirectAttributes redireccion) { return "redirect:/gestion"; } - /** Descarga ahora mismo las plantillas reales de los equipos. */ + /** POST /gestion/sincronizar-plantillas: descarga ahora mismo las plantillas reales de los equipos (HU-50). */ @PostMapping("/sincronizar-plantillas") public String sincronizarPlantillas(RedirectAttributes redireccion) { try { @@ -94,6 +106,10 @@ public String sincronizarPlantillas(RedirectAttributes redireccion) { // --- Jugadores --- + /** + * POST /gestion/equipos/{id}/jugadores: añade a mano un jugador a la plantilla del equipo (HU-50). + * Los parámetros son opcionales para que sea {@link PlantillaService} quien explique qué falta. + */ @PostMapping("/equipos/{id}/jugadores") public String anadirJugador(@PathVariable Long id, @RequestParam(required = false) String nombre, @RequestParam(required = false) Posicion posicion, @@ -109,6 +125,7 @@ public String anadirJugador(@PathVariable Long id, @RequestParam(required = fals return "redirect:/gestion/equipos/" + id + "/editar#jugadores"; } + /** POST /gestion/jugadores/{id}/nota: cambia la nota (0–10) de un jugador y vuelve a la página de su equipo. */ @PostMapping("/jugadores/{id}/nota") public String notaJugador(@PathVariable Long id, @RequestParam double nota, RedirectAttributes redireccion) { Long equipoId; @@ -124,6 +141,7 @@ public String notaJugador(@PathVariable Long id, @RequestParam double nota, Redi return "redirect:/gestion/equipos/" + equipoId + "/editar#jugadores"; } + /** POST /gestion/jugadores/{id}/borrar: quita un jugador de la plantilla. */ @PostMapping("/jugadores/{id}/borrar") public String borrarJugador(@PathVariable Long id, RedirectAttributes redireccion) { try { @@ -136,16 +154,22 @@ public String borrarJugador(@PathVariable Long id, RedirectAttributes redireccio } } + /** Deportes para los desplegables; Spring lo añade al modelo de todas las páginas de este controlador. */ @ModelAttribute("deportes") public Deporte[] deportes() { return Deporte.values(); } + /** Formas recientes posibles (HU-02), también para los desplegables. */ @ModelAttribute("formas") public Forma[] formas() { return Forma.values(); } + /** + * GET /gestion: página principal del panel, con competiciones, equipos, eventos, mercados, límites + * y si la API está configurada (si no, la vista lo avisa y desactiva la descarga de plantillas). + */ @GetMapping public String panel(Model model) { model.addAttribute("competiciones", gestion.competiciones()); @@ -160,6 +184,7 @@ public String panel(Model model) { // --- Límites de apuesta (HU-07) --- + /** GET /gestion/limites: formulario de límites de apuesta, relleno con los valores actuales (HU-07). */ @GetMapping("/limites") public String limites(Model model) { Limites actuales = limites.actuales(); @@ -171,6 +196,10 @@ public String limites(Model model) { return "gestion/limites"; } + /** + * POST /gestion/limites: guarda los límites de apuesta (HU-07). Las anotaciones del formulario comprueban + * cada campo; {@link Limites#cambiar} comprueba además que el máximo no sea menor que el mínimo. + */ @PostMapping("/limites") public String guardarLimites(@Valid @ModelAttribute("limites") LimitesForm form, BindingResult errores, RedirectAttributes redireccion) { @@ -180,13 +209,14 @@ public String guardarLimites(@Valid @ModelAttribute("limites") LimitesForm form, redireccion.addFlashAttribute("mensaje", "Límites de apuesta actualizados"); return "redirect:/gestion"; } catch (IllegalArgumentException e) { + // Las reglas entre campos (máximo >= mínimo) se muestran junto al importe máximo errores.rejectValue("importeMaximo", "invalido", e.getMessage()); } } return "gestion/limites"; } - /** Activa o desactiva el juego responsable en toda la web (HU-10). */ + /** POST /gestion/juego-responsable: activa o desactiva el juego responsable en toda la web (HU-10). */ @PostMapping("/juego-responsable") public String juegoResponsable(@RequestParam boolean activo, RedirectAttributes redireccion) { limites.activarJuegoResponsable(activo); @@ -198,12 +228,14 @@ public String juegoResponsable(@RequestParam boolean activo, RedirectAttributes // --- Competiciones --- + /** GET /gestion/competiciones/nueva: formulario de alta de una competición (HU-01). */ @GetMapping("/competiciones/nueva") public String nuevaCompeticion(Model model) { model.addAttribute("competicion", new CompeticionForm()); return "gestion/competicion"; } + /** POST /gestion/competiciones/nueva: crea la competición o vuelve al formulario con el error (HU-01). */ @PostMapping("/competiciones/nueva") public String crearCompeticion(@Valid @ModelAttribute("competicion") CompeticionForm form, BindingResult errores, RedirectAttributes redireccion) { @@ -222,6 +254,7 @@ public String crearCompeticion(@Valid @ModelAttribute("competicion") Competicion // --- Equipos --- + /** GET /gestion/equipos/nuevo: formulario de alta de un equipo o deportista (HU-01). */ @GetMapping("/equipos/nuevo") public String nuevoEquipo(Model model) { model.addAttribute("equipo", new EquipoForm()); @@ -229,6 +262,7 @@ public String nuevoEquipo(Model model) { return "gestion/equipo"; } + /** POST /gestion/equipos/nuevo: crea el equipo o vuelve al formulario con el error (HU-01). */ @PostMapping("/equipos/nuevo") public String crearEquipo(@Valid @ModelAttribute("equipo") EquipoForm form, BindingResult errores, Model model, RedirectAttributes redireccion) { @@ -245,6 +279,10 @@ public String crearEquipo(@Valid @ModelAttribute("equipo") EquipoForm form, Bind return "gestion/equipo"; } + /** + * GET /gestion/equipos/{id}/editar: formulario para cambiar calificación, forma y escudo, junto con + * la plantilla del equipo (HU-50). + */ @GetMapping("/equipos/{id}/editar") public String editarEquipo(@PathVariable Long id, Model model) { Equipo equipo = gestion.equipo(id); @@ -256,9 +294,11 @@ public String editarEquipo(@PathVariable Long id, Model model) { return formularioEquipo(equipo, model); } + /** POST /gestion/equipos/{id}/editar: guarda calificación, forma y escudo del equipo. */ @PostMapping("/equipos/{id}/editar") public String guardarEquipo(@PathVariable Long id, @Valid @ModelAttribute("edicion") EdicionEquipoForm form, BindingResult errores, Model model, RedirectAttributes redireccion) { + // Se carga antes de validar porque la página de edición lo necesita si hay errores Equipo equipo = gestion.equipo(id); if (errores.hasErrors()) { return formularioEquipo(equipo, model); @@ -268,6 +308,7 @@ public String guardarEquipo(@PathVariable Long id, @Valid @ModelAttribute("edici return "redirect:/gestion"; } + /** Prepara el modelo de la página de edición de un equipo (datos, plantilla y posiciones). */ private String formularioEquipo(Equipo equipo, Model model) { model.addAttribute("equipo", equipo); model.addAttribute("plantilla", plantillas.plantilla(equipo)); @@ -277,12 +318,14 @@ private String formularioEquipo(Equipo equipo, Model model) { // --- Eventos --- + /** GET /gestion/eventos/nuevo: formulario para crear un evento a mano (HU-21). */ @GetMapping("/eventos/nuevo") public String nuevoEvento(Model model) { model.addAttribute("evento", new EventoForm()); return formularioEvento(model); } + /** POST /gestion/eventos/nuevo: crea el evento o vuelve al formulario con el error (HU-21). */ @PostMapping("/eventos/nuevo") public String crearEvento(@Valid @ModelAttribute("evento") EventoForm form, BindingResult errores, Model model, RedirectAttributes redireccion) { @@ -298,6 +341,7 @@ public String crearEvento(@Valid @ModelAttribute("evento") EventoForm form, Bind return formularioEvento(model); } + /** GET /gestion/eventos/{id}/editar: formulario de edición de un evento, relleno con sus datos (HU-46). */ @GetMapping("/eventos/{id}/editar") public String editarEvento(@PathVariable Long id, Model model) { Evento evento = gestion.evento(id); @@ -310,6 +354,10 @@ public String editarEvento(@PathVariable Long id, Model model) { return formularioEdicionEvento(evento, model); } + /** + * POST /gestion/eventos/{id}/editar: guarda los cambios de fecha, equipos o fase (HU-46). + * Las reglas (evento sin empezar, equipos fijos si ya hay apuestas) están en {@link GestionService#editarEvento}. + */ @PostMapping("/eventos/{id}/editar") public String guardarEvento(@PathVariable Long id, @Valid @ModelAttribute("edicion") EdicionEventoForm form, BindingResult errores, Model model, RedirectAttributes redireccion) { @@ -322,9 +370,14 @@ public String guardarEvento(@PathVariable Long id, @Valid @ModelAttribute("edici errores.rejectValue(e.getCampo(), "invalido", e.getMessage()); } } + // La página de edición necesita el evento para mostrar sus datos junto a los errores return formularioEdicionEvento(gestion.evento(id), model); } + /** + * POST /gestion/eventos/{id}/borrar: borra un evento sin apuestas (HU-46). Si tiene apuestas no se borra + * y se muestra el motivo: el creador tiene que anularlo. + */ @PostMapping("/eventos/{id}/borrar") public String borrarEvento(@PathVariable Long id, RedirectAttributes redireccion) { try { @@ -337,6 +390,10 @@ public String borrarEvento(@PathVariable Long id, RedirectAttributes redireccion } } + /** + * Prepara el modelo de la página de edición. Solo se ofrecen los equipos de la competición del evento, + * y {@code editable} indica a la vista si el formulario se puede enviar (evento sin empezar). + */ private String formularioEdicionEvento(Evento evento, Model model) { model.addAttribute("evento", evento); model.addAttribute("equipos", @@ -345,6 +402,10 @@ private String formularioEdicionEvento(Evento evento, Model model) { return "gestion/editar-evento"; } + /** + * GET /gestion/eventos/{id}: ficha del evento para el creador, con el dinero apostado a cada opción (HU-06) + * y los botones de resultado, suspender, reactivar, anular, editar y borrar. + */ @GetMapping("/eventos/{id}") public String evento(@PathVariable Long id, Model model) { Evento evento = resolucion.evento(id); @@ -355,6 +416,11 @@ public String evento(@PathVariable Long id, Model model) { return "gestion/evento-detalle"; } + /** + * POST /gestion/eventos/{id}/resultado: introduce o corrige el resultado y resuelve las apuestas (HU-04). + * Si llega el marcador completo se usa el marcador, que además resuelve goles y ambos marcan (HU-52); + * si solo llega el ganador, se usa el ganador. Los errores se muestran en la ficha del evento. + */ @PostMapping("/eventos/{id}/resultado") public String resultado(@PathVariable Long id, @RequestParam(required = false) Resultado resultado, @RequestParam(required = false) Integer golesLocal, @@ -363,6 +429,7 @@ public String resultado(@PathVariable Long id, @RequestParam(required = false) R try { String guardado; int resueltas; + // Con los dos goles manda el marcador; el ganador se deduce de él if (golesLocal != null && golesVisitante != null) { Resultado delMarcador = golesLocal > golesVisitante ? Resultado.LOCAL : golesLocal < golesVisitante ? Resultado.VISITANTE : Resultado.EMPATE; @@ -388,22 +455,29 @@ public String resultado(@PathVariable Long id, @RequestParam(required = false) R return "redirect:/gestion/eventos/" + id; } + /** POST /gestion/eventos/{id}/suspender: el evento deja de admitir apuestas nuevas (HU-05). */ @PostMapping("/eventos/{id}/suspender") public String suspender(@PathVariable Long id, RedirectAttributes redireccion) { return accion(id, () -> resolucion.suspender(id), "Evento suspendido: no admite apuestas nuevas.", redireccion); } + /** POST /gestion/eventos/{id}/reactivar: un evento suspendido vuelve a admitir apuestas (HU-05). */ @PostMapping("/eventos/{id}/reactivar") public String reactivar(@PathVariable Long id, RedirectAttributes redireccion) { return accion(id, () -> resolucion.reactivar(id), "Evento reactivado.", redireccion); } + /** POST /gestion/eventos/{id}/anular: anula el evento y devuelve el importe de sus apuestas (HU-05). */ @PostMapping("/eventos/{id}/anular") public String anular(@PathVariable Long id, RedirectAttributes redireccion) { return accion(id, () -> resolucion.anular(id), "Evento anulado. Se ha devuelto el importe de todas sus apuestas.", redireccion); } + /** + * Ejecuta una acción sobre el evento y vuelve a su ficha. Si el estado del evento no la permite + * (por ejemplo, suspender uno ya finalizado), muestra el error en lugar de fallar. + */ private String accion(Long id, Runnable accion, String mensaje, RedirectAttributes redireccion) { try { accion.run(); @@ -414,6 +488,7 @@ private String accion(Long id, Runnable accion, String mensaje, RedirectAttribut return "redirect:/gestion/eventos/" + id; } + /** Prepara el modelo del formulario de alta de evento (competiciones y equipos para los desplegables). */ private String formularioEvento(Model model) { model.addAttribute("competiciones", gestion.competiciones()); model.addAttribute("equipos", gestion.equipos()); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionMercadosController.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionMercadosController.java index 45b30a4..fd3d84d 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionMercadosController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionMercadosController.java @@ -21,7 +21,14 @@ import es.ucm.fdi.is1.apuestas.mercados.MercadoService; import jakarta.validation.Valid; -/** Mercados a largo plazo en el panel del creador: alta, cuotas, cierre y ganador (HU-45). */ +/** + * Controlador de los mercados a largo plazo en el panel del creador: alta, cuotas, cierre, ganador y + * anulación (HU-45). Sus rutas cuelgan de {@code /gestion/mercados}, así que solo las usa el rol CREADOR + * (ver SeguridadConfig). La parte pública, donde los usuarios apuestan (HU-44), está en + * {@code MercadosController}. + *

    + * Las acciones vuelven siempre a la ficha del mercado con un mensaje de éxito o con el error. + */ @Controller @RequestMapping("/gestion/mercados") public class GestionMercadosController { @@ -36,17 +43,23 @@ public GestionMercadosController(MercadoService mercados, ResolucionMercadosServ this.reloj = reloj; } + /** Deportes para el desplegable del formulario de alta. */ @ModelAttribute("deportes") public Deporte[] deportes() { return Deporte.values(); } + /** GET /gestion/mercados/nuevo: formulario de alta de un mercado. */ @GetMapping("/nuevo") public String nuevo(Model model) { model.addAttribute("mercado", new MercadoForm()); return "gestion/mercado"; } + /** + * POST /gestion/mercados/nuevo: crea el mercado y lleva a su ficha (CA-45.1). Los errores al leer + * los candidatos (formato, cuotas, menos de 2) se muestran junto al campo de candidatos. + */ @PostMapping("/nuevo") public String crear(@Valid @ModelAttribute("mercado") MercadoForm form, BindingResult errores, RedirectAttributes redireccion) { @@ -63,6 +76,10 @@ public String crear(@Valid @ModelAttribute("mercado") MercadoForm form, BindingR return "gestion/mercado"; } + /** + * GET /gestion/mercados/{id}: ficha del mercado para el creador, con lo apostado a cada candidato + * y si todavía admite apuestas. + */ @GetMapping("/{id}") public String detalle(@PathVariable Long id, Model model) { Mercado mercado = mercados.mercado(id); @@ -72,6 +89,7 @@ public String detalle(@PathVariable Long id, Model model) { return "gestion/mercado-detalle"; } + /** POST /gestion/mercados/{id}/candidatos: añade un candidato (solo con el mercado abierto). */ @PostMapping("/{id}/candidatos") public String anadirCandidato(@PathVariable Long id, @RequestParam String nombre, @RequestParam(required = false) BigDecimal cuota, RedirectAttributes redireccion) { @@ -79,6 +97,10 @@ public String anadirCandidato(@PathVariable Long id, @RequestParam String nombre "Candidato añadido: " + nombre.trim(), redireccion); } + /** + * POST /gestion/mercados/{id}/candidatos/{candidatoId}/cuota: cambia la cuota de un candidato. + * La cuota es opcional en la petición para que, si falta, el error lo explique el propio candidato. + */ @PostMapping("/{id}/candidatos/{candidatoId}/cuota") public String cambiarCuota(@PathVariable Long id, @PathVariable Long candidatoId, @RequestParam(required = false) BigDecimal cuota, RedirectAttributes redireccion) { @@ -86,11 +108,16 @@ public String cambiarCuota(@PathVariable Long id, @PathVariable Long candidatoId "Cuota actualizada. Las apuestas ya hechas mantienen la suya.", redireccion); } + /** POST /gestion/mercados/{id}/cerrar: el mercado deja de admitir apuestas antes de su fecha (CA-45.2). */ @PostMapping("/{id}/cerrar") public String cerrar(@PathVariable Long id, RedirectAttributes redireccion) { return accion(id, () -> mercados.cerrar(id), "Mercado cerrado: ya no admite apuestas.", redireccion); } + /** + * POST /gestion/mercados/{id}/ganador: marca (o corrige) el candidato ganador. Se cobran las apuestas + * a ese candidato y las demás pasan a perdidas (CA-45.3). + */ @PostMapping("/{id}/ganador") public String ganador(@PathVariable Long id, @RequestParam Long candidato, RedirectAttributes redireccion) { try { @@ -102,12 +129,14 @@ public String ganador(@PathVariable Long id, @RequestParam Long candidato, Redir return "redirect:/gestion/mercados/" + id; } + /** POST /gestion/mercados/{id}/anular: anula el mercado y devuelve el importe de sus apuestas. */ @PostMapping("/{id}/anular") public String anular(@PathVariable Long id, RedirectAttributes redireccion) { return accion(id, () -> resolucion.anular(id), "Mercado anulado. Se ha devuelto el importe de todas sus apuestas.", redireccion); } + /** Ejecuta la acción y vuelve a la ficha del mercado; si una regla la impide, muestra el motivo. */ private String accion(Long id, Runnable accion, String mensaje, RedirectAttributes redireccion) { try { accion.run(); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionService.java index baa43dc..0f523d3 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/GestionService.java @@ -17,7 +17,12 @@ import es.ucm.fdi.is1.apuestas.eventos.EventoNoDisponibleException; import es.ucm.fdi.is1.apuestas.eventos.EventoRepository; -/** Alta de competiciones, equipos y eventos por parte del creador de apuestas (HU-01, HU-21). */ +/** + * Servicio del panel de gestión: alta de competiciones, equipos y eventos por parte del creador de apuestas + * (HU-01, HU-21), y edición o borrado de eventos mal creados (HU-46). + * Las reglas que incumple un formulario se avisan con {@link DatosInvalidosException}, que indica el campo, + * para que {@link GestionController} muestre el error junto a él. + */ @Service public class GestionService { @@ -36,15 +41,26 @@ public GestionService(CompeticionRepository competiciones, EquipoRepository equi this.reloj = reloj; } + /** + * Da de alta una competición (HU-01). + * + * @throws DatosInvalidosException si ya hay otra con el mismo nombre (sin distinguir mayúsculas) + */ @Transactional public Competicion crearCompeticion(CompeticionForm form) { String nombre = form.getNombre().trim(); + // Se compara sin mayúsculas para no tener "LaLiga" y "laliga" como dos competiciones distintas if (competiciones.existsByNombreIgnoreCase(nombre)) { throw new DatosInvalidosException("nombre", "Ya hay una competición que se llama " + nombre); } return competiciones.save(new Competicion(nombre, form.getDeporte())); } + /** + * Da de alta un equipo o deportista con su calificación y las competiciones en las que participa (HU-01). + * + * @throws DatosInvalidosException si el nombre ya existe o alguna competición es de otro deporte + */ @Transactional public Equipo crearEquipo(EquipoForm form) { String nombre = form.getNombre().trim(); @@ -55,6 +71,7 @@ public Equipo crearEquipo(EquipoForm form) { } Equipo equipo = new Equipo(nombre, form.getDeporte(), form.getCalidad()); for (Competicion competicion : competiciones.findAllById(form.getCompeticionIds())) { + // Un equipo de fútbol no puede jugar una competición de baloncesto if (competicion.getDeporte() != form.getDeporte()) { throw new DatosInvalidosException("competicionIds", "La competición " + competicion.getNombre() + " no es de " + form.getDeporte().getNombre()); @@ -64,20 +81,34 @@ public Equipo crearEquipo(EquipoForm form) { return equipos.save(equipo); } + /** @throws EquipoNoEncontradoException si no existe un equipo con ese id */ @Transactional(readOnly = true) public Equipo equipo(Long id) { return equipos.findById(id).orElseThrow(() -> new EquipoNoEncontradoException(id)); } + /** + * Cambia la calificación, la forma reciente (HU-02) y el escudo de un equipo. El nombre y el deporte + * no se editan. + * + * @throws EquipoNoEncontradoException si no existe el equipo + */ @Transactional public void editarEquipo(Long equipoId, EdicionEquipoForm form) { Equipo equipo = equipo(equipoId); equipo.setCalidad(form.getCalidad()); equipo.setForma(form.getForma()); String escudo = form.getEscudoUrl(); + // Un escudo en blanco se guarda como null: el equipo se queda sin escudo equipo.setEscudoUrl(escudo == null || escudo.isBlank() ? null : escudo.trim()); } + /** + * Crea un evento a mano (HU-21). Se guarda programado y aparece enseguida en el catálogo. + * + * @throws DatosInvalidosException si local y visitante coinciden, la competición no existe o algún equipo + * no participa en ella + */ @Transactional public Evento crearEvento(EventoForm form) { if (form.getLocalId().equals(form.getVisitanteId())) { @@ -88,6 +119,7 @@ public Evento crearEvento(EventoForm form) { Equipo local = equipoDeLaCompeticion(form.getLocalId(), competicion, "localId"); Equipo visitante = equipoDeLaCompeticion(form.getVisitanteId(), competicion, "visitanteId"); Evento evento = new Evento(competicion, local, visitante, form.getFechaHora()); + // La fase es opcional (jornada, semifinal...): si viene vacía el evento se queda sin ella if (form.getFase() != null && !form.getFase().isBlank()) { evento.setFase(form.getFase().trim()); } @@ -97,6 +129,10 @@ public Evento crearEvento(EventoForm form) { /** * Corrige un evento que aún no ha empezado (HU-46). Si ya tiene apuestas no se pueden cambiar * los equipos, porque cambiaría el sentido de esas apuestas; la fecha y la fase sí. + * La competición no se cambia: los equipos nuevos tienen que participar en la del evento. + * + * @throws DatosInvalidosException si los datos no son válidos, el evento ya ha empezado (o no está + * programado ni suspendido) o se cambian los equipos de un evento con apuestas */ @Transactional public Evento editarEvento(Long eventoId, EdicionEventoForm form) { @@ -106,6 +142,7 @@ public Evento editarEvento(Long eventoId, EdicionEventoForm form) { } Equipo local = equipoDeLaCompeticion(form.getLocalId(), evento.getCompeticion(), "localId"); Equipo visitante = equipoDeLaCompeticion(form.getVisitanteId(), evento.getCompeticion(), "visitanteId"); + // Se comparan los ids porque Equipo no redefine equals boolean cambianEquipos = !local.getId().equals(evento.getLocal().getId()) || !visitante.getId().equals(evento.getVisitante().getId()); if (cambianEquipos && selecciones.existsByEvento(evento)) { @@ -113,6 +150,7 @@ public Evento editarEvento(Long eventoId, EdicionEventoForm form) { "El evento ya tiene apuestas: no se pueden cambiar los equipos. Si está mal, anúlalo."); } String fase = form.getFase() == null || form.getFase().isBlank() ? null : form.getFase().trim(); + // El propio evento comprueba que aún no ha empezado; su error se convierte en un error del formulario try { evento.modificar(local, visitante, form.getFechaHora(), fase, LocalDateTime.now(reloj)); } catch (IllegalStateException e) { @@ -121,10 +159,15 @@ public Evento editarEvento(Long eventoId, EdicionEventoForm form) { return evento; } - /** Borra un evento creado por error (HU-46). Si tiene apuestas solo se puede anular. */ + /** + * Borra un evento creado por error (HU-46). Si tiene apuestas solo se puede anular. + * + * @throws IllegalStateException si alguna apuesta (simple o combinada) tiene una selección en el evento + */ @Transactional public void borrarEvento(Long eventoId) { Evento evento = evento(eventoId); + // Borrarlo dejaría apuestas apuntando a un evento que no existe; anularlo devuelve el importe if (selecciones.existsByEvento(evento)) { throw new IllegalStateException("El evento tiene apuestas: no se puede borrar, solo anular " + "(se devuelve el importe)."); @@ -132,11 +175,16 @@ public void borrarEvento(Long eventoId) { eventos.delete(evento); } + /** @throws EventoNoDisponibleException si no existe un evento con ese id */ @Transactional(readOnly = true) public Evento evento(Long eventoId) { return eventos.findById(eventoId).orElseThrow(() -> new EventoNoDisponibleException(eventoId)); } + /** + * Busca un equipo y comprueba que juega la competición. {@code campo} es el campo del formulario + * al que se asocia el error. + */ private Equipo equipoDeLaCompeticion(Long equipoId, Competicion competicion, String campo) { Equipo equipo = equipos.findById(equipoId) .orElseThrow(() -> new DatosInvalidosException(campo, "El equipo no existe")); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/LimitesForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/LimitesForm.java index ef4d949..f11eb31 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/LimitesForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/LimitesForm.java @@ -8,9 +8,14 @@ import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; -/** Límites de apuesta que fija el creador (HU-07). */ +/** + * Formulario de los límites de apuesta que fija el creador (HU-07). Lo usa {@link GestionController}; + * los valores se guardan en la entidad {@code Limites}, que comprueba además que el máximo no sea menor + * que el mínimo. + */ public class LimitesForm { + // Importes en moneditas con céntimos: positivos y con 2 decimales como mucho @NotNull(message = "Introduce el importe mínimo") @DecimalMin(value = "0.01", message = "Tiene que ser mayor que 0") @Digits(integer = 10, fraction = 2, message = "Como mucho 2 decimales") @@ -21,6 +26,7 @@ public class LimitesForm { @Digits(integer = 10, fraction = 2, message = "Como mucho 2 decimales") private BigDecimal importeMaximo; + /** Máximo de selecciones de una combinada: una combinada necesita al menos 2. */ @NotNull(message = "Introduce el número máximo de selecciones") @Min(value = 2, message = "Al menos 2") @Max(value = 30, message = "Como mucho 30") diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/MercadoForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/MercadoForm.java index fa79857..531f7ec 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/gestion/MercadoForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/gestion/MercadoForm.java @@ -10,7 +10,11 @@ import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; -/** Alta de un mercado a largo plazo con sus candidatos (HU-45). */ +/** + * Formulario de alta de un mercado a largo plazo con sus candidatos (HU-45). Las anotaciones solo + * comprueban que los campos estén rellenos; {@code MercadoService#crear} lee los candidatos y comprueba + * que haya al menos 2 y que sus cuotas sean válidas. + */ public class MercadoForm { @NotBlank(message = "Escribe el nombre del mercado") @@ -20,12 +24,13 @@ public class MercadoForm { @NotNull(message = "Elige un deporte") private Deporte deporte; + /** Último momento para apostar; formato del campo datetime-local de HTML. */ @NotNull(message = "Introduce la fecha de cierre") @Future(message = "La fecha de cierre tiene que ser futura") @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm") private LocalDateTime cierre; - /** Un candidato por línea: "Nombre; cuota". */ + /** Un candidato por línea: "Nombre; cuota". Se escribe en un área de texto y se lee en el servicio. */ @NotBlank(message = "Añade los candidatos") private String candidatos; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/Liga.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/Liga.java index ee289e7..df7abb3 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/Liga.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/Liga.java @@ -19,8 +19,9 @@ import jakarta.persistence.ManyToOne; /** - * Liga privada entre amigos (HU-51): tiene un código de invitación para unirse y su propio ranking, - * calculado solo con sus miembros. + * Entidad JPA: liga privada entre amigos (HU-51). Tiene un código de invitación para unirse y su propio + * ranking, calculado solo con sus miembros. Las reglas (cuántas ligas, quién sale o borra) están en + * {@link LigaService}; por eso unir y salir no son públicos. */ @Entity public class Liga { @@ -32,16 +33,21 @@ public class Liga { @Column(nullable = false, length = 40) private String nombre; + /** Código de invitación de 6 caracteres; único, lo genera {@link LigaService}. */ @Column(nullable = false, unique = true, length = 6) private String codigo; + /** Quien la creó: es el único que puede borrarla y no puede salir de ella. */ @ManyToOne(optional = false) private Usuario creador; @Column(nullable = false) private LocalDateTime creada; - /** Se cargan con la liga: las páginas los muestran fuera de la transacción (open-in-view desactivado). */ + /** + * Relación muchos a muchos con los usuarios, en la tabla intermedia liga_miembro. + * Se cargan con la liga: las páginas los muestran fuera de la transacción (open-in-view desactivado). + */ @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "liga_miembro", joinColumns = @JoinColumn(name = "liga_id"), inverseJoinColumns = @JoinColumn(name = "usuario_id")) @@ -51,6 +57,7 @@ protected Liga() { // requerido por JPA } + /** Crea la liga con su creador como primer miembro (CA-51.1). */ public Liga(String nombre, String codigo, Usuario creador, LocalDateTime creada) { this.nombre = nombre; this.codigo = codigo; @@ -59,6 +66,10 @@ public Liga(String nombre, String codigo, Usuario creador, LocalDateTime creada) miembros.add(creador); } + /** + * Compara por id porque Usuario no redefine equals: el mismo usuario cargado dos veces serían dos + * objetos distintos. Lo mismo en {@link #esCreador} y {@link #salir}. + */ public boolean esMiembro(Usuario usuario) { return miembros.stream().anyMatch(m -> m.getId().equals(usuario.getId())); } @@ -67,6 +78,7 @@ public boolean esCreador(Usuario usuario) { return creador.getId().equals(usuario.getId()); } + /** Lo llama {@link LigaService#unirse}, que antes comprueba que no sea ya miembro. */ void unir(Usuario usuario) { miembros.add(usuario); } @@ -75,6 +87,7 @@ void salir(Usuario usuario) { miembros.removeIf(m -> m.getId().equals(usuario.getId())); } + /** Ids de los miembros, para filtrar el ranking general y quedarse solo con la liga. */ public Set getIdsMiembros() { return miembros.stream().map(Usuario::getId).collect(Collectors.toSet()); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaNoEncontradaException.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaNoEncontradaException.java index 624b64f..3512cf2 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaNoEncontradaException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaNoEncontradaException.java @@ -3,7 +3,10 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.ResponseStatus; -/** La liga no existe o el usuario no es miembro: en los dos casos se trata como inexistente. */ +/** + * La liga no existe o el usuario no es miembro: en los dos casos se trata como inexistente, para no revelar + * a quien no es miembro que esa liga existe. Con {@code @ResponseStatus}, Spring responde con un 404. + */ @ResponseStatus(HttpStatus.NOT_FOUND) public class LigaNoEncontradaException extends RuntimeException { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaRepository.java index bd4d9b3..7b49594 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaRepository.java @@ -8,12 +8,15 @@ import es.ucm.fdi.is1.apuestas.usuarios.Usuario; +/** Repositorio Spring Data de las ligas privadas (HU-51). */ public interface LigaRepository extends JpaRepository { Optional findByCodigo(String codigo); + /** Para no repetir código al generar uno nuevo. */ boolean existsByCodigo(String codigo); + /** Ligas en las que está el usuario (creadas por él o no), por nombre. El join recorre la tabla liga_miembro. */ @Query("select l from Liga l join l.miembros m where m = :usuario order by l.nombre") List deMiembro(Usuario usuario); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaService.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaService.java index f90522a..f687c06 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigaService.java @@ -18,19 +18,25 @@ import es.ucm.fdi.is1.apuestas.usuarios.Usuario; import es.ucm.fdi.is1.apuestas.usuarios.UsuarioRepository; -/** Ligas privadas entre amigos con código de invitación y ranking propio (HU-51). */ +/** + * Servicio de las ligas privadas entre amigos, con código de invitación y ranking propio (HU-51). + * El ranking de una liga es el general de {@link RankingService} filtrado a sus miembros, así que usa + * los mismos criterios (CA-51.2). Los usuarios llegan por su email, el de la sesión iniciada. + */ @Service public class LigaService { /** Sin letras ni números que se confundan al dictarlos (0/O, 1/I/L). */ static final String ALFABETO = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; static final int LONGITUD_CODIGO = 6; + /** Ligas en las que puede estar un jugador a la vez, contando las que ha creado. */ static final int MAX_LIGAS = 10; private final LigaRepository ligas; private final UsuarioRepository usuarios; private final RankingService rankings; private final Clock reloj; + /** SecureRandom y no Random: los códigos no se pueden adivinar a partir de otros. */ private final SecureRandom azar = new SecureRandom(); public LigaService(LigaRepository ligas, UsuarioRepository usuarios, RankingService rankings, Clock reloj) { @@ -40,12 +46,18 @@ public LigaService(LigaRepository ligas, UsuarioRepository usuarios, RankingServ this.reloj = reloj; } + /** Ligas en las que está el usuario, por nombre. */ @Transactional(readOnly = true) public List mias(String email) { return ligas.deMiembro(usuario(email)); } - /** Crea la liga con un código nuevo; quien la crea es su primer miembro. */ + /** + * Crea la liga con un código nuevo; quien la crea es su primer miembro (CA-51.1). + * + * @throws IllegalArgumentException si es el creador de apuestas, el nombre no tiene entre 1 y 40 caracteres + * o el usuario ya está en {@link #MAX_LIGAS} ligas + */ @Transactional public Liga crear(String email, String nombre) { Usuario usuario = jugador(email); @@ -59,10 +71,16 @@ public Liga crear(String email, String nombre) { return ligas.save(new Liga(limpio, codigoNuevo(), usuario, LocalDateTime.now(reloj))); } - /** Entra en la liga del código (sin distinguir mayúsculas ni espacios). */ + /** + * Entra en la liga del código (sin distinguir mayúsculas ni espacios) (CA-51.2). + * + * @throws IllegalArgumentException si es el creador de apuestas, el código no existe, ya es miembro + * o ya está en {@link #MAX_LIGAS} ligas + */ @Transactional public Liga unirse(String email, String codigo) { Usuario usuario = jugador(email); + // "abc 123" y "ABC123" son el mismo código: se quitan los espacios y se pasa a mayúsculas String limpio = codigo == null ? "" : codigo.replaceAll("\\s", "").toUpperCase(Locale.ROOT); Liga liga = ligas.findByCodigo(limpio) .orElseThrow(() -> new IllegalArgumentException("No hay ninguna liga con el código " + limpio)); @@ -72,11 +90,16 @@ public Liga unirse(String email, String codigo) { if (ligas.deMiembro(usuario).size() >= MAX_LIGAS) { throw new IllegalArgumentException("Puedes estar como mucho en " + MAX_LIGAS + " ligas"); } + // No hace falta save: la liga está gestionada por JPA y el cambio se guarda al acabar la transacción liga.unir(usuario); return liga; } - /** Solo la ven sus miembros. */ + /** + * Devuelve la liga si el usuario es miembro. Solo la ven sus miembros. + * + * @throws LigaNoEncontradaException si no existe o el usuario no es miembro + */ @Transactional(readOnly = true) public Liga ver(Long id, String email) { Usuario usuario = usuario(email); @@ -84,12 +107,21 @@ public Liga ver(Long id, String email) { .orElseThrow(() -> new LigaNoEncontradaException(id)); } + /** + * Ranking de la liga con el criterio elegido (saldo, ganancias o aciertos), solo con sus miembros. + * El email sirve para destacar el puesto de quien lo consulta. + */ @Transactional(readOnly = true) public List ranking(Liga liga, String email, CriterioRanking criterio) { return rankings.ranking(email, criterio, liga.getIdsMiembros()); } - /** Sale de la liga. Quien la creó no puede salir: la borra. */ + /** + * Sale de la liga y deja de aparecer en su ranking (CA-51.3). Quien la creó no puede salir: la borra. + * + * @throws LigaNoEncontradaException si no existe o no es miembro + * @throws IllegalArgumentException si es quien la creó + */ @Transactional public Liga salir(Long id, String email) { Liga liga = ver(id, email); @@ -101,7 +133,12 @@ public Liga salir(Long id, String email) { return liga; } - /** Solo la puede borrar quien la creó. */ + /** + * Borra la liga con todos sus miembros (CA-51.3). Solo la puede borrar quien la creó. + * + * @throws LigaNoEncontradaException si no existe o no es miembro + * @throws IllegalArgumentException si no es quien la creó + */ @Transactional public Liga borrar(Long id, String email) { Liga liga = ver(id, email); @@ -114,6 +151,8 @@ public Liga borrar(Long id, String email) { /** * Al eliminar una cuenta: se borran las ligas que creó (nadie más podría borrarlas) y sale de las demás. + * Escucha el evento {@link CuentaEliminada} que publica la baja (HU-18); se ejecuta en el momento, + * dentro de la misma transacción. */ @EventListener public void alEliminarCuenta(CuentaEliminada cuenta) { @@ -127,6 +166,7 @@ public void alEliminarCuenta(CuentaEliminada cuenta) { } } + /** Genera códigos al azar con {@link #ALFABETO} hasta dar con uno que no use otra liga. */ private String codigoNuevo() { String codigo; do { @@ -139,6 +179,7 @@ private String codigoNuevo() { return codigo; } + /** Usuario de la sesión; siempre existe porque ha iniciado sesión. */ private Usuario usuario(String email) { return usuarios.findByEmail(email).orElseThrow(); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigasController.java b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigasController.java index eee1311..2ecff3d 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigasController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/ligas/LigasController.java @@ -15,7 +15,12 @@ import es.ucm.fdi.is1.apuestas.apuesta.CriterioRanking; import es.ucm.fdi.is1.apuestas.apuesta.PuestoRanking; -/** Ligas privadas (HU-51): mis ligas, crear, unirse con un código, ver su ranking, salir y borrar. */ +/** + * Controlador de las ligas privadas (HU-51): mis ligas, crear, unirse con un código, ver su ranking, salir + * y borrar. Las rutas cuelgan de {@code /ligas} y exigen haber iniciado sesión (ver SeguridadConfig); + * el usuario se obtiene del {@link Principal}, cuyo nombre es su email. + * Los errores de las reglas se muestran como mensaje en la página a la que se redirige. + */ @Controller @RequestMapping("/ligas") public class LigasController { @@ -26,12 +31,14 @@ public LigasController(LigaService ligas) { this.ligas = ligas; } + /** GET /ligas: ligas del usuario, con los formularios para crear una o unirse con un código. */ @GetMapping public String mias(Principal principal, Model model) { model.addAttribute("ligas", ligas.mias(principal.getName())); return "ligas"; } + /** POST /ligas: crea una liga y lleva a su página, con el código que hay que compartir (CA-51.1). */ @PostMapping public String crear(@RequestParam(required = false) String nombre, Principal principal, RedirectAttributes redireccion) { @@ -46,6 +53,7 @@ public String crear(@RequestParam(required = false) String nombre, Principal pri } } + /** POST /ligas/unirse: entra en la liga del código y lleva a su página (CA-51.2). */ @PostMapping("/unirse") public String unirse(@RequestParam(required = false) String codigo, Principal principal, RedirectAttributes redireccion) { @@ -59,6 +67,10 @@ public String unirse(@RequestParam(required = false) String codigo, Principal pr } } + /** + * GET /ligas/{id}: página de la liga, solo para sus miembros (si no, 404). Muestra el ranking + * de sus miembros con el criterio de {@code orden} (por defecto, saldo) y el código para invitar. + */ @GetMapping("/{id}") public String ver(@PathVariable Long id, @RequestParam(defaultValue = "SALDO") CriterioRanking orden, Principal principal, Model model) { @@ -68,10 +80,12 @@ public String ver(@PathVariable Long id, @RequestParam(defaultValue = "SALDO") C model.addAttribute("puestos", puestos); model.addAttribute("orden", orden); model.addAttribute("criterios", CriterioRanking.values()); + // La vista ofrece "borrar" al creador y "salir" al resto model.addAttribute("soyCreador", liga.getCreador().getEmail().equalsIgnoreCase(principal.getName())); return "liga"; } + /** POST /ligas/{id}/salir: sale de la liga (el creador no puede; tiene que borrarla) (CA-51.3). */ @PostMapping("/{id}/salir") public String salir(@PathVariable Long id, Principal principal, RedirectAttributes redireccion) { try { @@ -84,6 +98,7 @@ public String salir(@PathVariable Long id, Principal principal, RedirectAttribut } } + /** POST /ligas/{id}/borrar: borra la liga; solo quien la creó (CA-51.3). */ @PostMapping("/{id}/borrar") public String borrar(@PathVariable Long id, Principal principal, RedirectAttributes redireccion) { try { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Candidato.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Candidato.java index 34a5381..932ce52 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Candidato.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Candidato.java @@ -9,10 +9,15 @@ import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; -/** Uno de los posibles ganadores de un mercado, con la cuota que fija el creador (HU-45). */ +/** + * Entidad JPA: uno de los posibles ganadores de un mercado, con la cuota que fija el creador (HU-45). + * Las apuestas a largo plazo apuntan a un candidato (HU-44). Se crea solo desde + * {@link Mercado#anadirCandidato}, por eso el constructor no es público. + */ @Entity public class Candidato { + /** Cuotas admitidas para un candidato: con 1,00 el que acierta no ganaría nada. */ static final BigDecimal CUOTA_MINIMA = new BigDecimal("1.01"); static final BigDecimal CUOTA_MAXIMA = new BigDecimal("1000.00"); @@ -43,14 +48,20 @@ protected Candidato() { cambiarCuota(cuota); } - /** Las apuestas ya hechas conservan la cuota con la que se hicieron. */ + /** + * Cambia la cuota para las apuestas nuevas. Las apuestas ya hechas conservan la cuota con la que se hicieron. + * + * @throws IllegalArgumentException si falta, no está entre 1,01 y 1000 o tiene más de 2 decimales + */ public void cambiarCuota(BigDecimal nueva) { if (nueva == null || nueva.compareTo(CUOTA_MINIMA) < 0 || nueva.compareTo(CUOTA_MAXIMA) > 0) { throw new IllegalArgumentException("La cuota debe estar entre 1,01 y 1000"); } + // stripTrailingZeros hace que "2.500" cuente como 2,5 y se acepte if (nueva.stripTrailingZeros().scale() > 2) { throw new IllegalArgumentException("La cuota admite como mucho 2 decimales"); } + // Siempre con 2 decimales, como la columna (scale = 2) cuota = nueva.setScale(2); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/CandidatoRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/CandidatoRepository.java index 095fab8..141906b 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/CandidatoRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/CandidatoRepository.java @@ -2,5 +2,6 @@ import org.springframework.data.jpa.repository.JpaRepository; +/** Repositorio Spring Data de los candidatos; se buscan por id al cambiar su cuota o al apostar. */ public interface CandidatoRepository extends JpaRepository { } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/EstadoMercado.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/EstadoMercado.java index 9e37202..7f0ee9c 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/EstadoMercado.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/EstadoMercado.java @@ -2,7 +2,7 @@ /** Ciclo de vida de un mercado a largo plazo (HU-45). */ public enum EstadoMercado { - /** Admite apuestas hasta la fecha de cierre. */ + /** Admite apuestas hasta la fecha de cierre; pasada esa fecha sigue en este estado, pero ya no las admite. */ ABIERTO, /** El creador lo ha cerrado antes de tiempo; espera al ganador. */ CERRADO, diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Mercado.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Mercado.java index f187fa1..5bb1432 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Mercado.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/Mercado.java @@ -20,8 +20,10 @@ import jakarta.persistence.OrderBy; /** - * Apuesta a largo plazo con varios candidatos: Balón de Oro, campeón de liga… (HU-44, HU-45). - * El creador fija la cuota de cada candidato, lo cierra y marca el ganador. + * Entidad JPA: mercado de apuesta a largo plazo con varios candidatos: Balón de Oro, campeón de liga… + * (HU-44, HU-45). El creador fija la cuota de cada candidato, lo cierra y marca el ganador. + * Sus estados están en {@link EstadoMercado}. El mercado guarda sus reglas (qué se puede hacer en cada + * estado); el pago de las apuestas lo hace {@code ResolucionMercadosService}. */ @Entity public class Mercado { @@ -45,10 +47,15 @@ public class Mercado { @Enumerated(EnumType.STRING) private EstadoMercado estado = EstadoMercado.ABIERTO; + /** + * Los candidatos se guardan y se borran junto con el mercado (cascada). Se cargan siempre con él (EAGER), + * ordenados de la cuota más baja (el favorito) a la más alta. + */ @OneToMany(mappedBy = "mercado", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) @OrderBy("cuota, nombre") private List candidatos = new ArrayList<>(); + /** Candidato ganador; null hasta que el creador lo marca. */ @ManyToOne private Candidato ganador; @@ -62,6 +69,14 @@ public Mercado(String nombre, Deporte deporte, LocalDateTime cierre) { this.cierre = cierre; } + /** + * Añade un candidato con su cuota. Solo con el mercado abierto: si estuviera cerrado, nadie podría + * apostarle. + * + * @throws IllegalStateException si el mercado no está abierto + * @throws IllegalArgumentException si el nombre está vacío, pasa de 100 caracteres, ya es candidato + * (sin distinguir mayúsculas) o la cuota no es válida + */ public Candidato anadirCandidato(String nombreCandidato, BigDecimal cuota) { if (estado != EstadoMercado.ABIERTO) { throw new IllegalStateException("Solo se pueden añadir candidatos a un mercado abierto"); @@ -78,11 +93,19 @@ public Candidato anadirCandidato(String nombreCandidato, BigDecimal cuota) { return candidato; } + /** + * Se puede apostar si está abierto y no ha llegado la fecha de cierre. Al llegar esa fecha deja de admitir + * apuestas aunque su estado siga siendo ABIERTO: no hace falta que nadie lo cierre a mano (CA-44.3). + */ public boolean admiteApuestas(LocalDateTime ahora) { return estado == EstadoMercado.ABIERTO && cierre.isAfter(ahora); } - /** Deja de admitir apuestas antes de la fecha de cierre. */ + /** + * Deja de admitir apuestas antes de la fecha de cierre (CA-45.2). + * + * @throws IllegalStateException si no está abierto + */ public void cerrar() { if (estado != EstadoMercado.ABIERTO) { throw new IllegalStateException("Solo se puede cerrar un mercado abierto"); @@ -90,11 +113,19 @@ public void cerrar() { estado = EstadoMercado.CERRADO; } - /** Marca (o corrige) el ganador; las apuestas se pagan desde el servicio. */ + /** + * Marca (o corrige) el ganador; las apuestas se pagan desde el servicio (CA-45.3). + * Se admite en cualquier estado salvo anulado: también con el mercado abierto y, para corregir un error, + * con el mercado ya resuelto. + * + * @throws IllegalStateException si el mercado está anulado + * @throws IllegalArgumentException si el candidato no es de este mercado + */ public void resolver(Candidato candidato) { if (estado == EstadoMercado.ANULADO) { throw new IllegalStateException("Un mercado anulado no puede tener ganador"); } + // Candidato no redefine equals: se busca el mismo objeto, cargado en la misma transacción if (!candidatos.contains(candidato)) { throw new IllegalArgumentException(candidato.getNombre() + " no es candidato de " + nombre); } @@ -102,6 +133,12 @@ public void resolver(Candidato candidato) { estado = EstadoMercado.RESUELTO; } + /** + * Anula el mercado; el importe de sus apuestas se devuelve desde el servicio. Uno resuelto ya no se anula + * porque sus apuestas están pagadas: si el ganador estaba mal, se corrige con {@link #resolver}. + * + * @throws IllegalStateException si ya está resuelto o anulado + */ public void anular() { if (estado == EstadoMercado.RESUELTO || estado == EstadoMercado.ANULADO) { throw new IllegalStateException("No se puede anular un mercado resuelto o ya anulado"); @@ -129,6 +166,7 @@ public EstadoMercado getEstado() { return estado; } + /** Lista de solo lectura: los candidatos se añaden con {@link #anadirCandidato}, que aplica las reglas. */ public List getCandidatos() { return Collections.unmodifiableList(candidatos); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoNoDisponibleException.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoNoDisponibleException.java index 241397a..19c43c2 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoNoDisponibleException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoNoDisponibleException.java @@ -1,6 +1,9 @@ package es.ucm.fdi.is1.apuestas.mercados; -/** El mercado no existe, no tiene ese candidato o ya no admite apuestas. */ +/** + * El mercado no existe, no tiene ese candidato o ya no admite apuestas. La lanzan {@link MercadoService} + * y {@code ApuestaService} al apostar (CA-44.3). El mensaje es el mismo en los tres casos. + */ public class MercadoNoDisponibleException extends RuntimeException { public MercadoNoDisponibleException(Long id) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoRepository.java index 8c1fa49..18ac03c 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoRepository.java @@ -5,9 +5,12 @@ import org.springframework.data.jpa.repository.JpaRepository; +/** Repositorio Spring Data de los mercados a largo plazo (HU-44, HU-45). */ public interface MercadoRepository extends JpaRepository { + /** Todos los mercados, del que cierra antes al que cierra después. */ List findAllByOrderByCierreAsc(); + /** Lo usan los datos iniciales para no crear dos veces el mismo mercado de ejemplo. */ Optional findByNombre(String nombre); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoService.java b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoService.java index ddab9f9..e101503 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/mercados/MercadoService.java @@ -12,7 +12,11 @@ import es.ucm.fdi.is1.apuestas.equipos.Deporte; import es.ucm.fdi.is1.apuestas.equipos.EquipoRepository; -/** Mercados a largo plazo: consulta pública (HU-44) y alta y edición por el creador (HU-45). */ +/** + * Servicio de los mercados a largo plazo: consulta pública (HU-44) y alta y edición por el creador (HU-45). + * Lo usan {@code MercadosController} (catálogo) y {@code GestionMercadosController} (panel del creador). + * Marcar el ganador y anular, que mueven dinero, están en {@code ResolucionMercadosService}. + */ @Service public class MercadoService { @@ -36,7 +40,10 @@ public List abiertos() { return mercados.findAllByOrderByCierreAsc().stream().filter(m -> m.admiteApuestas(ahora)).toList(); } - /** Mercados cerrados o ya resueltos, pendientes de ganador o con él (no los anulados). */ + /** + * Mercados cerrados o ya resueltos, pendientes de ganador o con él (no los anulados). + * Incluye los que siguen ABIERTO pero ya han pasado su fecha de cierre. + */ @Transactional(readOnly = true) public List terminados() { LocalDateTime ahora = LocalDateTime.now(reloj); @@ -45,11 +52,13 @@ public List terminados() { .toList(); } + /** Todos los mercados, en cualquier estado, para el panel del creador. */ @Transactional(readOnly = true) public List todos() { return mercados.findAllByOrderByCierreAsc(); } + /** @throws MercadoNoDisponibleException si no existe un mercado con ese id */ @Transactional(readOnly = true) public Mercado mercado(Long id) { return mercados.findById(id).orElseThrow(() -> new MercadoNoDisponibleException(id)); @@ -58,6 +67,10 @@ public Mercado mercado(Long id) { /** * Crea un mercado con sus candidatos, uno por línea con el formato "Nombre; cuota" (HU-45). * Si un candidato se llama igual que un equipo o deportista del mismo deporte, se enlaza para mostrar su escudo. + * Se valida todo antes de guardar: si una línea está mal, no se crea nada. + * + * @throws IllegalArgumentException si el cierre no es futuro, hay menos de 2 candidatos, una línea no + * tiene el formato, una cuota o un nombre no son válidos o un nombre se repite */ @Transactional public Mercado crear(String nombre, Deporte deporte, LocalDateTime cierre, String lineasCandidatos) { @@ -68,6 +81,7 @@ public Mercado crear(String nombre, Deporte deporte, LocalDateTime cierre, Strin if (leidos.size() < 2) { throw new IllegalArgumentException("Hacen falta al menos 2 candidatos"); } + // Si algún candidato falla, la excepción llega antes del save y el mercado no se guarda Mercado mercado = new Mercado(nombre.trim(), deporte, cierre); for (String[] candidato : leidos) { anadir(mercado, candidato[0], cuota(candidato[1], candidato[0])); @@ -75,6 +89,12 @@ public Mercado crear(String nombre, Deporte deporte, LocalDateTime cierre, Strin return mercados.save(mercado); } + /** + * Añade un candidato a un mercado ya creado. Solo si el mercado sigue abierto. + * + * @throws IllegalArgumentException si el nombre está vacío o repetido, o la cuota no es válida + * @throws IllegalStateException si el mercado no está abierto + */ @Transactional public Candidato anadirCandidato(Long mercadoId, String nombre, BigDecimal cuota) { if (nombre == null || nombre.isBlank()) { @@ -83,10 +103,18 @@ public Candidato anadirCandidato(Long mercadoId, String nombre, BigDecimal cuota return anadir(mercado(mercadoId), nombre.trim(), cuota); } - /** Las apuestas ya hechas conservan su cuota; solo cambia para las nuevas. */ + /** + * Cambia la cuota de un candidato. Las apuestas ya hechas conservan su cuota; solo cambia para las nuevas. + * Se puede con el mercado abierto o cerrado, pero no si ya está resuelto o anulado. + * + * @throws MercadoNoDisponibleException si el candidato no existe o no es de ese mercado + * @throws IllegalStateException si el mercado está resuelto o anulado + * @throws IllegalArgumentException si la cuota no es válida + */ @Transactional public Candidato cambiarCuota(Long mercadoId, Long candidatoId, BigDecimal cuota) { Candidato candidato = candidatos.findById(candidatoId) + // Se comprueba que el candidato es de ese mercado: el id del mercado viene en la URL .filter(c -> c.getMercado().getId().equals(mercadoId)) .orElseThrow(() -> new MercadoNoDisponibleException(mercadoId)); EstadoMercado estado = candidato.getMercado().getEstado(); @@ -97,11 +125,17 @@ public Candidato cambiarCuota(Long mercadoId, Long candidatoId, BigDecimal cuota return candidato; } + /** + * Cierra el mercado antes de su fecha: deja de admitir apuestas (CA-45.2). + * + * @throws IllegalStateException si no está abierto + */ @Transactional public void cerrar(Long mercadoId) { mercado(mercadoId).cerrar(); } + /** Añade el candidato y, si hay un equipo del mismo deporte con ese nombre exacto, lo enlaza. */ private Candidato anadir(Mercado mercado, String nombre, BigDecimal cuota) { Candidato candidato = mercado.anadirCandidato(nombre, cuota); equipos.findByNombre(nombre) @@ -110,15 +144,21 @@ private Candidato anadir(Mercado mercado, String nombre, BigDecimal cuota) { return candidato; } + /** + * Lee el texto del formulario: una línea por candidato, "Nombre; cuota". Las líneas vacías se saltan. + * Devuelve pares [nombre, cuota como texto]. + */ private static List leerCandidatos(String texto) { List leidos = new ArrayList<>(); if (texto == null) { return leidos; } + // \R es cualquier salto de línea (\n en Linux, \r\n en Windows) for (String linea : texto.split("\\R")) { if (linea.isBlank()) { continue; } + // Exactamente un ";" y un nombre no vacío; la cuota se comprueba después String[] partes = linea.split(";"); if (partes.length != 2 || partes[0].isBlank()) { throw new IllegalArgumentException("Línea no válida: \"" + linea.trim() @@ -129,6 +169,7 @@ private static List leerCandidatos(String texto) { return leidos; } + /** Convierte la cuota; admite coma decimal ("2,50") además de punto. El rango lo comprueba Candidato. */ private static BigDecimal cuota(String texto, String candidato) { try { return new BigDecimal(texto.replace(',', '.')); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/Notificacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/Notificacion.java index 0d29d00..8bc8db5 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/Notificacion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/Notificacion.java @@ -9,7 +9,10 @@ import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; -/** Aviso a un usuario, por ejemplo cuando se resuelve una de sus apuestas (HU-37). */ +/** + * Entidad JPA: aviso a un usuario, por ejemplo cuando se resuelve una de sus apuestas (HU-37). + * Las crea {@link NotificacionService#avisar}; los textos de las apuestas los compone {@code AvisosApuestas}. + */ @Entity public class Notificacion { @@ -30,6 +33,7 @@ public class Notificacion { @Column(nullable = false) private LocalDateTime fecha; + /** Si ya se ha visto en la página de avisos; los no leídos cuentan en la campana de la cabecera. */ @Column(nullable = false) private boolean leida; @@ -37,9 +41,11 @@ protected Notificacion() { // requerido por JPA } + /** Aviso nuevo, sin leer. */ public Notificacion(Usuario usuario, String tipo, String texto, LocalDateTime fecha) { this.usuario = usuario; this.tipo = tipo; + // La columna admite 500 caracteres: un texto más largo se corta y termina en "…" this.texto = texto.length() > 500 ? texto.substring(0, 497) + "…" : texto; this.fecha = fecha; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionRepository.java index 77056ad..3ce99d8 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionRepository.java @@ -6,13 +6,18 @@ import es.ucm.fdi.is1.apuestas.usuarios.Usuario; +/** Repositorio Spring Data de los avisos (HU-37). Las consultas salen del nombre de cada método. */ public interface NotificacionRepository extends JpaRepository { + /** Los 50 avisos más recientes del usuario; con la misma fecha, el último creado primero. */ List findTop50ByUsuarioOrderByFechaDescIdDesc(Usuario usuario); + /** Cuántos avisos sin leer tiene el usuario con ese email ({@code UsuarioEmail} = usuario.email). */ long countByUsuarioEmailAndLeidaFalse(String email); + /** Avisos del usuario que aún no ha leído. */ List findByUsuarioAndLeidaFalse(Usuario usuario); + /** Borra todos los avisos del usuario (baja de la cuenta, HU-18). */ void deleteByUsuario(Usuario usuario); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionService.java index ba5e692..327bee1 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionService.java @@ -10,7 +10,10 @@ import es.ucm.fdi.is1.apuestas.usuarios.Usuario; import es.ucm.fdi.is1.apuestas.usuarios.UsuarioRepository; -/** Avisos dentro de la web (HU-37). */ +/** + * Servicio de los avisos dentro de la web (HU-37): crearlos, contarlos para la campana, leerlos y + * borrarlos al eliminar la cuenta. No envía correos: los avisos solo se ven en la página de avisos. + */ @Service public class NotificacionService { @@ -24,21 +27,32 @@ public NotificacionService(NotificacionRepository notificaciones, UsuarioReposit this.reloj = reloj; } + /** + * Guarda un aviso nuevo, sin leer, con la fecha de ahora. + * + * @param tipo para el icono de la vista: "ganada", "perdida", "anulada"… + */ @Transactional public Notificacion avisar(Usuario usuario, String tipo, String texto) { return notificaciones.save(new Notificacion(usuario, tipo, texto, LocalDateTime.now(reloj))); } + /** Número de avisos sin leer, para la campana de la cabecera. */ @Transactional(readOnly = true) public long sinLeer(String email) { return notificaciones.countByUsuarioEmailAndLeidaFalse(email); } - /** Las últimas notificaciones del usuario; las que aún no había leído salen marcadas como nuevas. */ + /** + * Las últimas 50 notificaciones del usuario, de la más reciente a la más antigua; las que aún no + * había leído salen marcadas como nuevas y, a partir de ahora, quedan leídas. Se puede volver + * a consultar un aviso ya leído (CA-37.3). + */ @Transactional public List leer(String email) { Usuario usuario = usuarios.findByEmail(email).orElseThrow(); List mostradas = notificaciones.findTop50ByUsuarioOrderByFechaDescIdDesc(usuario); + // Se copian antes de marcarlos: así la vista sabe cuáles eran nuevos List avisos = mostradas.stream() .map(n -> new Aviso(n.getTipo(), n.getTexto(), n.getFecha(), !n.isLeida())) .toList(); @@ -53,6 +67,7 @@ public void borrarDe(Usuario usuario) { notificaciones.deleteByUsuario(usuario); } + /** Aviso tal como lo muestra la vista; {@code nuevo} si no se había leído antes de abrir la página. */ public record Aviso(String tipo, String texto, LocalDateTime fecha, boolean nuevo) { } } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionesController.java b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionesController.java index ea84b6a..16ac0e6 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionesController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/notificaciones/NotificacionesController.java @@ -6,7 +6,10 @@ import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; -/** Página de avisos del usuario (HU-37). Al abrirla, los avisos quedan leídos. */ +/** + * Controlador de la página de avisos del usuario (HU-37). Al abrirla, los avisos que se ven quedan leídos. + * Exige haber iniciado sesión ({@code SeguridadConfig}). + */ @Controller public class NotificacionesController { @@ -16,10 +19,11 @@ public NotificacionesController(NotificacionService notificaciones) { this.notificaciones = notificaciones; } + /** GET /notificaciones: los últimos avisos del usuario, con los nuevos destacados. */ @GetMapping("/notificaciones") public String notificaciones(Principal principal, Model model) { model.addAttribute("avisos", notificaciones.leer(principal.getName())); - // La cabecera ya no tiene avisos pendientes + // La cabecera ya no tiene avisos pendientes: el recuento de UsuarioActualAdvice se hizo antes de leerlos model.addAttribute("avisosSinLeer", 0L); return "notificaciones"; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Contrasenas.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Contrasenas.java index 795ea75..cc814fb 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Contrasenas.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Contrasenas.java @@ -2,7 +2,11 @@ import java.nio.charset.StandardCharsets; -/** BCrypt solo admite 72 bytes: con tildes o ñ, menos de 72 caracteres pueden pasar de ese límite. */ +/** + * BCrypt solo admite 72 bytes: con tildes o ñ, menos de 72 caracteres pueden pasar de ese límite. + * Los formularios ya limitan a 72 caracteres con {@code @Size}; los controladores de registro, + * cambio y recuperación de contraseña usan además esta comprobación en bytes. + */ public final class Contrasenas { static final int MAXIMO_BYTES = 72; @@ -10,6 +14,7 @@ public final class Contrasenas { private Contrasenas() { } + /** Si la contraseña cabe en BCrypt. Una contraseña null se da por buena: de eso se encarga @NotBlank. */ public static boolean cabe(String password) { return password == null || password.getBytes(StandardCharsets.UTF_8).length <= MAXIMO_BYTES; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CorreoRecuperacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CorreoRecuperacion.java index 74e7537..df077cd 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CorreoRecuperacion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CorreoRecuperacion.java @@ -12,12 +12,14 @@ /** * Envía el enlace por correo si hay un servidor configurado (spring.mail.host, ver README). Si no lo hay, * por ejemplo en el ordenador de cada uno, el enlace se escribe en la consola de la aplicación. + * Implementación de {@link EnvioCorreo} que usa {@link RecuperacionService} (HU-17). */ @Component public class CorreoRecuperacion implements EnvioCorreo { private static final Logger LOG = LoggerFactory.getLogger(CorreoRecuperacion.class); + // ObjectProvider: Spring Boot solo crea el JavaMailSender si hay servidor de correo configurado private final ObjectProvider correo; private final String servidorCorreo; private final String remitente; @@ -30,6 +32,7 @@ public CorreoRecuperacion(ObjectProvider correo, this.remitente = remitente; } + /** Manda el correo con el enlace o, si no hay servidor, lo escribe en el registro de la aplicación. */ @Override public void enviarRecuperacion(String email, String nombre, String enlace) { JavaMailSender servidor = servidorCorreo.isBlank() ? null : correo.getIfAvailable(); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CuentaEliminada.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CuentaEliminada.java index c20c707..5c97f17 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CuentaEliminada.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/CuentaEliminada.java @@ -1,5 +1,9 @@ package es.ucm.fdi.is1.apuestas.usuarios; -/** Se publica al eliminar una cuenta (HU-18), antes de anonimizarla, para que otras partes limpien lo suyo. */ +/** + * Se publica al eliminar una cuenta (HU-18), antes de anonimizarla, para que otras partes limpien lo suyo. + * Evento de Spring: lo publica {@code BajaService} y lo escucha {@code LigaService} con + * {@code @EventListener} (borra las ligas que creó el usuario y lo saca de las demás). + */ public record CuentaEliminada(Long usuarioId) { } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EmailYaRegistradoException.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EmailYaRegistradoException.java index 3da99a2..ef9ea75 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EmailYaRegistradoException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EmailYaRegistradoException.java @@ -1,5 +1,9 @@ package es.ucm.fdi.is1.apuestas.usuarios; +/** + * Ya hay una cuenta con ese email, o usa el dominio reservado de las cuentas eliminadas (HU-11, CA-11.2). + * La lanza {@link UsuarioService#crear}. + */ public class EmailYaRegistradoException extends RuntimeException { public EmailYaRegistradoException(String email) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EnvioCorreo.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EnvioCorreo.java index 4047903..c605812 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EnvioCorreo.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/EnvioCorreo.java @@ -1,7 +1,11 @@ package es.ucm.fdi.is1.apuestas.usuarios; -/** Envía el enlace para recuperar la contraseña (HU-17). Interfaz para poder cambiarla en las pruebas. */ +/** + * Envía el enlace para recuperar la contraseña (HU-17). Interfaz para poder cambiarla en las pruebas. + * La implementación de la aplicación es {@link CorreoRecuperacion}. + */ public interface EnvioCorreo { + /** @param enlace dirección completa, con el código, para crear la contraseña nueva */ void enviarRecuperacion(String email, String nombre, String enlace); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/IntentosLogin.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/IntentosLogin.java index 00243af..9203369 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/IntentosLogin.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/IntentosLogin.java @@ -15,6 +15,8 @@ /** * Freno a quien prueba contraseñas: tras {@value #MAXIMO_FALLOS} intentos fallidos seguidos con un email, * ese email no puede entrar durante 15 minutos (aunque acierte). Se guarda en memoria: al reiniciar se olvida. + * Escucha los eventos de inicio de sesión que publica Spring Security, y {@link UsuarioDetailsService} + * le pregunta si la cuenta está bloqueada (HU-12). */ @Component public class IntentosLogin { @@ -22,9 +24,11 @@ public class IntentosLogin { static final int MAXIMO_FALLOS = 5; static final Duration BLOQUEO = Duration.ofMinutes(15); + /** Fallos seguidos de un email y momento del último. */ private record Fallos(int seguidos, Instant ultimo) { } + // Concurrente: pueden llegar varios inicios de sesión a la vez private final Map fallos = new ConcurrentHashMap<>(); private final Clock reloj; @@ -32,13 +36,20 @@ public IntentosLogin(Clock reloj) { this.reloj = reloj; } + /** + * Email o contraseña incorrectos: suma un fallo. Si el anterior fue hace más de 15 minutos, la cuenta + * empieza de nuevo. Con la cuenta ya bloqueada Spring publica otro evento, así que esos intentos + * no alargan el bloqueo. + */ @EventListener public void alFallar(AuthenticationFailureBadCredentialsEvent evento) { Instant ahora = reloj.instant(); + // merge: si no había fallos guarda el nuevo; si los había, los combina con la lambda fallos.merge(clave(evento.getAuthentication().getName()), new Fallos(1, ahora), (antes, nuevo) -> caducado(antes, ahora) ? nuevo : new Fallos(antes.seguidos() + 1, ahora)); } + /** Un inicio de sesión correcto borra los fallos acumulados. */ @EventListener public void alEntrar(AuthenticationSuccessEvent evento) { fallos.remove(clave(evento.getAuthentication().getName())); @@ -49,15 +60,18 @@ public void olvidar(String email) { fallos.remove(clave(email)); } + /** Lleva {@value #MAXIMO_FALLOS} fallos o más y el último fue hace menos de 15 minutos. */ public boolean bloqueado(String email) { Fallos f = fallos.get(clave(email)); return f != null && f.seguidos() >= MAXIMO_FALLOS && !caducado(f, reloj.instant()); } + /** Han pasado más de {@link #BLOQUEO} desde el último fallo. */ private static boolean caducado(Fallos f, Instant ahora) { return f.ultimo().plus(BLOQUEO).isBefore(ahora); } + /** El email normalizado, para que "Ana@x.es" y "ana@x.es" cuenten juntos. */ private static String clave(String email) { return email == null ? "" : email.trim().toLowerCase(Locale.ROOT); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/LimitesPersonalesForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/LimitesPersonalesForm.java index d28b0ef..998b3bc 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/LimitesPersonalesForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/LimitesPersonalesForm.java @@ -5,7 +5,10 @@ import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Digits; -/** Límites que se pone el propio usuario (HU-10). Vacío = sin límite. */ +/** + * Formulario de los límites que se pone el propio usuario en Mi cuenta (HU-10). Vacío = sin límite. + * Que el diario no supere al semanal lo comprueba {@link UsuarioService#fijarLimites}. + */ public class LimitesPersonalesForm { @DecimalMin(value = "1", message = "Como mínimo 1 moneda") diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NombreForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NombreForm.java index 537cfb6..77738ca 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NombreForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NombreForm.java @@ -3,7 +3,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -/** Cambio del nombre visible (HU-47). Mismas reglas que en el registro. */ +/** Formulario de cambio del nombre visible en Mi cuenta (HU-47). Mismas reglas que en el registro. */ public class NombreForm { @NotBlank(message = "Introduce tu nombre") diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NuevaPasswordForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NuevaPasswordForm.java index c2cf146..667ce97 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NuevaPasswordForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/NuevaPasswordForm.java @@ -6,12 +6,14 @@ /** Contraseña nueva desde el enlace de recuperación (HU-17). Mismas reglas que en el registro. */ public class NuevaPasswordForm { + /** Código del enlace del correo; viaja en un campo oculto del formulario. */ private String token; @NotBlank(message = "Introduce la contraseña nueva") @Size(min = 8, max = 72, message = "La contraseña debe tener entre 8 y 72 caracteres") private String nueva; + /** La contraseña nueva repetida; el controlador comprueba que coincide. */ private String confirmacion; public String getToken() { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordForm.java index 57f1f7b..0721ab9 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordForm.java @@ -3,7 +3,10 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -/** Cambio de contraseña (HU-47). Mismas reglas que en el registro. */ +/** + * Formulario de cambio de contraseña en Mi cuenta (HU-47). Mismas reglas que en el registro. + * La actual se comprueba en {@link UsuarioService#cambiarPassword}. + */ public class PasswordForm { @NotBlank(message = "Introduce tu contraseña actual") @@ -13,6 +16,7 @@ public class PasswordForm { @Size(min = 8, max = 72, message = "La contraseña debe tener entre 8 y 72 caracteres") private String nueva; + /** La contraseña nueva repetida; el controlador comprueba que coincide. */ private String confirmacion; public String getActual() { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordIncorrectaException.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordIncorrectaException.java index 2d9c33b..0ed529c 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordIncorrectaException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/PasswordIncorrectaException.java @@ -1,6 +1,9 @@ package es.ucm.fdi.is1.apuestas.usuarios; -/** La contraseña actual no es la correcta (HU-47). */ +/** + * La contraseña actual no es la correcta: al cambiarla (HU-47) o al confirmar la baja de la cuenta (HU-18). + * Los controladores la recogen y muestran un mensaje de error. + */ public class PasswordIncorrectaException extends RuntimeException { public PasswordIncorrectaException() { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecargaAlEntrarHandler.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecargaAlEntrarHandler.java index ba2ee30..a5eaabb 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecargaAlEntrarHandler.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecargaAlEntrarHandler.java @@ -10,7 +10,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -/** Al iniciar sesión se aplica la recarga gratuita pendiente y se vuelve a la página que se pedía. */ +/** + * Al iniciar sesión se aplica la recarga gratuita pendiente y se vuelve a la página que se pedía + * (HU-14, CA-14.2). {@code SeguridadConfig} lo registra como lo que se hace tras un login correcto. + */ @Component public class RecargaAlEntrarHandler extends SavedRequestAwareAuthenticationSuccessHandler { @@ -18,12 +21,14 @@ public class RecargaAlEntrarHandler extends SavedRequestAwareAuthenticationSucce public RecargaAlEntrarHandler(UsuarioService usuarios) { this.usuarios = usuarios; + // Si no se venía de ninguna página protegida, se va al catálogo setDefaultTargetUrl("/eventos"); } @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { + // authentication.getName() es el email con el que ha entrado usuarios.consultarConRecarga(authentication.getName()); super.onAuthenticationSuccess(request, response, authentication); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionController.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionController.java index c9f07c0..cf6a241 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionController.java @@ -12,7 +12,10 @@ import jakarta.validation.Valid; -/** Recuperar la contraseña (HU-17). */ +/** + * Controlador de "He olvidado mi contraseña" (HU-17). Sus rutas son públicas ({@code SeguridadConfig}): + * quien la ha olvidado no puede iniciar sesión. La lógica está en {@link RecuperacionService}. + */ @Controller @RequestMapping("/recuperar") public class RecuperacionController { @@ -25,18 +28,27 @@ public RecuperacionController(RecuperacionService recuperacion, Sesiones sesione this.sesiones = sesiones; } + /** GET /recuperar: formulario para pedir el enlace con el email. */ @GetMapping public String formulario() { return "recuperar"; } - /** Siempre la misma respuesta, exista o no la cuenta. */ + /** + * POST /recuperar: pide el enlace y vuelve a /recuperar?enviado. Siempre la misma respuesta, + * exista o no la cuenta. + */ @PostMapping public String solicitar(@RequestParam(defaultValue = "") String email) { + // La base del enlace es la dirección por la que se ha entrado: vale en local y en la nube recuperacion.solicitar(email, ServletUriComponentsBuilder.fromCurrentContextPath().toUriString()); return "redirect:/recuperar?enviado"; } + /** + * GET /recuperar/nueva?token=...: la página del enlace del correo. Si el enlace ha caducado o ya + * se ha usado, la vista lo avisa en lugar de mostrar el formulario (CA-17.2). + */ @GetMapping("/nueva") public String nueva(@RequestParam(defaultValue = "") String token, Model model) { NuevaPasswordForm form = new NuevaPasswordForm(); @@ -46,6 +58,10 @@ public String nueva(@RequestParam(defaultValue = "") String token, Model model) return "recuperar-nueva"; } + /** + * POST /recuperar/nueva: comprueba que las dos contraseñas coinciden y caben en BCrypt, pone la nueva, + * cierra todas las sesiones abiertas de esa cuenta y lleva a /login?recuperada. + */ @PostMapping("/nueva") public String restablecer(@Valid @ModelAttribute("nuevaPassword") NuevaPasswordForm form, BindingResult errores, Model model) { @@ -58,13 +74,16 @@ public String restablecer(@Valid @ModelAttribute("nuevaPassword") NuevaPasswordF if (!errores.hasErrors()) { try { String email = recuperacion.restablecer(form.getToken(), form.getNueva()); + // null: quien recupera no tiene sesión iniciada, así que se cierran todas sesiones.cerrarOtras(email, null); return "redirect:/login?recuperada"; } catch (EnlaceNoValidoException e) { + // Caducó o se usó mientras se rellenaba el formulario model.addAttribute("enlaceValido", false); return "recuperar-nueva"; } } + // Errores en el formulario: se vuelve a mostrar; el enlace se comprueba otra vez al reenviarlo model.addAttribute("enlaceValido", true); return "recuperar-nueva"; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionService.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionService.java index 901894f..3e80ebc 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RecuperacionService.java @@ -19,12 +19,15 @@ /** * Recuperar la contraseña (HU-17): se pide un enlace con el email y el enlace permite poner una nueva. * La respuesta es la misma exista o no la cuenta, para no desvelar qué emails están registrados. + * El código del enlace es aleatorio y en la base de datos solo se guarda su resumen ({@link TokenRecuperacion}). */ @Service public class RecuperacionService { + /** Tiempo que sirve un enlace desde que se pide (CA-17.2). */ static final Duration VALIDEZ = Duration.ofMinutes(30); + // Generador criptográfico: con Random los códigos se podrían adivinar private static final SecureRandom ALEATORIO = new SecureRandom(); private final UsuarioRepository usuarios; @@ -55,13 +58,16 @@ public void solicitar(String email, String base) { String normalizado = email == null ? "" : email.trim().toLowerCase(Locale.ROOT); Optional cuenta = usuarios.findByEmail(normalizado).filter(u -> !u.isEliminado()); if (cuenta.isEmpty()) { + // Sin cuenta no se envía nada, pero el controlador responde igual que si la hubiera return; } Usuario usuario = cuenta.get(); tokens.borrarDe(usuario); + // 32 bytes aleatorios (256 bits), en Base64 apto para URL: se puede poner tal cual en el enlace byte[] bytes = new byte[32]; ALEATORIO.nextBytes(bytes); String codigo = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + // Se guarda el resumen; el código en claro solo viaja en el correo tokens.save(new TokenRecuperacion(usuario, resumen(codigo), LocalDateTime.now(reloj).plus(VALIDEZ))); correo.enviarRecuperacion(usuario.getEmail(), usuario.getNombre(), base + "/recuperar/nueva?token=" + codigo); } @@ -84,10 +90,12 @@ public String restablecer(String codigo, String nueva) { Usuario usuario = token.getUsuario(); usuario.cambiarPasswordHash(passwordEncoder.encode(nueva)); token.usar(); + // Si estaba bloqueado por fallos de inicio de sesión, ya puede entrar con la nueva intentos.olvidar(usuario.getEmail()); return usuario.getEmail(); } + /** Token que corresponde al código, solo si aún sirve. */ private Optional buscar(String codigo) { if (codigo == null || codigo.isBlank()) { return Optional.empty(); @@ -96,11 +104,17 @@ private Optional buscar(String codigo) { return tokens.findByHash(resumen(codigo)).filter(t -> t.valido(ahora)); } + /** + * Resumen SHA-256 del código, en hexadecimal. Siempre da el mismo resultado para el mismo código, + * y por eso se puede buscar en la base de datos (con BCrypt, que usa sal aleatoria, no se podría). + * Como el código ya es aleatorio y largo, no hace falta un algoritmo lento como BCrypt. + */ static String resumen(String codigo) { try { byte[] hash = MessageDigest.getInstance("SHA-256").digest(codigo.getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(hash); } catch (NoSuchAlgorithmException e) { + // No pasa: todas las versiones de Java incluyen SHA-256 throw new IllegalStateException(e); } } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RegistroForm.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RegistroForm.java index 37c260b..a615be3 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RegistroForm.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/RegistroForm.java @@ -4,6 +4,11 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; +/** + * Formulario de registro (HU-11). Las anotaciones las comprueba {@code @Valid} en + * {@link UsuariosController}; que la confirmación coincida y que la contraseña quepa en BCrypt + * se comprueba allí a mano. + */ public class RegistroForm { @NotBlank(message = "Introduce tu nombre") @@ -16,9 +21,11 @@ public class RegistroForm { private String email; @NotBlank(message = "Introduce una contraseña") + // 72: el máximo de BCrypt (en bytes, ver Contrasenas) @Size(min = 8, max = 72, message = "La contraseña debe tener entre 8 y 72 caracteres") private String password; + /** La contraseña repetida, para evitar erratas. */ private String confirmacion; public String getNombre() { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Rol.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Rol.java index ac066d8..9b4f16b 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Rol.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Rol.java @@ -1,6 +1,12 @@ package es.ucm.fdi.is1.apuestas.usuarios; +/** + * Rol de una cuenta. Spring Security lo recibe como {@code ROLE_USUARIO} o {@code ROLE_CREADOR} + * y {@code SeguridadConfig} decide con él qué páginas puede abrir cada uno. + */ public enum Rol { + /** Jugador: apuesta con sus moneditas. */ USUARIO, + /** Creador de apuestas: además entra en el panel de gestión ({@code /gestion}). */ CREADOR } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoInsuficienteException.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoInsuficienteException.java index 3f1a49d..aedcac5 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoInsuficienteException.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoInsuficienteException.java @@ -2,6 +2,10 @@ import java.math.BigDecimal; +/** + * El saldo no llega para el importe de una apuesta. La lanza {@link Usuario#cargar} y el mensaje + * se muestra al usuario. + */ public class SaldoInsuficienteException extends RuntimeException { public SaldoInsuficienteException(BigDecimal saldo, BigDecimal importe) { diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoProperties.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoProperties.java index eaba388..e71f910 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoProperties.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/SaldoProperties.java @@ -6,7 +6,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; /** - * Configuración de las moneditas (application.properties, prefijo apuestas.saldo). + * Configuración de las moneditas (application.properties, prefijo apuestas.saldo) para el saldo + * de bienvenida y las recargas gratuitas (HU-14). Spring crea este record con esos valores. * * @param bienvenida saldo inicial al registrarse * @param recargaImporte importe de cada recarga gratuita diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Sesiones.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Sesiones.java index 82f5dc8..c298f20 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Sesiones.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Sesiones.java @@ -9,6 +9,8 @@ /** * Cierra las sesiones de un usuario en otros navegadores al eliminar la cuenta o cambiar la contraseña: * si no, seguían abiertas (y después de una baja solo daban errores). + * Se usa en la baja (HU-18), el cambio de contraseña (HU-47) y la recuperación (HU-17). + * El registro de sesiones lo configura {@code SeguridadConfig}. */ @Component public class Sesiones { @@ -22,10 +24,12 @@ public Sesiones(SessionRegistry registro) { /** Todas las del usuario menos {@code actual} (null = todas). */ public void cerrarOtras(String email, HttpSession actual) { String id = actual == null ? null : actual.getId(); + // Cada principal es un usuario con sesión; false = sin contar las que ya han caducado registro.getAllPrincipals().stream() .filter(p -> p instanceof UserDetails u && u.getUsername().equalsIgnoreCase(email)) .flatMap(p -> registro.getAllSessions(p, false).stream()) .filter(s -> !s.getSessionId().equals(id)) + // expireNow la marca como caducada: en su siguiente petición Spring la cierra y lleva a /login?expirada .forEach(s -> s.expireNow()); } } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacion.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacion.java index c87e020..6751626 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacion.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacion.java @@ -11,6 +11,7 @@ /** * Enlace para crear una contraseña nueva (HU-17). Solo se guarda el resumen SHA-256 del código del enlace: * quien lea la base de datos no puede usarlo. Caduca y sirve una sola vez. + * Entidad JPA; la crea y la consulta {@link RecuperacionService}. */ @Entity public class TokenRecuperacion { @@ -22,9 +23,11 @@ public class TokenRecuperacion { @ManyToOne(optional = false) private Usuario usuario; + /** Resumen SHA-256 del código, en hexadecimal (64 caracteres). */ @Column(nullable = false, unique = true, length = 64) private String hash; + /** Momento hasta el que sirve ({@link RecuperacionService#VALIDEZ} desde que se pidió). */ @Column(nullable = false) private LocalDateTime caduca; @@ -41,10 +44,12 @@ public TokenRecuperacion(Usuario usuario, String hash, LocalDateTime caduca) { this.caduca = caduca; } + /** Sirve si no se ha usado, no ha caducado y la cuenta no se ha eliminado (CA-17.2). */ public boolean valido(LocalDateTime ahora) { return !usado && ahora.isBefore(caduca) && !usuario.isEliminado(); } + /** Gasta el enlace: ya no vale para otra vez. */ public void usar() { usado = true; } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacionRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacionRepository.java index 3bd15d8..4d9fe25 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacionRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/TokenRecuperacionRepository.java @@ -6,11 +6,16 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +/** Repositorio Spring Data de los enlaces para recuperar la contraseña (HU-17). */ public interface TokenRecuperacionRepository extends JpaRepository { + /** Se busca por el resumen SHA-256 del código: el código en claro no está en la base de datos. */ Optional findByHash(String hash); - /** Al pedir un enlace nuevo, los anteriores dejan de servir. */ + /** + * Al pedir un enlace nuevo, los anteriores dejan de servir. Consulta JPQL escrita a mano; + * {@code @Modifying} indica que cambia datos (un delete) en lugar de devolver filas. + */ @Modifying @Query("delete from TokenRecuperacion t where t.usuario = ?1") void borrarDe(Usuario usuario); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Usuario.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Usuario.java index 20b9b81..d3447ee 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Usuario.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/Usuario.java @@ -12,7 +12,13 @@ import jakarta.persistence.Id; import jakarta.persistence.Version; -/** Cuenta de usuario con su saldo de moneditas virtuales (HU-11, HU-13, HU-14). */ +/** + * Entidad JPA: cuenta de usuario con su saldo de moneditas virtuales (HU-11, HU-13, HU-14). + * Sirve tanto para los jugadores como para el creador de apuestas (ver {@link Rol}). + * Aquí están las reglas del saldo (cargar, abonar, recargas gratuitas), los límites del juego + * responsable (HU-10), el cambio de nombre y contraseña (HU-47) y la baja (HU-18). + * {@link UsuarioService} llama a estos métodos dentro de una transacción. + */ @Entity public class Usuario { @@ -30,12 +36,14 @@ public class Usuario { @GeneratedValue private Long id; + /** Se guarda normalizado (sin espacios y en minúsculas) y es único. */ @Column(nullable = false, unique = true) private String email; @Column(nullable = false) private String nombre; + /** Resumen BCrypt de la contraseña. La contraseña en claro no se guarda nunca. */ @Column(nullable = false) private String passwordHash; @@ -43,6 +51,7 @@ public class Usuario { @Enumerated(EnumType.STRING) private Rol rol; + /** Moneditas disponibles. BigDecimal con 2 decimales: sin los errores de redondeo de double. */ @Column(nullable = false, precision = 12, scale = 2) private BigDecimal saldo; @@ -69,6 +78,11 @@ protected Usuario() { // requerido por JPA } + /** + * Cuenta nueva con el saldo de bienvenida (HU-14). La fecha de alta cuenta como la primera recarga. + * + * @param passwordHash la contraseña ya cifrada con BCrypt + */ public Usuario(String email, String nombre, String passwordHash, Rol rol, BigDecimal saldoBienvenida, LocalDateTime fechaAlta) { this.email = email; @@ -80,7 +94,7 @@ public Usuario(String email, String nombre, String passwordHash, Rol rol, } /** - * Si ha pasado el periodo desde la última recarga gratuita, suma el importe al saldo. + * Si ha pasado el periodo desde la última recarga gratuita, suma el importe al saldo (HU-14, CA-14.2). * * @return true si se ha aplicado la recarga */ @@ -89,11 +103,16 @@ public boolean aplicarRecargaPeriodica(LocalDateTime ahora, Duration periodo, Bi return false; } saldo = saldo.add(importe); + // El periodo vuelve a contar desde hoy: si pasa varias semanas sin entrar, recibe una sola recarga ultimaRecarga = ahora; return true; } - /** Descuenta el importe de una apuesta (HU-23). */ + /** + * Descuenta el importe de una apuesta (HU-23). El saldo nunca queda negativo por apostar. + * + * @throws SaldoInsuficienteException si el saldo no llega al importe + */ public void cargar(BigDecimal importe) { if (saldo.compareTo(importe) < 0) { throw new SaldoInsuficienteException(saldo, importe); @@ -104,8 +123,8 @@ public void cargar(BigDecimal importe) { /** * Ajuste por corrección de un resultado: puede restar una ganancia ya pagada. * A diferencia de {@link #cargar}, permite que el saldo quede negativo. + * Una cuenta eliminada no recibe dinero: sus apuestas se resuelven igual, pero sin abonarle nada. */ - /** Una cuenta eliminada no recibe dinero: sus apuestas se resuelven igual, pero sin abonarle nada. */ public void ajustar(BigDecimal diferencia) { if (eliminado) { return; @@ -113,7 +132,7 @@ public void ajustar(BigDecimal diferencia) { saldo = saldo.add(diferencia); } - /** Devuelve o abona un importe al saldo (cancelaciones y ganancias). */ + /** Devuelve o abona un importe al saldo (cancelaciones y ganancias). Si la cuenta está eliminada, nada. */ public void abonar(BigDecimal importe) { if (eliminado) { return; @@ -121,7 +140,7 @@ public void abonar(BigDecimal importe) { saldo = saldo.add(importe); } - /** HU-47. */ + /** Cambia el nombre visible (HU-47). */ public void cambiarNombre(String nuevo) { nombre = nuevo; } @@ -131,6 +150,7 @@ public void cambiarPasswordHash(String nuevoHash) { passwordHash = nuevoHash; } + /** Fecha a partir de la cual toca la siguiente recarga gratuita (HU-14). */ public LocalDateTime proximaRecarga(Duration periodo) { return ultimaRecarga.plus(periodo); } @@ -151,6 +171,7 @@ public void pausarHasta(LocalDateTime hasta) { } } + /** HU-10: si tiene una pausa que aún no ha terminado. */ public boolean enPausa(LocalDateTime ahora) { return pausaHasta != null && pausaHasta.isAfter(ahora); } @@ -158,8 +179,11 @@ public boolean enPausa(LocalDateTime ahora) { /** * HU-18: borra los datos personales y deja la cuenta inutilizable. Se conserva la fila para que * las apuestas ya hechas y su historial sigan cuadrando. + * + * @param hashInutilizable resumen BCrypt de una contraseña aleatoria que nadie conoce */ public void eliminar(String hashInutilizable) { + // El id hace que el email siga siendo único (la columna lo exige) email = "eliminado-" + id + DOMINIO_ELIMINADOS; nombre = "Usuario eliminado"; passwordHash = hashInutilizable; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioDetailsService.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioDetailsService.java index ee067ec..402ed1d 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioDetailsService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioDetailsService.java @@ -6,7 +6,11 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; -/** Spring Security carga los usuarios por email para el inicio de sesión (HU-12). */ +/** + * Spring Security carga los usuarios por email para el inicio de sesión (HU-12). + * Con lo que devuelve, Spring comprueba la contraseña escrita contra el resumen BCrypt guardado; + * nuestro código nunca compara contraseñas en el login. + */ @Service public class UsuarioDetailsService implements UserDetailsService { @@ -18,12 +22,19 @@ public UsuarioDetailsService(UsuarioService usuarios, IntentosLogin intentos) { this.intentos = intentos; } + /** + * Datos de la cuenta para Spring Security: email, resumen de la contraseña, rol y si puede entrar. + * + * @throws UsernameNotFoundException si no hay cuenta; Spring da el mismo error genérico que con + * una contraseña incorrecta (CA-12.2) + */ @Override public UserDetails loadUserByUsername(String email) { return usuarios.buscar(email) .map(u -> User.withUsername(u.getEmail()) .password(u.getPasswordHash()) .roles(u.getRol().name()) + // Cuenta eliminada (HU-18) o con demasiados fallos seguidos: no entra aunque acierte .disabled(u.isEliminado()) .accountLocked(intentos.bloqueado(email)) .build()) diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioRepository.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioRepository.java index b2d0b50..264a8f3 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioRepository.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioRepository.java @@ -5,11 +5,16 @@ import org.springframework.data.jpa.repository.JpaRepository; +/** + * Repositorio Spring Data de {@link Usuario}. Las consultas las genera Spring a partir del nombre + * de cada método. Los emails deben llegar ya normalizados (ver {@link UsuarioService}). + */ public interface UsuarioRepository extends JpaRepository { Optional findByEmail(String email); boolean existsByEmail(String email); + /** Usuarios de un rol, de más a menos saldo y, con el mismo saldo, por nombre (ranking, HU-36). */ List findByRolOrderBySaldoDescNombreAsc(Rol rol); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioService.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioService.java index 6f601c4..62658f4 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioService.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuarioService.java @@ -12,9 +12,18 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * Servicio de las cuentas: registro, saldo con recargas gratuitas, nombre y contraseña, juego responsable + * y anonimización al eliminar la cuenta (HU-10, HU-11, HU-13, HU-14, HU-18, HU-47). + * Las contraseñas se cifran con el {@link PasswordEncoder} de {@code SeguridadConfig}, que es BCrypt. + * Los emails se buscan siempre normalizados, así que da igual cómo los escriba el usuario. + * Los métodos que cambian un usuario no llaman a {@code save}: el usuario lo ha cargado JPA dentro + * de la transacción y sus cambios se guardan solos al terminarla. + */ @Service public class UsuarioService { + /** Duraciones de pausa (en días) que se pueden elegir en Mi cuenta (HU-10). */ static final Set DIAS_DE_PAUSA = Set.of(1, 7, 30); private final UsuarioRepository usuarios; @@ -36,6 +45,12 @@ public Usuario registrar(RegistroForm form) { return crear(form.getEmail(), form.getNombre(), form.getPassword(), Rol.USUARIO); } + /** + * Crea una cuenta con el rol indicado y el saldo de bienvenida. También la usan los datos iniciales + * para el creador de apuestas y el usuario de prueba. + * + * @throws EmailYaRegistradoException si ya hay una cuenta con ese email o usa el dominio reservado + */ @Transactional public Usuario crear(String email, String nombre, String password, Rol rol) { String emailNormalizado = normalizar(email); @@ -43,12 +58,16 @@ public Usuario crear(String email, String nombre, String password, Rol rol) { if (usuarios.existsByEmail(emailNormalizado) || emailNormalizado.endsWith(Usuario.DOMINIO_ELIMINADOS)) { throw new EmailYaRegistradoException(emailNormalizado); } + // encode aplica BCrypt con una sal aleatoria: se guarda el resumen, nunca la contraseña Usuario usuario = new Usuario(emailNormalizado, nombre.trim(), passwordEncoder.encode(password), rol, saldo.bienvenida(), LocalDateTime.now(reloj)); return usuarios.save(usuario); } - /** Devuelve el usuario aplicando antes la recarga gratuita si le corresponde (HU-13, HU-14). */ + /** + * Devuelve el usuario aplicando antes la recarga gratuita si le corresponde (HU-13, HU-14). + * Se llama al iniciar sesión y al abrir Mi cuenta. + */ @Transactional public Usuario consultarConRecarga(String email) { Usuario usuario = usuarios.findByEmail(normalizar(email)).orElseThrow(); @@ -62,10 +81,15 @@ public void cambiarNombre(String email, String nombre) { usuarios.findByEmail(normalizar(email)).orElseThrow().cambiarNombre(nombre.trim()); } - /** Cambia la contraseña si la actual es correcta (HU-47). */ + /** + * Cambia la contraseña si la actual es correcta (HU-47). + * + * @throws PasswordIncorrectaException si la contraseña actual no coincide (CA-47.2) + */ @Transactional public void cambiarPassword(String email, String actual, String nueva) { Usuario usuario = usuarios.findByEmail(normalizar(email)).orElseThrow(); + // BCrypt no se puede descifrar: matches cifra la contraseña escrita con la sal del resumen y los compara if (!passwordEncoder.matches(actual, usuario.getPasswordHash())) { throw new PasswordIncorrectaException(); } @@ -75,12 +99,17 @@ public void cambiarPassword(String email, String actual, String nueva) { /** Pone esta contraseña si no es ya la actual (contraseña del creador en los perfiles públicos). */ @Transactional public void fijarPassword(String email, String password) { + // Se comprueba antes: con su sal aleatoria, BCrypt daría un resumen nuevo en cada arranque usuarios.findByEmail(normalizar(email)) .filter(u -> !passwordEncoder.matches(password, u.getPasswordHash())) .ifPresent(u -> u.cambiarPasswordHash(passwordEncoder.encode(password))); } - /** Límites diario y semanal que se pone el usuario; null quita el límite (HU-10). */ + /** + * Límites diario y semanal que se pone el usuario; null quita el límite (HU-10). + * + * @throws IllegalArgumentException si el límite diario es mayor que el semanal + */ @Transactional public void fijarLimites(String email, BigDecimal diario, BigDecimal semanal) { if (diario != null && semanal != null && diario.compareTo(semanal) > 0) { @@ -89,7 +118,12 @@ public void fijarLimites(String email, BigDecimal diario, BigDecimal semanal) { usuarios.findByEmail(normalizar(email)).orElseThrow().fijarLimites(diario, semanal); } - /** Pausa las apuestas durante unos días; no se puede deshacer antes de tiempo (HU-10). */ + /** + * Pausa las apuestas durante unos días; no se puede deshacer antes de tiempo (HU-10). + * + * @return cuándo termina la pausa (si ya había una más larga, la de esa) + * @throws IllegalArgumentException si los días no son 1, 7 o 30 + */ @Transactional public LocalDateTime pausar(String email, int dias) { if (!DIAS_DE_PAUSA.contains(dias)) { @@ -100,7 +134,10 @@ public LocalDateTime pausar(String email, int dias) { return usuario.getPausaHasta(); } - /** Comprueba la contraseña del usuario, por ejemplo antes de eliminar la cuenta (HU-18). */ + /** + * Comprueba la contraseña del usuario, por ejemplo antes de eliminar la cuenta (HU-18). + * Devuelve false si la contraseña es null o no existe la cuenta. + */ @Transactional(readOnly = true) public boolean passwordCorrecta(String email, String password) { return password != null && usuarios.findByEmail(normalizar(email)) @@ -111,14 +148,17 @@ public boolean passwordCorrecta(String email, String password) { /** Anonimiza la cuenta: los datos personales desaparecen y ya no se puede entrar con ella (HU-18). */ @Transactional public void anonimizar(Usuario usuario) { + // La contraseña pasa a ser un UUID aleatorio que no se guarda en ningún sitio: nadie la conoce usuario.eliminar(passwordEncoder.encode(UUID.randomUUID().toString())); } + /** Busca la cuenta por email, sin aplicar la recarga. */ @Transactional(readOnly = true) public Optional buscar(String email) { return usuarios.findByEmail(normalizar(email)); } + /** Sin espacios alrededor y en minúsculas: " Ana@Mail.es" y "ana@mail.es" son la misma cuenta. */ static String normalizar(String email) { return email.trim().toLowerCase(Locale.ROOT); } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuariosController.java b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuariosController.java index dc6dd49..63ffd71 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuariosController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/usuarios/UsuariosController.java @@ -17,6 +17,12 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +/** + * Controlador de las cuentas: inicio de sesión, registro y Mi cuenta (saldo y recargas, juego responsable, + * nombre y contraseña). HU-10, HU-11, HU-12, HU-13, HU-14 y HU-47. La baja (HU-18) está en + * {@code BajaController}. La lógica está en {@link UsuarioService}. + * Cuando un formulario tiene errores no se redirige: se vuelve a pintar la página para que se vean los mensajes. + */ @Controller public class UsuariosController { @@ -32,11 +38,16 @@ public UsuariosController(UsuarioService usuarios, SaldoProperties saldo, Clock this.sesiones = sesiones; } + /** + * GET /login: formulario de inicio de sesión (HU-12). El envío (POST /login) no llega aquí: + * lo procesa Spring Security, configurado en {@code SeguridadConfig}. + */ @GetMapping("/login") public String login() { return "login"; } + /** GET /registro: formulario de registro, con el saldo de bienvenida que se recibirá (HU-11, HU-14). */ @GetMapping("/registro") public String registro(Model model) { model.addAttribute("registro", new RegistroForm()); @@ -44,6 +55,11 @@ public String registro(Model model) { return "registro"; } + /** + * POST /registro: crea la cuenta y lleva a /login?registrado (CA-11.1). Además de las reglas de + * {@link RegistroForm}, comprueba que las contraseñas coinciden y caben en BCrypt. + * Si el email ya está registrado, lo marca como error del campo (CA-11.2). + */ @PostMapping("/registro") public String registrar(@Valid @ModelAttribute("registro") RegistroForm form, BindingResult errores, Model model) { if (form.getPassword() != null && !form.getPassword().equals(form.getConfirmacion())) { @@ -64,12 +80,16 @@ public String registrar(@Valid @ModelAttribute("registro") RegistroForm form, Bi return "registro"; } + /** GET /cuenta: Mi cuenta. Antes aplica la recarga gratuita si toca, así el saldo está al día (HU-13, HU-14). */ @GetMapping("/cuenta") public String cuenta(Principal principal, Model model) { return cuenta(usuarios.consultarConRecarga(principal.getName()), model); } - /** Límites diario y semanal que se pone el usuario (HU-10). */ + /** + * POST /cuenta/limites: límites diario y semanal que se pone el usuario (HU-10). Vuelve a la sección + * de juego responsable de Mi cuenta. Si el diario supera al semanal, lo muestra como error del campo. + */ @PostMapping("/cuenta/limites") public String limites(@Valid @ModelAttribute("limitesPersonales") LimitesPersonalesForm form, BindingResult errores, Principal principal, Model model, RedirectAttributes redireccion) { @@ -85,10 +105,11 @@ public String limites(@Valid @ModelAttribute("limitesPersonales") LimitesPersona return cuenta(usuarios.consultarConRecarga(principal.getName()), model); } - /** Pausa temporal de las apuestas (HU-10). */ + /** POST /cuenta/pausa: pausa temporal de las apuestas (HU-10); los días deben ser 1, 7 o 30. */ @PostMapping("/cuenta/pausa") public String pausa(@RequestParam(defaultValue = "0") int dias, Principal principal, RedirectAttributes redireccion) { + // Sin elegir duración llega 0, y el servicio lo rechaza con un mensaje try { LocalDateTime hasta = usuarios.pausar(principal.getName(), dias); redireccion.addFlashAttribute("mensaje", "Apuestas en pausa hasta el " @@ -99,7 +120,7 @@ public String pausa(@RequestParam(defaultValue = "0") int dias, Principal princi return "redirect:/cuenta#juego-responsable"; } - /** HU-47. */ + /** POST /cuenta/nombre: cambia el nombre visible (HU-47). */ @PostMapping("/cuenta/nombre") public String cambiarNombre(@Valid @ModelAttribute("datosNombre") NombreForm form, BindingResult errores, Principal principal, Model model, RedirectAttributes redireccion) { @@ -111,7 +132,10 @@ public String cambiarNombre(@Valid @ModelAttribute("datosNombre") NombreForm for return "redirect:/cuenta"; } - /** HU-47. */ + /** + * POST /cuenta/password: cambia la contraseña si la actual es correcta (HU-47) y cierra las sesiones + * de la cuenta en otros navegadores; la de este sigue abierta. + */ @PostMapping("/cuenta/password") public String cambiarPassword(@Valid @ModelAttribute("datosPassword") PasswordForm form, BindingResult errores, Principal principal, Model model, RedirectAttributes redireccion, @@ -125,6 +149,7 @@ public String cambiarPassword(@Valid @ModelAttribute("datosPassword") PasswordFo if (!errores.hasErrors()) { try { usuarios.cambiarPassword(principal.getName(), form.getActual(), form.getNueva()); + // getSession(false): la sesión actual, sin crear una nueva sesiones.cerrarOtras(principal.getName(), peticion.getSession(false)); redireccion.addFlashAttribute("mensaje", "Contraseña cambiada. Úsala la próxima vez que entres."); return "redirect:/cuenta"; @@ -153,6 +178,7 @@ private String cuenta(Usuario usuario, Model model) { } model.addAttribute("enPausa", usuario.enPausa(LocalDateTime.now(reloj))); model.addAttribute("usuario", usuario); + // Cuándo llega la próxima recarga gratuita y de cuánto es (HU-14) model.addAttribute("proximaRecarga", usuario.proximaRecarga(saldo.recargaPeriodo())); model.addAttribute("importeRecarga", saldo.recargaImporte()); return "cuenta"; diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/Colores.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/Colores.java index 4501e35..94004db 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/web/Colores.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/Colores.java @@ -5,6 +5,7 @@ /** * Colores de cada club para dibujar su escudo cuando no hay imagen: {fondo, borde}. * Los equipos que no aparecen reciben un color calculado a partir del nombre. + * Solo la usa {@link Formato#colores}; la clave es el nombre exacto del equipo. */ final class Colores { @@ -108,6 +109,7 @@ final class Colores { Map.entry("Pedro Acosta", new String[] {"#ff6600", "#000000"}), Map.entry("Marco Bezzecchi", new String[] {"#008c45", "#000000"})); + // Clase de constantes: no se crean objetos de ella private Colores() { } } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/ConcurrenciaAdvice.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/ConcurrenciaAdvice.java index 96761d1..97bd8f4 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/web/ConcurrenciaAdvice.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/ConcurrenciaAdvice.java @@ -9,15 +9,19 @@ /** * Dos operaciones a la vez sobre el mismo saldo (doble clic, dos pestañas…): la segunda se rechaza * y se pide repetirla, en lugar de mostrar un error 500. + * {@code @ControllerAdvice}: se aplica a todos los controladores. La excepción la lanza JPA cuando falla + * el control de versión ({@code @Version}) de {@code Usuario}: otra operación cambió el saldo antes. */ @ControllerAdvice public class ConcurrenciaAdvice { + /** Vuelve a la página de la que venía (Referer) con el aviso; si no se sabe, a Mis apuestas (/apuestas). */ @ExceptionHandler(ObjectOptimisticLockingFailureException.class) public String aLaVez(@RequestHeader(value = "Referer", required = false) String referer, RedirectAttributes redireccion) { redireccion.addFlashAttribute("error", "Se ha hecho otra operación con tu saldo al mismo tiempo. Comprueba tus apuestas e inténtalo de nuevo."); + // RutaSegura: el Referer lo manda el navegador y no hay que fiarse de él return "redirect:" + RutaSegura.de(referer, "/apuestas"); } } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/Formato.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/Formato.java index 06f694e..709a3bf 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/web/Formato.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/Formato.java @@ -10,7 +10,9 @@ import org.springframework.stereotype.Component; /** - * Utilidades de presentación para las plantillas: {@code ${@formato.fecha(evento.fechaHora)}}. + * Utilidades de presentación para las plantillas Thymeleaf. Es un bean de Spring llamado "formato", + * y las vistas lo llaman así: {@code ${@formato.dia(evento.fechaHora)}}. + * Fechas en español, cuotas con coma decimal y el escudo dibujado de los equipos sin imagen. */ @Component("formato") public class Formato { @@ -18,23 +20,26 @@ public class Formato { private static final Locale ES = Locale.forLanguageTag("es-ES"); private static final DateTimeFormatter DIA = DateTimeFormatter.ofPattern("EEE d MMM", ES); private static final DateTimeFormatter HORA = DateTimeFormatter.ofPattern("HH:mm", ES); + /** Palabras que no cuentan para las iniciales del escudo (siglas del tipo de club, artículos…). */ private static final Set PALABRAS_IGNORADAS = Set.of("fc", "cf", "cd", "ca", "rc", "rcd", "ud", "sd", "de", "del", "la", "el", "as", "fk", "vfb", "rb", "club", "real"); /** "Sáb 10 oct". */ public String dia(LocalDateTime fecha) { + // Se quitan los puntos de las abreviaturas y se pone en mayúscula la primera letra String texto = DIA.format(fecha).replace(".", ""); return texto.substring(0, 1).toUpperCase(ES) + texto.substring(1); } /** Identificador para enlaces dentro de la página: "Champions League" → "champions-league". */ public String ancla(String texto) { + // NFD separa cada letra de su tilde, y \p{M} quita las tildes sueltas: "Fórmula" → "Formula" String sinTildes = java.text.Normalizer.normalize(texto, java.text.Normalizer.Form.NFD) .replaceAll("\\p{M}", ""); return sinTildes.toLowerCase(ES).replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", ""); } - /** Cuota con coma decimal: 1.85 → "1,85". */ + /** Cuota con coma decimal: 1.85 → "1,85". Siempre dos decimales, cortando sin redondear; "–" si no hay. */ public String cuota(java.math.BigDecimal cuota) { return cuota == null ? "–" : cuota.setScale(2, java.math.RoundingMode.DOWN).toPlainString().replace('.', ','); } @@ -46,6 +51,7 @@ public String cuota(java.math.BigDecimal cuota) { public String apellido(String nombre) { String[] partes = nombre.trim().split("\\s+"); int inicio = partes.length - 1; + // Se retrocede mientras la palabra anterior empiece por minúscula ("de", "van"…), sin llegar a la primera while (inicio > 1 && Character.isLowerCase(partes[inicio - 1].charAt(0))) { inicio--; } @@ -57,20 +63,27 @@ public String hora(LocalDateTime fecha) { return HORA.format(fecha); } - /** Iniciales para el escudo genérico: "Real Madrid" → "RM", "FC Barcelona" → "B". */ + /** + * Iniciales para el escudo genérico, como mucho dos: "Racing de Santander" → "RS", "FC Barcelona" → "B", + * "Real Madrid" → "M" (se saltan las palabras de PALABRAS_IGNORADAS). + */ public String iniciales(String nombre) { String[] palabras = nombre.split("[\\s/-]+"); String significativas = Arrays.stream(palabras) .filter(p -> !PALABRAS_IGNORADAS.contains(p.toLowerCase(ES))) .map(p -> p.substring(0, 1).toUpperCase(ES)) .collect(Collectors.joining()); + // Si todas las palabras se ignoran, la inicial de la primera if (significativas.isEmpty()) { significativas = palabras[0].substring(0, 1).toUpperCase(ES); } return significativas.length() > 2 ? significativas.substring(0, 2) : significativas; } - /** Color estable para el escudo de cada equipo, calculado a partir del nombre. */ + /** + * Color estable para el escudo de cada equipo, calculado a partir del nombre: el mismo nombre + * da siempre el mismo tono (de 0 a 359 en la rueda de color). + */ public String color(String nombre) { int tono = Math.floorMod(nombre.hashCode(), 360); return "hsl(" + tono + " 55% 38%)"; @@ -89,6 +102,7 @@ public String[] colores(String nombre) { return new String[] {club[0], club[1], texto}; } + /** Si un color "#rrggbb" es claro, según su luminosidad percibida (el verde pesa más que el azul). */ private static boolean esClaro(String hex) { int r = Integer.parseInt(hex.substring(1, 3), 16); int g = Integer.parseInt(hex.substring(3, 5), 16); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/InicioController.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/InicioController.java index ea7f747..d2399dd 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/web/InicioController.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/InicioController.java @@ -14,6 +14,10 @@ import es.ucm.fdi.is1.apuestas.eventos.FichaEquipoService; import es.ucm.fdi.is1.apuestas.usuarios.SaldoProperties; +/** + * Controlador de las páginas públicas generales: la portada y el listado y la ficha de los equipos. + * Se ven sin haber iniciado sesión (HU-08; las rutas públicas están en {@code SeguridadConfig}). + */ @Controller public class InicioController { @@ -30,6 +34,10 @@ public InicioController(EquiposService equipos, CatalogoService catalogo, FichaE this.saldo = saldo; } + /** + * GET /: portada con los 6 próximos partidos, el partido destacado, los eventos disponibles de cada deporte + * y el saldo de bienvenida que se recibe al registrarse (HU-08, HU-14). + */ @GetMapping("/") public String inicio(Model model) { model.addAttribute("proximos", catalogo.proximos(6)); @@ -39,6 +47,10 @@ public String inicio(Model model) { return "index"; } + /** + * GET /equipos: equipos y deportistas agrupados por deporte. Filtros opcionales: deporte, competición + * y texto {@code q} en el nombre. + */ @GetMapping("/equipos") public String equipos(@RequestParam(required = false) Deporte deporte, @RequestParam(required = false) Long competicion, @@ -47,12 +59,13 @@ public String equipos(@RequestParam(required = false) Deporte deporte, model.addAttribute("recuento", equipos.recuentoPorDeporte()); model.addAttribute("deporteElegido", deporte); model.addAttribute("competicionElegida", competicion); + // El desplegable de competiciones solo aparece con un deporte elegido model.addAttribute("competiciones", deporte == null ? List.of() : equipos.competiciones(deporte)); model.addAttribute("q", q == null ? "" : q.trim()); return "equipos"; } - /** Ficha pública de un equipo o deportista (HU-31). */ + /** GET /equipos/{id}: ficha pública de un equipo o deportista (HU-31). */ @GetMapping("/equipos/{id}") public String ficha(@PathVariable Long id, Model model) { model.addAttribute("ficha", fichas.ficha(id)); diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/RutaSegura.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/RutaSegura.java index 9b05282..cc31a9d 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/web/RutaSegura.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/RutaSegura.java @@ -2,24 +2,37 @@ import java.net.URI; -/** Ruta de esta misma web a la que volver (por ejemplo, la del Referer); si no es segura, la indicada. */ +/** + * Ruta de esta misma web a la que volver (por ejemplo, la del Referer); si no es segura, la indicada. + * Evita las redirecciones abiertas: un enlace preparado no puede mandar al usuario a otra web. + */ public final class RutaSegura { + // Clase de utilidades: no se crean objetos de ella private RutaSegura() { } + /** + * Ruta y parámetros de {@code referer}, sin el servidor; {@code porDefecto} si falta o no vale. + * + * @param referer dirección a la que volver (puede ser null) + * @param porDefecto ruta interna que se usa si la anterior no sirve + */ public static String de(String referer, String porDefecto) { if (referer == null) { return porDefecto; } try { URI uri = URI.create(referer); + // Solo se queda la ruta: aunque el Referer sea de otro servidor, se vuelve a esta web String ruta = uri.getRawPath(); + // "//otra-web.com" el navegador lo entendería como otro servidor; a /login no tiene sentido volver if (ruta == null || !ruta.startsWith("/") || ruta.startsWith("//") || ruta.startsWith("/login")) { return porDefecto; } return uri.getRawQuery() == null ? ruta : ruta + "?" + uri.getRawQuery(); } catch (IllegalArgumentException e) { + // No es una dirección bien formada return porDefecto; } } diff --git a/src/main/java/es/ucm/fdi/is1/apuestas/web/UsuarioActualAdvice.java b/src/main/java/es/ucm/fdi/is1/apuestas/web/UsuarioActualAdvice.java index 42fa5bb..f4ff81c 100644 --- a/src/main/java/es/ucm/fdi/is1/apuestas/web/UsuarioActualAdvice.java +++ b/src/main/java/es/ucm/fdi/is1/apuestas/web/UsuarioActualAdvice.java @@ -16,11 +16,17 @@ import es.ucm.fdi.is1.apuestas.usuarios.UsuarioService; import jakarta.servlet.http.HttpServletRequest; -/** Datos comunes a todas las vistas: usuario con sesión iniciada (cabecera, saldo) y su boleto. */ +/** + * Datos comunes a todas las vistas: usuario con sesión iniciada (cabecera, saldo) y su boleto. + * {@code @ControllerAdvice}: cada método {@code @ModelAttribute} se ejecuta antes de cualquier controlador + * y deja su resultado en el modelo con ese nombre, así las plantillas lo tienen siempre a mano. + * Para los visitantes ({@code principal} null) se devuelven valores vacíos. + */ @ControllerAdvice public class UsuarioActualAdvice { private final UsuarioService usuarios; + // Boleto es de sesión: Spring inyecta un intermediario que lleva al boleto de la sesión de cada petición private final Boleto boleto; private final BoletoService boletos; private final NotificacionService notificaciones; @@ -61,7 +67,7 @@ public LocalDateTime pausaHasta(Principal principal) { .orElse(null); } - /** Número de avisos sin leer, para la campana de la cabecera (HU-37). */ + /** Número de avisos sin leer, para la campana de la cabecera (HU-37). 0 para los visitantes. */ @ModelAttribute("avisosSinLeer") public Long avisosSinLeer(Principal principal) { return principal == null ? 0L : notificaciones.sinLeer(principal.getName()); @@ -85,6 +91,10 @@ public BoletoVista boletoResumen(Principal principal) { return boletos.resumen(boleto); } + /** + * Usuario con sesión iniciada, para la cabecera (nombre, saldo); null para los visitantes. + * No aplica la recarga gratuita: eso se hace al entrar y en Mi cuenta. + */ @ModelAttribute("usuarioActual") public Usuario usuarioActual(Principal principal) { if (principal == null) {