Apariencia
0058. Cycle gana seasonYear/seasonNumber: temporada por orden cronológico, no por conteo al crear
Estado
Aceptada
Contexto
Las filas "Año" y "Ciclo" del Excel de producción de Winston (ver backlog → "WL Aqua Intelligence v4 — brecha vs. el doc del socio", filas 4-5 de la hoja Consolidado) no tenían equivalente en el modelo: el admin derivaba "Año" client-side de sowingDate.slice(0, 4) y el select "Ciclo" del dashboard filtraba en realidad por cycleStatus (activa/liberada/cerrada), no por número de corrida — un workaround admitido desde que se implementó el dashboard (09-dashboard-produccion-flow.md, sección de filtros).
Winston confirmó tres reglas el 2026-08-04 (respuestas 1 y 2 del backlog):
- El número de ciclo se cuenta POR LAGUNA, no por finca: "esta laguna va por su 2ª siembra del año".
- Los ciclos duran de 2 a 4 meses y se definen caso a caso — el número no se puede deducir del calendario (no existe "ciclo 2 = mayo-agosto").
- Un ciclo pertenece al año en que se sembró: una siembra de noviembre cosechada en febrero cuenta en el año anterior.
- El conteo arranca cargando el historial del año: no hace falta un campo de offset ni indicar a mano en qué número va cada laguna — se cargan los ciclos ya cerrados y el contador sale solo.
La regla 4 es la que obligó a decisiones de diseño no triviales: POST /cycles exige hoy que la piscina esté libre (409 si tiene un ciclo activa), así que no hay forma de insertar un ciclo cerrado anterior en una piscina que ya tiene su ciclo activo cargado — que es exactamente el caso de las 47 lagunas de Winston.
Decisión
Modelo: campos persistidos, no derivados en cada request
seasonYear Int y seasonNumber Int en Cycle, más endDate DateTime? (no existía: hacía falta saber hasta cuándo ocupó la piscina cada ciclo para la siguiente decisión). Se descartó la alternativa de calcularlos al vuelo en cada respuesta (GROUP BY sobre Cycle por poolId/año) por dos razones: el filtro GET /cycles?seasonYear=&seasonNumber= necesita poder indexarse, y la regla de "orden cronológico" solo es barata de mantener si se persiste y se recalcula en escritura, no en cada lectura.
seasonYear = year(startDate)— determinista, sin caso especial (regla 3).seasonNumber= posición cronológica del ciclo dentro de(poolId, seasonYear), no "cantidad de ciclos previos + 1" al momento de crear. La diferencia importa: con "previos + 1", cargar un histórico después del activo lo dejaría con el número más alto en vez de renumerar el conjunto. Con "posición cronológica", toda alta, edición de fecha o borrado que cambie el conjunto de una(poolId, seasonYear)dispara una renumeración (renumberSeasonenciclos/season.ts, función pura y testeada aparte) que reordena todo el grupo. Es lo que hace que cargar el historial hacia atrás "simplemente funcione": el activo pasa de1a2solo, sin tocarlo.- Un ciclo nacido de una transferencia (precriadero → engorde) hereda
seasonYear/seasonNumberde su ciclo madre en vez de contar como una siembra propia de la piscina de engorde: es la continuación de la misma corrida, no una corrida nueva. Por eso queda excluido de la renumeración de su propia piscina.
Historial: relajar assertPoolFree solo para ciclos que nacen cerrados
Tres opciones evaluadas para cargar el historial de un año en una piscina ya ocupada:
- (A, elegida)
POST /cyclesaceptastatus: 'cerrada'+endDateopcionales. Si vienen, se saltaassertPoolFree(que exige piscina libre) y en su lugar se validaassertNoOverlap: el rango[startDate, endDate]no puede solaparse con ningún otro ciclo de la piscina. El invariante "una piscina, a lo sumo un cicloactiva" no se toca — se relaja únicamente para ciclos que nacencerrada. - (B) Endpoint
POST /cycles/importaparte, con permiso y pantalla propios para carga masiva. Descartada por ahora: mucha más superficie nueva (permiso, DTO, UI de importación, docs) para el mismo resultado; nada impide agregarlo después si la carga masiva de las ~47 lagunas resulta tediosa endpoint-por-endpoint. - (C) No tocar
POST /cycles; dejarseasonNumbereditable a mano como escape y que el usuario corrija el número de las piscinas que van por su 2ª/3ª corrida. Descartada: contradice la regla 4 de Winston (arrancar cargando el historial) y el valor manual se perdería en la primera renumeración automática — por eso tampoco existe un campo editable de escape: la fuente de verdad es el orden cronológico, no un override manual.
endDate también se completa solo cuando el ciclo se cierra por el camino normal (cosecha final ejecutada, o transferencia completa que libera a la madre), para que assertNoOverlap tenga con qué comparar sin pedirle al usuario una fecha en esos casos.
Concurrencia: un advisory lock por piscina
acquirePoolCycleLock (mismo patrón que los locks de "último administrador" del ADR-0053) serializa, dentro de la transacción, la validación (assertPoolFree/assertNoOverlap) + la renumeración de una piscina. Sin el lock, dos altas concurrentes en la misma piscina podrían pasar ambas la validación de solapamiento antes de que la otra confirme, o calcular la misma numeración a partir de una lectura stale del grupo.
Edición de fecha: recalcular siempre, sin escape manual
PATCH /cycles/:id recalcula seasonYear/seasonNumber cuando cambia date (salvo que el body los mande explícitos): si el ciclo cruza de año, se renumeran las dos temporadas afectadas (la vieja pierde un miembro, la nueva gana uno). Se prefirió recalcular siempre — en vez de dejarlo fijo tras la asignación inicial — porque la regla de negocio es "el año de la siembra", y permitir que quede desincronizado de una fecha editada habría reintroducido el mismo tipo de dato-derivado-a-mano que esta tarea vino a eliminar.
Admin: separar "Estado" de "N° Ciclo" en los filtros del dashboard
El select "Ciclo" de FiltersBar (dashboard de producción) filtraba cycleStatus — un nombre engañoso, ahora resuelto: se separó en "Estado" (cycleStatus) y "N° Ciclo" (seasonNumber), y "Año" pasó de derivarse de sowingDate.slice(0,4) a leer seasonYear directamente. El filtrado sigue siendo client-side (igual que sector/piscina/peso): GET /reports/dashboard no pagina ni filtra server-side por diseño (devuelve todas las piscinas de la fecha de corte para que el propio front recalcule kpis/donut/barras al filtrar) — migrar eso a server-side habría significado rediseñar el endpoint, fuera del alcance de esta tarea.
Consecuencias
CycleDto/CycleDetailDto/WeeklyReportRowDtogananseasonYear,seasonNumberyendDate— cambio de contrato aditivo (no rompe consumidores existentes).- El backfill de la migración (
20260804220559_cycle_season_year_number) derivaseasonYeardestartDate,seasonNumberdel orden cronológico por(poolId, seasonYear), yendDatede la cosecha final / transferencia completa ya registrada — sin intervención manual sobre los datos existentes. - Mobile solo muestra Año/Ciclo (lista y detalle de ciclos); no hay edición ni carga de histórico desde el teléfono — el mobile es para campo, la carga de histórico es una operación administrativa puntual.
- Mientras no se cargue el historial de un año,
seasonNumbersale bajo para las piscinas que en realidad van por su 2ª/3ª corrida (arrancan todas en 1). Es el costo aceptado de no tener un campo editable de escape: se corrige solo en cuanto se carga el histórico, no antes. assertPoolFreedeja de ser la única guardia de unicidad de un cicloactiva: ahora conviven conassertNoOverlappara los históricos. Es el punto más delicado del cambio — cubierto con e2e explícitos (apertura activa + histórico + solapamiento + renumeración- herencia por transferencia), no solo por lectura de código.
Referencias
- Tarea
ciclo-temporada(2026-08-04),_planning/ciclo-temporada-plan.md/-progress.md. camaroneras_backend/src/modules/ciclos/season.ts(helper puro,season.spec.ts),ciclos.service.ts(openCycle,updateCycle,removeCycle,renumberPoolSeason,assertNoOverlap),common/utils/advisory-lock.ts(acquirePoolCycleLock).camaroneras_backend/test/ciclos.e2e-spec.ts→ describe "temporada (seasonYear / seasonNumber)".camaroneras_docs/modulos/02-ciclos-flow.md(contrato),08-reporte-semanal-flow.mdy09-dashboard-produccion-flow.md(columnas y filtros).- ADR-0053 (patrón de advisory locks reutilizado).
- Backlog del proyecto → "WL Aqua Intelligence v4 — brecha vs. el doc del socio", filas 4-5 de
Consolidadoy "Decisiones ya tomadas" (respuestas de Winston del 2026-08-04).