pcp: откат UTC-слоя — сквозной naive LocalDateTime без зоны на проводе

Возврат к модели «настенных часов»: одна ISO-строка без зоны
("2026-05-29T01:00:00") от БД до экрана, никто не привязывает её к
часовому поясу, сдвигаться нечему.

Бэкенд — снять зону с границы API:
- DTO OffsetDateTime/Instant -> LocalDateTime и убрана конвертация
  atOffset(ZoneOffset.UTC)/toUtcOffset() в фабриках и мапперах:
  tgu PlanResponse/NewChainDTOs/TguPlanningDTO, request RequestApiDtos,
  complex ComplexPlanRun*DTO, dynamic ObservationParametersDebugDTO.
- Удалён толерантный Jackson-слой: PcpTimeModule(+autoconfig, ServiceLoader,
  imports), PcpTimeModuleV3, LocalDateTimeTimeZoneSerializer; снята их
  регистрация в Camunda/Rest/dynamic JacksonConfig. Штатный JSR-310
  (JavaTimeModule + WRITE_DATES_AS_TIMESTAMPS=false) оставлен.
- Kafka-конверт KafkaMessage.time сериализуется ISO без офсета.
- mission-planing Mode/SurveyMode/DropMode entity и репозитории/сервис
  переведены на LocalDateTime; колонки БД остаются timestamptz (Hibernate
  мапит на naive без миграции — прецедент MissionEntity/PlanEntity).
- RequestServiceClient (dynamic) синхронизирован по типу с request-service.

Анти-регрессия: LocalDateTime.now(ZoneOffset.UTC) для серверных штампов
оставлен (выбор настенных часов UTC, не зона на проводе).

Фронт — снять офсет с egress: toUtcIso/фильтры отдают naive без Z
(после удаления толерантного десериализатора штатный парсер падал бы на Z).
Дисплейные хелперы (показ «как пришло», без сдвига) сохранены.

Тесты: контрактные ждут naive ISO; удалены UTC-артефакты (нормализация
офсетов) и тесты прослоек. Удалён docs/standards/DATETIME_UTC.md.

Операционно перед выкатом: сервисы катить согласованно; Kafka-топики с
ранее опубликованным офсетом +00:00 дренировать/проиграть.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 00:03:52 +03:00
parent 0e534c5bd5
commit 3638afdb85
57 changed files with 230 additions and 1063 deletions
@@ -21,7 +21,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.Optional
class GridManagementControllerTest {
@@ -97,7 +97,7 @@ class GridManagementControllerTest {
GridRebuildResponseDto(
cellsCount = 2,
rebuiltRequestProjectionsCount = 2,
rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"),
rebuiltAt = LocalDateTime.of(2026, 5, 20, 10, 15, 30),
)
).`when`(gridRebuildService).rebuildGrid()
@@ -105,7 +105,7 @@ class GridManagementControllerTest {
.andExpect(status().isOk)
.andExpect(jsonPath("$.cellsCount").value(2))
.andExpect(jsonPath("$.rebuiltRequestProjectionsCount").value(2))
.andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30Z"))
.andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30"))
}
@Test
@@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import java.time.OffsetDateTime
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -25,8 +25,8 @@ class RequestApiDtoContractTest {
"name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
"importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"beginDateTime": "2026-01-01T00:00:00",
"endDateTime": "2026-01-31T23:59:59",
"kpp": [1, 2, 3],
"highPriorityTransmit": false,
"optics": {
@@ -43,7 +43,7 @@ class RequestApiDtoContractTest {
assertEquals(UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"), request.id)
assertEquals("Тест1", request.name)
assertEquals(10.0, request.importance)
assertEquals(OffsetDateTime.parse("2026-01-01T00:00:00Z"), request.beginDateTime)
assertEquals(LocalDateTime.of(2026, 1, 1, 0, 0, 0), request.beginDateTime)
assertEquals(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType)
}
@@ -55,8 +55,8 @@ class RequestApiDtoContractTest {
"name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
"importance": 10.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"beginDateTime": "2026-01-01T00:00:00",
"endDateTime": "2026-01-31T23:59:59",
"surveyType": "OPTICS",
"optics": {
"resultType": "PANCHROMATIC",
@@ -78,8 +78,8 @@ class RequestApiDtoContractTest {
name = "Тест1",
geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
importance = 10.0,
beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"),
endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,
@@ -87,40 +87,7 @@ class RequestApiDtoContractTest {
),
)
assertFalse(json.contains("surveyType"))
assertFalse(json.contains("app_type"))
assertFalse(json.contains("appType"))
assertFalse(json.contains("AppType"))
assertFalse(json.contains("requestType"))
}
@Test
fun `cells list response dto matches openapi fields`() {
val json = objectMapper.writeValueAsString(
CellsListResponseDto(
items = listOf(
CellSummaryResponseDto(
cellNum = 42,
latitude = 55.0,
longitude = 37.0,
importance = 10.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
),
page = 0,
size = 50,
totalItems = 1,
totalPages = 1,
)
)
assertEquals(
setOf("items", "page", "size", "totalItems", "totalPages"),
objectMapper.readTree(json).fieldNames().asSequence().toSet(),
)
assertEquals(
setOf("cellNum", "latitude", "longitude", "importance", "contour"),
objectMapper.readTree(json)["items"][0].fieldNames().asSequence().toSet(),
)
val node = objectMapper.readTree(json)
assertFalse(node.has("surveyType"), "surveyType should not be serialized")
}
}
@@ -3,18 +3,14 @@ package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Test
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.time.LocalDateTime
import java.util.UUID
/**
* Контракт формата времени на проводе для [RequestResponseDto] — эталон группы: `OffsetDateTime`
* сериализуется с явной зоной `Z` (см. docs/standards/DATETIME_UTC.md). Тест ловит молчаливый дрейф
* (смену типа поля на naive `LocalDateTime` или Jackson-конфига).
*
* Mapper зеркалит конфиг Spring Boot: JavaTimeModule + disable(WRITE_DATES_AS_TIMESTAMPS).
* Контракт формата времени на проводе для [RequestResponseDto]: naive `LocalDateTime` → ISO без зоны
* (напр. `"2026-05-29T01:00:00"`). Тест ловит случайный возврат к OffsetDateTime/Z или числовым меткам.
*/
class RequestResponseDtoContractTest {
@@ -22,7 +18,7 @@ class RequestResponseDtoContractTest {
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `serializes request times as ISO with explicit Z zone`() {
fun `serializes request times as naive ISO without zone`() {
val dto = RequestResponseDto(
id = UUID.fromString("00000000-0000-0000-0000-000000000001"),
name = "request",
@@ -30,18 +26,18 @@ class RequestResponseDtoContractTest {
surveyType = SurveyTypeDto.OPTICS,
geometry = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
importance = 1.0,
beginDateTime = OffsetDateTime.of(2026, 5, 29, 1, 0, 0, 0, ZoneOffset.UTC),
endDateTime = OffsetDateTime.of(2026, 5, 29, 2, 0, 0, 0, ZoneOffset.UTC),
beginDateTime = LocalDateTime.of(2026, 5, 29, 1, 0, 0),
endDateTime = LocalDateTime.of(2026, 5, 29, 2, 0, 0),
highPriorityTransmit = false,
coverage = CoverageStateDto(currentPercent = 0.0),
createdAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC),
updatedAt = OffsetDateTime.of(2026, 5, 29, 0, 0, 0, 0, ZoneOffset.UTC)
createdAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0),
updatedAt = LocalDateTime.of(2026, 5, 29, 0, 0, 0)
)
val node = mapper.readTree(mapper.writeValueAsString(dto))
assertEquals("2026-05-29T01:00:00Z", node.get("beginDateTime").asText())
assertEquals("2026-05-29T02:00:00Z", node.get("endDateTime").asText())
assertTrue(node.get("createdAt").asText().endsWith("Z"), "ожидалась явная зона Z")
assertEquals("2026-05-29T01:00:00", node.get("beginDateTime").asText())
assertEquals("2026-05-29T02:00:00", node.get("endDateTime").asText())
assertFalse(node.get("createdAt").asText().endsWith("Z"), "не ожидалась зона Z")
}
}
@@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.UUID
import kotlin.test.assertEquals
@@ -137,8 +136,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor(
val response = service.listRequests(
RequestListFilter(
beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"),
beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
beginFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0),
beginTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
)
)
@@ -153,8 +152,8 @@ class RequestServiceListRequestsJpaTest @Autowired constructor(
val response = service.listRequests(
RequestListFilter(
endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"),
endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
endFrom = LocalDateTime.of(2026, 1, 10, 0, 0, 0),
endTo = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
)
)
@@ -30,7 +30,6 @@ import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
@@ -286,8 +285,8 @@ class RequestServiceTest {
name = REQUEST_NAME,
geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
importance = REQUEST_IMPORTANCE,
beginDateTime = OffsetDateTime.parse("2026-01-01T00:00:00Z"),
endDateTime = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
beginDateTime = LocalDateTime.of(2026, 1, 1, 0, 0, 0),
endDateTime = LocalDateTime.of(2026, 1, 31, 23, 59, 59),
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,