From 3c94815ffc61a5fbe56e939ca66a7f601c6a58b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Mon, 15 Jun 2026 17:22:25 +0300 Subject: [PATCH] =?UTF-8?q?fix(slots-service):=20#48=20by-request=20=D0=BE?= =?UTF-8?q?=D1=82=D0=B4=D0=B0=D1=91=D1=82=20=D0=B1=D1=80=D0=BE=D0=BD=D1=8C?= =?UTF-8?q?=20=D0=B2=20=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=BC=20cycle-?= =?UTF-8?q?=D1=84=D1=80=D0=B5=D0=B9=D0=BC=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET booking/by-request/{id} возвращал tn/tk/revolution из базового cycle-фрейма без сдвига (BookedSlotEntity.toViewDTO), тогда как все остальные пути (toDetails, allByInterval→shiftedToCycle, resolveBookedSlotStartTime) сдвигают на cycle*durationCalc и cycle*cycleRevs. Для броней с cycle > 0 дата уходила на cycle*durationCalc суток раньше расчётной (cycle=0 совпадал, поэтому баг прятался; #42 починил только ключ sat:slotNum:cycle, не время). Сдвиг перенесён в SlotService.bookedByReqId через новый toShiftedViewDTO(satellite); КА резолвится из каталога, нет в каталоге — базовый фрейм без падения списка. Небезопасный базовый билдер в сущности удалён. Контур/координаты не сдвигаются (наземный след повторяется каждый цикл), симметрично shiftedToCycle. Регрессия в SlotServiceCancelTest: сдвиг tn/tk/витка на cycle брони и fallback без КА. Бэклог #48 помечен сделанным. --- docs/бэклог-pcp-tgu-ops-ui.md | 3 +- .../slots_service/entity/BookedSlotEntity.kt | 26 ++----------- .../pcp/slots_service/service/SlotService.kt | 36 ++++++++++++++++-- .../service/SlotServiceCancelTest.kt | 37 +++++++++++++++++++ 4 files changed, 75 insertions(+), 27 deletions(-) diff --git a/docs/бэклог-pcp-tgu-ops-ui.md b/docs/бэклог-pcp-tgu-ops-ui.md index 5d65995..f0b8441 100644 --- a/docs/бэклог-pcp-tgu-ops-ui.md +++ b/docs/бэклог-pcp-tgu-ops-ui.md @@ -13,7 +13,7 @@ > Задачи #12–#25 заведены по результатам аудита. Подробности, обоснование и рекомендации > по каждой — в [аудит-проекта-2026-06-06.md](аудит-проекта-2026-06-06.md) (ссылки на коды находок B/F/R/C). -_Обновлено: 2026-06-11_ +_Обновлено: 2026-06-15_ --- @@ -63,6 +63,7 @@ _Обновлено: 2026-06-11_ - [x] **Выбор заявки на карте (вместо списка)** _(2026-06-11): на вкладке «Слоты» убран выпадающий список заявок — он не масштабируется на тысячи заявок. Базово все заявки интервала отображаются на карте; клик по заявке (или по наложению нескольких — список с раскрытием) открывает плавающую панель-инспектор справа над картой (общий `MapObjectInspector`) с данными заявки и кнопкой «Выбрать». До выбора в тулбаре вместо списка — имя выбранной заявки или «—», а левая панель показывает фильтр «Заявки на интервал» (С/По + «Загрузить»/«Сегодня»/«7 дней»/«30 дней», как на «Карте») и подсказку «Кликните заявку на карте». После выбора заявки появляется кнопка «Сброс» справа над картой — возвращает окно в состояние «заявка не выбрана, слоты не показаны». Файлы: `features/tgu-slots/TguSlotsTab.tsx`, `features/tgu-map-2d/MapObjectInspector.tsx` (опц. `onSelectRequest` → кнопка «Выбрать» в карточке заявки), стили `.tgu-slots-request*`/`.tgu-slots-reset`/`.tgu-slots-inspector`/`.tgu-slots-requests-panel`._ - [x] **Бронь не отражалась в UI + явная подсветка строки** _(2026-06-11): «не работает кнопка бронь» — диагностировано: POST `booking/request` бронирует корректно и в ответе `BookedSlotDTO` несёт верный `cycle`, но `GET booking/by-request/{id}` отдаёт **базовый** слот в другом cycle-фрейме (`bookedSlot.slot.toDTO` без сдвига на `bookedSlot.cycle`), из-за чего ключ `sat:slotNum:cycle` не совпадал с рассчитанной строкой → бейдж «бронь» не появлялся, на карте — лишний полигон. Фронт-обход: после брони отмечаем забронированными ровно те рассчитанные слоты (с контуром), что вернул POST; by-request на горячем пути не перечитываем; cancel чистит локально. Заодно подсветка активной строки слота сделана явной (фон + левый акцентный бар + bold). Root-fix (на бэке, отдельно): `slots-service SlotService.bookedByReqId/bookedById` должны возвращать слот, сдвинутый на `bookedSlot.cycle` (как `resolveBookedSlotStartTime`/перечисление в `request-cover`) — иначе пред-существующие брони не коррелируют с расчётом. Файлы: `features/tgu-slots/TguSlotsTab.tsx`, стили `.tgu-slots-table tbody tr.is-active`._ - [ ] **#43** — Уточнение статуса слота. На вкладке «Слоты» уже показывается базовый статус слота (`state`: свободен/бронь/занят) и бейдж брони по заявке. Осталось: подтягивать/обновлять актуальный статус занятости по **заложенному** плану (слоты занимаются по `LAID_IN`, а не по `ACCEPT`, ср. #10) и согласовать его источник со slots-service (real-time/после закладки), а также прокинуть статус на вкладки «Карта»/«Редактор» (#3). +- [x] **#48** — (баг, бэкенд) Слоты бронируются «не на те даты». _Исправлено 2026-06-15:_ `BookedSlotEntity.toViewDTO()` (единственный билдер за `GET …/booking/by-request/{id}`) отдавал `tn/tk/revolution` из **базового** cycle-фрейма без сдвига, тогда как все остальные пути (`toDetails`, `allByInterval`→`shiftedToCycle`, `resolveBookedSlotStartTime`) сдвигают на `cycle*durationCalc`. Сдвиг перенесён в `SlotService.bookedByReqId` (новый `toShiftedViewDTO(satellite)`, КА резолвится из каталога; нет в каталоге → базовый фрейм без падения), базовый билдер в сущности удалён. Регрессия: `SlotServiceCancelTest` (cycle=2 → +32 сут / +486 витков; fallback без КА). POST `booking/request` сохраняет только ключ (`bookedSlotId/sat/slotNum/cycle`), tn/tk не пересчитывает — корректно.
_Исходный отчёт:_ При бронировании набора слотов фактическое время брони расходится с тем, что оператор видел/выбирал в таблице расчёта. Корень — та же рассинхронизация cycle-фрейма, что разобрана в задаче «Бронь не отражалась в UI» (#42): `GET …/booking/by-request/{id}` отдаёт слот в **базовом** cycle-фрейме (`bookedSlot.slot.toDTO` без сдвига на `bookedSlot.cycle`), тогда как `request-cover` перечисляет слоты со сдвигом — поэтому время/сутки брони, восстановленные из by-request, не совпадают с рассчитанным слотом. Фронт сейчас обходит это, отмечая бронь по ответу POST (там cycle верный) и не перечитывая by-request на горячем пути, но это лишь маскирует симптом: пред-существующие брони и брони после перезагрузки страницы по-прежнему могут показываться/трактоваться с неверной датой. Root-fix на бэке: `slots-service SlotService.bookedByReqId/bookedById` должны возвращать слот, сдвинутый на `bookedSlot.cycle` (как `resolveBookedSlotStartTime` / перечисление в `request-cover`), чтобы время брони было согласовано с расчётом. Проверить также, что POST `booking/request` сохраняет именно те tn/tk, что соответствуют выбранному слоту, а не пересчитывает их в базовом фрейме. ### Аутентификация и роли - [ ] **#46** — Вывести роли на морду и связать их с аутентификацией. На фронте показывать роль(и) пользователя и разграничивать доступ к действиям/вкладкам по роли; источник ролей — аутентификация (токен/headless-auth, `@nstart/auth`). Уточнить: какие роли есть, где они приходят (claims токена / профиль), как маппятся на UI-разрешения (что скрывать/блокировать), и нужен ли серверный enforcement на стороне сервисов, а не только UI-гейтинг. diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedSlotEntity.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedSlotEntity.kt index 8f145a1..7517214 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedSlotEntity.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/entity/BookedSlotEntity.kt @@ -13,11 +13,8 @@ import jakarta.persistence.OneToMany import jakarta.persistence.Table import org.hibernate.annotations.Fetch import org.hibernate.annotations.FetchMode -import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotDTO import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotStatus -import space.nstart.pcp.pcp_types_lib.dto.requests.slots.BookedSlotViewDTO -import space.nstart.pcp.pcp_types_lib.dto.requests.slots.SlotStatus @Entity @@ -55,24 +52,7 @@ class BookedSlotEntity( cycle = cycle ) - /** - * Представление брони для фронта: поля слота (контур, координаты) + bookedSlotId + cycle БРОНИ. - * cycle берём из самой брони (this.cycle), а не из slot.cycle — иначе ключ sat:slotNum:cycle - * не совпадёт с тем, что вернул POST booking/request (рассинхрон cycle). - */ - fun toViewDTO() = BookedSlotViewDTO( - bookedSlotId = bookedSlotId ?: 0, - cycle = cycle, - satelliteId = slot.satelliteId, - tn = slot.tn, - tk = slot.tk, - roll = slot.roll, - contour = slot.contour, - revolution = slot.revolution, - revolutionSign = RevolutionSign.valueOf(slot.revolutionSign), - slotNumber = slot.slotNum, - state = SlotStatus.BOOKED, - latitude = slot.latitude, - longitude = slot.longitude - ) + // Представление брони для фронта (контур/координаты + bookedSlotId + cycle брони) строится в + // SlotService.toShiftedViewDTO — там доступен КА для сдвига tn/tk/витка на cycle брони. В сущности + // строить нельзя: без сдвига получался базовый cycle-фрейм и дата брони расходилась с расчётом. } diff --git a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt index 8e87501..7c09607 100644 --- a/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt +++ b/services/slots-service/src/main/kotlin/space/nstart/pcp/slots_service/service/SlotService.kt @@ -2459,9 +2459,16 @@ class SlotService { @Transactional(readOnly = true) fun bookedByReqId(id : String) : List { - // toViewDTO() несёт bookedSlotId (для снятия по id) и cycle БРОНИ (а не slot.cycle), - // чтобы ключ слота на фронте совпал с ответом POST booking/request. - return requestRepository.findByRequestId(id).map { req -> req.bookedSlot.toViewDTO() } + // Время/виток брони сдвигаем на cycle БРОНИ (как resolveBookedSlotStartTime / shiftedToCycle / + // toDetails). Иначе by-request отдаёт слот в БАЗОВОМ cycle-фрейме, а request-cover перечисляет + // слоты уже сдвинутыми — отсюда баг «слоты бронируются на не те даты»: дата брони, восстановленная + // из by-request, на cycle*durationCalc суток раньше расчётной. cycle в DTO остаётся циклом брони — + // чтобы ключ sat:slotNum:cycle на фронте совпал с ответом POST booking/request. + return requestRepository.findByRequestId(id).map { req -> + val booked = req.bookedSlot + val satellite = runCatching { satelliteById(booked.slot.satelliteId) }.getOrNull() + booked.toShiftedViewDTO(satellite) + } } @@ -2629,6 +2636,29 @@ class SlotService { f81 = f81 ?: InitialConditionsDTO().f81 ) + // Бронь для фронта со сдвигом на cycle брони (виток + tn/tk). satellite == null (КА не в каталоге) — + // отдаём базовый фрейм без сдвига: лучше показать слот, чем уронить весь список by-request. Контур и + // координаты не сдвигаем — наземный след повторяется каждый цикл (симметрично shiftedToCycle). + private fun BookedSlotEntity.toShiftedViewDTO(satellite: AbstractSatellite?): BookedSlotViewDTO { + val shiftDays = satellite?.let { cycle * it.durationCalc } ?: 0L + val revShift = satellite?.let { cycle * it.cycleRevs } ?: 0L + return BookedSlotViewDTO( + bookedSlotId = bookedSlotId ?: 0, + cycle = cycle, + satelliteId = slot.satelliteId, + tn = slot.tn.plusDays(shiftDays), + tk = slot.tk.plusDays(shiftDays), + roll = slot.roll, + contour = slot.contour, + revolution = slot.revolution + revShift, + revolutionSign = RevolutionSign.valueOf(slot.revolutionSign), + slotNumber = slot.slotNum, + state = SlotStatus.BOOKED, + latitude = slot.latitude, + longitude = slot.longitude + ) + } + private fun SlotEntity.shiftedToCycle(cycle: Long, satellite: AbstractSatellite): SlotDTO = SlotDTO( cycle = cycle, diff --git a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceCancelTest.kt b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceCancelTest.kt index 45f33d5..b9cc2bd 100644 --- a/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceCancelTest.kt +++ b/services/slots-service/src/test/kotlin/space/nstart/pcp/slots_service/service/SlotServiceCancelTest.kt @@ -9,6 +9,7 @@ import org.springframework.test.util.ReflectionTestUtils import space.nstart.pcp.slots_service.entity.BookedRequestEntity import space.nstart.pcp.slots_service.entity.BookedSlotEntity import space.nstart.pcp.slots_service.entity.SlotEntity +import space.nstart.pcp.slots_service.model.satellite.TestSatelliteImpl import space.nstart.pcp.slots_service.repository.BookedRequestRepository import space.nstart.pcp.slots_service.repository.BookedSlotsRepository import java.time.LocalDateTime @@ -22,11 +23,13 @@ class SlotServiceCancelTest { private val bookedSlotsRepository = mock(BookedSlotsRepository::class.java) private val requestRepository = mock(BookedRequestRepository::class.java) + private val satelliteCatalogClient = mock(SatelliteCatalogClient::class.java) private fun service(): SlotService { val service = SlotService() ReflectionTestUtils.setField(service, "bookedSlotsRepository", bookedSlotsRepository) ReflectionTestUtils.setField(service, "requestRepository", requestRepository) + ReflectionTestUtils.setField(service, "satelliteCatalogClient", satelliteCatalogClient) return service } @@ -96,6 +99,40 @@ class SlotServiceCancelTest { assertEquals("POLYGON ((0 0, 1 0, 1 1, 0 0))", view[0].contour) } + @Test + fun `bookedByReqId shifts tn-tk and revolution by booking cycle`() { + // Регрессия на «слоты бронируются на не те даты»: by-request обязан отдавать слот, сдвинутый + // на cycle брони (как request-cover), а не в базовом cycle-фрейме. cycle=2, durationCalc=16, + // cycleRevs=243 -> сдвиг +32 суток и +486 витков относительно базового slot.tn/revolution. + val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1", bookingCycle = 2L) + `when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link)) + `when`(satelliteCatalogClient.satellite(77L)) + .thenReturn(TestSatelliteImpl(id = 77L, cycleRevs = 243L, durationCalc = 16L)) + + val view = service().bookedByReqId("R1") + + assertEquals(1, view.size) + assertEquals(2L, view[0].cycle) + // базовый slot.tn/tk из builder slot(): 2026-03-31 10:00 / 10:10, базовый revolution = 0. + assertEquals(LocalDateTime.of(2026, 3, 31, 10, 0).plusDays(32), view[0].tn) + assertEquals(LocalDateTime.of(2026, 3, 31, 10, 10).plusDays(32), view[0].tk) + assertEquals(486L, view[0].revolution) + } + + @Test + fun `bookedByReqId without satellite in catalog falls back to base frame`() { + // КА нет в каталоге -> сдвиг невозможен, отдаём базовый фрейм без падения (лучше слот, чем 500). + val link = soleLink(bookedSlotId = 10L, linkId = 100L, requestId = "R1", bookingCycle = 2L) + `when`(requestRepository.findByRequestId("R1")).thenReturn(listOf(link)) + `when`(satelliteCatalogClient.satellite(77L)).thenThrow(RuntimeException("нет в каталоге")) + + val view = service().bookedByReqId("R1") + + assertEquals(1, view.size) + assertEquals(LocalDateTime.of(2026, 3, 31, 10, 0), view[0].tn) // без сдвига + assertEquals(2L, view[0].cycle) // cycle брони сохранён даже без КА + } + // --- builders --- private fun slot(