package main import ( "fmt" "sort" "strings" "time" ) // Reglas reune los limites que acordo la asamblea. Los valores vienen de // /etc/enrecal/config.json para poder ajustarlos sin recompilar. type Reglas struct { MesesVentana int `json:"meses_ventana"` // no se puede pedir mas alla de hoy + N meses CupoDias int `json:"cupo_dias"` // dias por colectivo dentro de la ventana DiasMinimoConfirmar int `json:"dias_minimo_confirmar"` // por debajo de esto, una solicitud sin confirmar caduca DiasMaximoPendiente int `json:"dias_maximo_pendiente"` // una solicitud no puede esperar mas que esto DiasReservables []int `json:"dias_reservables"` // 1=lunes .. 7=domingo AnclaFinde Dia `json:"ancla_finde"` // un viernes de referencia TurnoAncla Turno `json:"turno_ancla"` // que turno le toca a ese finde } const maxDiasEvento = 31 func ReglasPorDefecto() Reglas { return Reglas{ MesesVentana: 3, CupoDias: 3, DiasMinimoConfirmar: 7, DiasMaximoPendiente: 30, DiasReservables: []int{5, 6, 7}, AnclaFinde: "2026-09-25", TurnoAncla: Ruido, } } // Ventana devuelve el primer y el ultimo dia que se pueden pedir hoy. func (rg Reglas) Ventana(hoy Dia) (Dia, Dia) { primero := hoy.Mas(rg.DiasMinimoConfirmar) ultimo := DiaDe(hoy.MustTime().AddDate(0, rg.MesesVentana, 0)) return primero, ultimo } func (rg Reglas) reservable(d Dia) bool { iso := d.DiaISO() for _, v := range rg.DiasReservables { if v == iso { return true } } return false } // Solicitud es lo que llega del formulario, ya recortado pero sin validar. type Solicitud struct { Solicitante string Rol Rol Titulo string Amadrinado string Contacto string Desde Dia Hasta Dia Hora string Notas string Excluir string // ID del evento que se está modificando: no choca consigo mismo } // Ocupacion mapea cada dia ocupado con el titulo del evento que lo ocupa. func Ocupacion(eventos []Evento, excluir string) map[Dia]Evento { out := map[Dia]Evento{} for _, e := range eventos { if e.ID == excluir || !e.Estado.Ocupa() { continue } for _, d := range e.Dias() { out[d] = e } } return out } // CupoUsado cuenta los dias que un colectivo ya tiene ocupados dentro de la // ventana de reserva, sumando solicitados y confirmados. func (rg Reglas) CupoUsado(usuario string, eventos []Evento, hoy Dia, excluir string) []Dia { _, ultimo := rg.Ventana(hoy) var dias []Dia for _, e := range eventos { if e.ID == excluir || e.Solicitante != usuario || !e.Estado.Ocupa() { continue } for _, d := range e.Dias() { if d >= hoy && d <= ultimo { dias = append(dias, d) } } } sort.Slice(dias, func(i, j int) bool { return dias[i] < dias[j] }) return dias } // Validar aplica todas las reglas a una solicitud nueva. Devuelve un error con // el motivo escrito para mostrarlo tal cual en el formulario. func (rg Reglas) Validar(s Solicitud, eventos []Evento, hoy Dia) error { if strings.TrimSpace(s.Titulo) == "" { return fmt.Errorf("falta el nombre del evento") } if len([]rune(s.Titulo)) > 80 { return fmt.Errorf("el nombre del evento no puede pasar de 80 caracteres") } if s.Rol == RolExternas && strings.TrimSpace(s.Amadrinado) == "" { return fmt.Errorf("falta de qué colectivo de fuera es el evento") } if s.Amadrinado != "" && strings.TrimSpace(s.Contacto) == "" { return fmt.Errorf("si amadrinas a un colectivo de fuera hace falta un contacto suyo") } if _, err := s.Desde.Time(); err != nil { return err } if _, err := s.Hasta.Time(); err != nil { return err } if s.Hasta < s.Desde { return fmt.Errorf("la fecha de fin es anterior a la de inicio") } if s.Hora != "" { if _, err := time.Parse("15:04", s.Hora); err != nil { return fmt.Errorf("la hora %q no vale, se escribe como 20:00", s.Hora) } } dias := Rango(s.Desde, s.Hasta) // Tope para todos, también la asamblea: Rango corta en 400 días y un evento // más largo quedaría pintado en el calendario pero sin bloquear sus fechas. if len(dias) > maxDiasEvento { return fmt.Errorf("un evento no puede durar más de %d días", maxDiasEvento) } libre := s.Rol == RolEnre // la asamblea de la Enre no tiene cupo ni ventana if !libre { primero, ultimo := rg.Ventana(hoy) if s.Desde < primero { return fmt.Errorf("hay que pedirlo con al menos %d días de antelación: "+ "la primera fecha libre es el %s. Con menos margen la solicitud caducaría sin que "+ "la asamblea llegue a verla", rg.DiasMinimoConfirmar, primero.Legible()) } if s.Hasta > ultimo { return fmt.Errorf("solo se pueden pedir fechas hasta %s, que son %d meses vista", ultimo.Legible(), rg.MesesVentana) } if len(dias) > rg.CupoDias { return fmt.Errorf("un evento no puede ocupar más de %d días y estás pidiendo %d", rg.CupoDias, len(dias)) } for _, d := range dias { if !rg.reservable(d) { return fmt.Errorf("el %s no es día reservable: las fechas se piden para %s. "+ "Para otro día, habladlo en la asamblea", d.Legible(), rg.nombresReservables()) } } } ocupados := Ocupacion(eventos, s.Excluir) for _, d := range dias { if e, ok := ocupados[d]; ok { return fmt.Errorf("el %s ya está cogido por %q (%s)", d.Legible(), e.Titulo, strings.ToLower(e.Estado.Etiqueta())) } } // La asamblea de externas apunta eventos de muchos colectivos distintos: // el cupo es por colectivo y no tiene sentido sumarlos todos en su cuenta. if !libre && s.Rol != RolExternas { usados := rg.CupoUsado(s.Solicitante, eventos, hoy, s.Excluir) if len(usados)+len(dias) > rg.CupoDias { return fmt.Errorf("te pasáis del cupo: %d días por colectivo en los próximos %d meses. "+ "Ya tenéis %s y pedís %d día(s) más. Cancelad una de esas fechas o esperad a que pasen", rg.CupoDias, rg.MesesVentana, listaDias(usados), len(dias)) } } return nil } func (rg Reglas) nombresReservables() string { var out []string for _, v := range rg.DiasReservables { if v >= 1 && v <= 7 { out = append(out, diasSemana[v-1]) } } return enumerar(out) } // enumerar une una lista en castellano: "viernes, sábado y domingo". func enumerar(partes []string) string { switch len(partes) { case 0: return "" case 1: return partes[0] } return strings.Join(partes[:len(partes)-1], ", ") + " y " + partes[len(partes)-1] } func listaDias(dias []Dia) string { var out []string for _, d := range dias { out = append(out, d.Legible()) } return enumerar(out) } // Caducidad devuelve el motivo por el que un evento debe caducar, o "" si // todavia no toca. func (rg Reglas) Caducidad(e Evento, ahora time.Time) string { if e.Estado != Solicitado { return "" } hoy := DiaDe(ahora) if e.Desde < hoy.Mas(rg.DiasMinimoConfirmar) { return fmt.Sprintf("sin confirmar a menos de %d días de la fecha", rg.DiasMinimoConfirmar) } if ahora.Sub(e.Creado) > time.Duration(rg.DiasMaximoPendiente)*24*time.Hour { return fmt.Sprintf("más de %d días esperando confirmación", rg.DiasMaximoPendiente) } return "" } // ViernesDe devuelve el viernes del finde al que pertenece un dia, o "" si el // dia no cae en finde. func ViernesDe(d Dia) Dia { iso := d.DiaISO() if iso < 5 { return "" } return d.Mas(5 - iso) } // TurnoDe resuelve si un finde es tranqui o con ruido: primero mira las // excepciones puestas a mano y si no alterna semana a semana desde el ancla. func (rg Reglas) TurnoDe(d Dia, exc map[Dia]Turno) Turno { viernes := ViernesDe(d) if viernes == "" { return "" } if t, ok := exc[viernes]; ok { return t } ancla := ViernesDe(rg.AnclaFinde) if ancla == "" { ancla = rg.AnclaFinde } dif := int(viernes.MustTime().Sub(ancla.MustTime()).Hours() / 24 / 7) if dif < 0 { dif = -dif } otro := Tranqui if rg.TurnoAncla == Tranqui { otro = Ruido } if dif%2 == 0 { return rg.TurnoAncla } return otro }