This commit is contained in:
Дмитрий Соловьев
2026-05-25 14:23:52 +03:00
parent b3a6012ebb
commit d48ddd2657
1066 changed files with 104601 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
FROM bellsoft/liberica-openjre-alpine:21.0.5
ENV JAVA_OPTS=""
ADD ./build/libs/*.jar /app.jar
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app.jar"]
@@ -0,0 +1,48 @@
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa")
id("org.springframework.boot")
id("io.spring.dependency-management")
}
group = "space.nstart.pcp"
version = "1.0.0"
dependencies {
implementation(project(":libs:pcp-types-lib"))
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${property("versions.open-api")}")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.kafka:spring-kafka")
implementation("org.springframework.cloud:spring-cloud-starter-config")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
implementation("org.locationtech.jts:jts-core:1.19.0")
implementation("org.geotools:gt-main:31.0")
implementation("org.geotools:gt-referencing:31.0")
implementation("org.geotools:gt-epsg-hsql:31.0")
implementation("org.geotools:gt-epsg-extension:31.0")
implementation("org.jetbrains.kotlin:kotlin-reflect")
runtimeOnly("org.postgresql:postgresql")
testImplementation(kotlin("test"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("com.h2database:h2")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("versions.spring.cloud")}")
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
File diff suppressed because it is too large Load Diff
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
package org.nstart.dep265.requestservice
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@ConfigurationPropertiesScan
@EnableScheduling
class PcpRequestServiceApplication
fun main(args: Array<String>) {
runApplication<PcpRequestServiceApplication>(*args)
}
@@ -0,0 +1,22 @@
package org.nstart.dep265.requestservice.config
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class JacksonConfig {
/**
* The service code still depends on Jackson 2 APIs, while the shared Boot 4 stack
* auto-configures Jackson 3 for MVC. Keep a dedicated Jackson 2 mapper for internal
* parsing until the module is migrated.
*/
@Bean
fun objectMapper(): ObjectMapper {
return jacksonObjectMapper()
.registerModule(JavaTimeModule())
.findAndRegisterModules()
}
}
@@ -0,0 +1,34 @@
package org.nstart.dep265.requestservice.config
import org.apache.kafka.clients.producer.ProducerConfig
import org.apache.kafka.common.serialization.StringSerializer
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.kafka.core.DefaultKafkaProducerFactory
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.core.ProducerFactory
@Configuration
class KafkaProducerConfig {
@Bean
@ConditionalOnMissingBean
fun requestProducerFactory(
@Value("\${spring.kafka.bootstrap-servers:192.168.100.160:19092}") bootstrapServers: String,
): ProducerFactory<String, String> {
return DefaultKafkaProducerFactory(
mapOf(
ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to bootstrapServers,
ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java,
),
)
}
@Bean
@ConditionalOnMissingBean
fun requestKafkaTemplate(producerFactory: ProducerFactory<String, String>): KafkaTemplate<String, String> {
return KafkaTemplate(producerFactory)
}
}
@@ -0,0 +1,20 @@
package org.nstart.dep265.requestservice.config
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class OpenApiConfig {
@Bean
fun requestServiceOpenApi(): OpenAPI {
return OpenAPI()
.info(
Info()
.title("PCP Request Service API")
.description("REST API сервиса заявок PCP.")
.version("v1"),
)
}
}
@@ -0,0 +1,10 @@
package org.nstart.dep265.requestservice.config
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "pcp.outbox")
data class OutboxProperties(
val requestCompletedTopic: String = "pcp.request.completed.v1",
val publishBatchSize: Int = 50,
val publishFixedDelayMs: Long = 5_000,
)
@@ -0,0 +1,25 @@
package org.nstart.dep265.requestservice.config
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.TransactionOperations
import org.springframework.transaction.support.TransactionTemplate
@Configuration
class OutboxTransactionConfig {
@Bean("routeMatchingTransactionOperations")
fun routeMatchingTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations {
return TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRED
}
}
@Bean("outboxTransactionOperations")
fun outboxTransactionOperations(transactionManager: PlatformTransactionManager): TransactionOperations {
return TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
}
}
@@ -0,0 +1,33 @@
package org.nstart.dep265.requestservice.controller
import org.nstart.dep265.requestservice.dto.ErrorResponseDto
import org.nstart.dep265.requestservice.service.InvalidCellsQueryException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
@RestControllerAdvice(assignableTypes = [CellsController::class])
class CellsApiExceptionHandler {
@ExceptionHandler(InvalidCellsQueryException::class)
fun handleInvalidCellsQuery(exception: InvalidCellsQueryException): ResponseEntity<ErrorResponseDto> {
return validationError(exception.message ?: "Некорректные query parameters")
}
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleTypeMismatch(exception: MethodArgumentTypeMismatchException): ResponseEntity<ErrorResponseDto> {
return validationError("Некорректное значение query parameter: ${exception.name}")
}
private fun validationError(message: String): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = message,
),
)
}
}
@@ -0,0 +1,47 @@
package org.nstart.dep265.requestservice.controller
import org.nstart.dep265.requestservice.dto.CellsListResponseDto
import org.nstart.dep265.requestservice.dto.CellsWithRequestsResponseDto
import org.nstart.dep265.requestservice.service.CellsQueryService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v1/cells")
class CellsController(
private val cellsQueryService: CellsQueryService,
) {
@GetMapping
fun listCells(
@RequestParam(required = false) minImportance: Double?,
@RequestParam(required = false) countLat: Int?,
@RequestParam(required = false) countLong: Int?,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int,
@RequestParam(required = false) sort: String?,
): CellsListResponseDto {
return cellsQueryService.listCells(
minImportance = minImportance,
countLat = countLat,
countLong = countLong,
page = page,
size = size,
sort = sort,
)
}
@GetMapping("/with-requests")
fun listCellsWithRequests(
@RequestParam(required = false) minImportance: Double?,
@RequestParam(required = false) countLat: Int?,
@RequestParam(required = false) countLong: Int?,
): CellsWithRequestsResponseDto {
return cellsQueryService.listCellsWithRequests(
minImportance = minImportance,
countLat = countLat,
countLong = countLong,
)
}
}
@@ -0,0 +1,63 @@
package org.nstart.dep265.requestservice.controller
import org.nstart.dep265.requestservice.dto.ErrorDetailDto
import org.nstart.dep265.requestservice.dto.ErrorResponseDto
import org.nstart.dep265.requestservice.service.GridRebuildAlreadyRunningException
import org.nstart.dep265.requestservice.service.InvalidGridSettingsException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice(assignableTypes = [GridManagementController::class])
class GridManagementApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationError(exception: MethodArgumentNotValidException): ResponseEntity<ErrorResponseDto> {
val details = exception.bindingResult.fieldErrors.map { error ->
ErrorDetailDto(
field = error.field,
message = error.defaultMessage ?: "Некорректное значение",
)
}
return validationError("Ошибка валидации запроса", details)
}
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleNotReadable(exception: HttpMessageNotReadableException): ResponseEntity<ErrorResponseDto> {
return validationError(exception.mostSpecificCause.message ?: "Некорректный JSON-запрос")
}
@ExceptionHandler(InvalidGridSettingsException::class)
fun handleInvalidGridSettings(exception: InvalidGridSettingsException): ResponseEntity<ErrorResponseDto> {
return validationError(exception.message ?: "Некорректные настройки сетки")
}
@ExceptionHandler(GridRebuildAlreadyRunningException::class)
fun handleGridRebuildAlreadyRunning(exception: GridRebuildAlreadyRunningException): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(
ErrorResponseDto(
code = "GRID_REBUILD_ALREADY_RUNNING",
message = exception.message ?: "Grid rebuild is already running",
),
)
}
private fun validationError(
message: String,
details: List<ErrorDetailDto>? = null,
): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = message,
details = details,
),
)
}
}
@@ -0,0 +1,38 @@
package org.nstart.dep265.requestservice.controller
import jakarta.validation.Valid
import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto
import org.nstart.dep265.requestservice.dto.GridSettingsResponseDto
import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto
import org.nstart.dep265.requestservice.service.GridRebuildService
import org.nstart.dep265.requestservice.service.GridSettingsService
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/v1/grid")
class GridManagementController(
private val gridSettingsService: GridSettingsService,
private val gridRebuildService: GridRebuildService,
) {
@GetMapping("/settings")
fun getGridSettings(): GridSettingsResponseDto {
return gridSettingsService.getSettings()
}
@PutMapping("/settings")
fun updateGridSettings(
@Valid @RequestBody request: UpdateGridSettingsRequestDto,
): GridSettingsResponseDto {
return gridSettingsService.updateSettings(request)
}
@PostMapping("/rebuild")
fun rebuildGrid(): GridRebuildResponseDto {
return gridRebuildService.rebuildGrid()
}
}
@@ -0,0 +1,70 @@
package org.nstart.dep265.requestservice.controller
import org.nstart.dep265.requestservice.dto.ErrorDetailDto
import org.nstart.dep265.requestservice.dto.ErrorResponseDto
import org.nstart.dep265.requestservice.service.InvalidRequestListSortException
import org.nstart.dep265.requestservice.service.RequestAlreadyExistsException
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice(assignableTypes = [RequestController::class])
class RequestApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationError(exception: MethodArgumentNotValidException): ResponseEntity<ErrorResponseDto> {
val details = exception.bindingResult.fieldErrors.map { error ->
ErrorDetailDto(
field = error.field,
message = error.defaultMessage ?: "Некорректное значение",
)
}
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = "Ошибка валидации запроса",
details = details,
),
)
}
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleNotReadable(exception: HttpMessageNotReadableException): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = exception.mostSpecificCause.message ?: "Некорректный JSON-запрос",
),
)
}
@ExceptionHandler(RequestAlreadyExistsException::class)
fun handleRequestAlreadyExists(exception: RequestAlreadyExistsException): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(
ErrorResponseDto(
code = "REQUEST_ALREADY_EXISTS",
message = "Заявка с таким id уже существует",
),
)
}
@ExceptionHandler(InvalidRequestListSortException::class)
fun handleInvalidRequestListSort(exception: InvalidRequestListSortException): ResponseEntity<ErrorResponseDto> {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(
ErrorResponseDto(
code = "VALIDATION_ERROR",
message = exception.message ?: "Некорректный параметр sort",
),
)
}
}
@@ -0,0 +1,89 @@
package org.nstart.dep265.requestservice.controller
import jakarta.validation.Valid
import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto
import org.nstart.dep265.requestservice.dto.CreateRequestResponseDto
import org.nstart.dep265.requestservice.dto.DeleteRequestResponseDto
import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto
import org.nstart.dep265.requestservice.dto.RequestListResponseDto
import org.nstart.dep265.requestservice.dto.RequestResponseDto
import org.nstart.dep265.requestservice.dto.RequestStatusDto
import org.nstart.dep265.requestservice.dto.RequestWithCellsResponseDto
import org.nstart.dep265.requestservice.dto.SurveyTypeDto
import org.nstart.dep265.requestservice.service.RequestService
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.time.OffsetDateTime
import java.util.UUID
@RestController
@RequestMapping("/v1/requests")
class RequestController(
private val requestService: RequestService,
) {
@PostMapping
fun createRequest(@Valid @RequestBody request: CreateRequestRequestDto): ResponseEntity<CreateRequestResponseDto> {
return ResponseEntity.status(201).body(requestService.createRequest(request))
}
@GetMapping
fun listRequests(
@RequestParam(required = false) status: RequestStatusDto?,
@RequestParam(required = false) surveyType: SurveyTypeDto?,
@RequestParam(required = false) kpp: Int?,
@RequestParam(required = false) highPriorityTransmit: Boolean?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) beginTo: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endFrom: OffsetDateTime?,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) endTo: OffsetDateTime?,
@RequestParam(defaultValue = "false") includeDeleted: Boolean,
@RequestParam(defaultValue = "0") page: Int,
@RequestParam(defaultValue = "50") size: Int,
@RequestParam(defaultValue = "createdAt,desc") sort: String,
): RequestListResponseDto {
val query = ListRequestsQueryDto(
status = status,
surveyType = surveyType,
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
beginFrom = beginFrom,
beginTo = beginTo,
endFrom = endFrom,
endTo = endTo,
includeDeleted = includeDeleted,
page = page,
size = size,
sort = sort,
)
return requestService.listRequests(query)
}
@GetMapping("/{id}")
fun getRequestById(@PathVariable id: UUID): ResponseEntity<RequestResponseDto> {
return requestService.getRequestById(id)
?.let { request -> ResponseEntity.ok(request) }
?: ResponseEntity.notFound().build()
}
@GetMapping("/{requestId}/with-cells")
fun getRequestWithCells(@PathVariable requestId: UUID): ResponseEntity<RequestWithCellsResponseDto> {
return requestService.getRequestWithCells(requestId)
?.let { response -> ResponseEntity.ok(response) }
?: ResponseEntity.notFound().build()
}
@DeleteMapping("/{id}")
fun deleteRequest(@PathVariable id: UUID): ResponseEntity<DeleteRequestResponseDto> {
return requestService.deleteRequest(id)
?.let { response -> ResponseEntity.ok(response) }
?: ResponseEntity.notFound().build()
}
}
@@ -0,0 +1,91 @@
package org.nstart.dep265.requestservice.dto
import java.util.UUID
/**
* Краткая карточка ячейки сетки для GET /v1/cells.
*/
data class CellSummaryResponseDto(
/** Номер ячейки сетки. */
val cellNum: Long,
/** Широта центра ячейки. */
val latitude: Double,
/** Долгота центра ячейки. */
val longitude: Double,
/** Расчетная важность ячейки. */
val importance: Double,
/** Контур ячейки в WKT. */
val contour: String,
)
/**
* Постраничный список ячеек сетки.
*/
data class CellsListResponseDto(
/** Элементы текущей страницы. */
val items: List<CellSummaryResponseDto>,
/** Номер страницы, начиная с 0. */
val page: Int,
/** Размер страницы. */
val size: Int,
/** Общее количество элементов. */
val totalItems: Long,
/** Общее количество страниц. */
val totalPages: Int,
)
/**
* Фрагмент заявки внутри ячейки сетки для GET /v1/cells/with-requests.
*/
data class CellRequestFragmentResponseDto(
/** Идентификатор заявки. */
val requestId: UUID,
/** Контур фрагмента заявки внутри ячейки в WKT. */
val contour: String,
/** Процент покрытия ячейки фрагментом заявки. */
val coveragePercent: Double,
/** Snapshot важности заявки из request_cells. */
val importance: Double,
)
/**
* Ячейка сетки с фрагментами заявок для GET /v1/cells/with-requests.
*/
data class CellWithRequestsResponseDto(
/** Номер ячейки сетки. */
val cellNum: Long,
/** Широта центра ячейки. */
val latitude: Double,
/** Долгота центра ячейки. */
val longitude: Double,
/** Расчетная важность ячейки. */
val importance: Double,
/** Контур ячейки в WKT. */
val contour: String,
/** Фрагменты заявок, пересекающие ячейку. */
val requests: List<CellRequestFragmentResponseDto>,
)
/**
* Список ячеек сетки с фрагментами заявок.
*/
data class CellsWithRequestsResponseDto(
/** Ячейки сетки с привязанными request_cells fragments. */
val items: List<CellWithRequestsResponseDto>,
)
@@ -0,0 +1,42 @@
package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.annotation.JsonFormat
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.NotNull
import java.time.OffsetDateTime
/**
* Текущие настройки сетки.
*/
data class GridSettingsResponseDto(
/** Идентификатор набора настроек сетки. */
val settingsId: Long,
/** Шаг построения сетки в градусах. */
val calculationStep: Double,
)
/**
* Обновляемые настройки сетки. Обновление не запускает rebuild автоматически.
*/
data class UpdateGridSettingsRequestDto(
/** Новый шаг построения сетки в градусах. */
@field:NotNull
@field:DecimalMin(value = "0.0", inclusive = false)
val calculationStep: Double?,
) : StrictRequestApiDto()
/**
* Результат явного перестроения сетки и request_cells projection.
*/
data class GridRebuildResponseDto(
/** Количество ячеек earth_cells после перестроения. */
val cellsCount: Long,
/** Количество активных заявок, для которых пересобраны request_cells. */
val rebuiltRequestProjectionsCount: Long,
/** Дата-время завершения перестроения в UTC. */
@field:JsonFormat(shape = JsonFormat.Shape.STRING)
val rebuiltAt: OffsetDateTime,
)
@@ -0,0 +1,467 @@
package org.nstart.dep265.requestservice.dto
import com.fasterxml.jackson.annotation.JsonAnySetter
import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.validation.Valid
import jakarta.validation.constraints.AssertTrue
import jakarta.validation.constraints.DecimalMax
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.time.OffsetDateTime
import java.util.UUID
/**
* Базовая защита OpenAPI DTO от полей вне schema.additionalProperties=false.
*/
abstract class StrictRequestApiDto {
@JsonAnySetter
fun rejectAdditionalProperty(name: String, value: Any?) {
throw IllegalArgumentException("Unknown request API property: $name")
}
}
/**
* Тело запроса на создание заявки по OpenAPI.
*/
data class CreateRequestRequestDto(
/** Внешний идентификатор заявки. */
@field:NotNull
val id: UUID,
/** Наименование задания. */
@field:NotBlank
@field:Size(max = 255)
val name: String,
/** Геометрия заявки в WKT. */
@field:NotBlank
val geometry: String,
/** Важность заявки для расчета важности ячеек сетки. */
@field:NotNull
@field:DecimalMin("0")
val importance: Double?,
/** Начало временного окна выполнения заявки. */
@field:NotNull
val beginDateTime: OffsetDateTime,
/** Окончание временного окна выполнения заявки. */
@field:NotNull
val endDateTime: OffsetDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
/** Признак высокоприоритетной передачи. */
val highPriorityTransmit: Boolean = false,
/** Параметры оптической съемки. */
@field:Valid
val optics: OpticsParamsDto? = null,
/** Параметры радиолокационной съемки РСА. */
@field:Valid
val rsa: RsaParamsDto? = null,
) : StrictRequestApiDto() {
/** В create-запросе должен быть указан хотя бы один payload-блок съемки. */
@get:AssertTrue(message = "Должен быть указан хотя бы один блок съемки: optics или rsa")
@get:JsonIgnore
val hasSurveyPayload: Boolean
get() = optics != null || rsa != null
}
/**
* Ответ на создание заявки.
*/
data class CreateRequestResponseDto(
/** Идентификатор заявки. */
val id: UUID,
/** Наименование задания. */
val name: String,
/** Статус заявки после приема. */
val status: RequestStatusDto,
/** Вычисленный тип заявки по наличию optics/rsa. */
val surveyType: SurveyTypeDto,
/** Важность заявки для расчета важности ячеек сетки. */
val importance: Double,
/** Дополнительное сообщение для клиента. */
val message: String? = null,
)
/**
* Полная read-модель заявки.
*/
data class RequestResponseDto(
/** Идентификатор заявки. */
val id: UUID,
/** Наименование задания. */
val name: String,
/** Текущий статус заявки. */
val status: RequestStatusDto,
/** Вычисленный тип заявки. */
val surveyType: SurveyTypeDto,
/** Полная геометрия заявки в WKT. */
val geometry: String,
/** Важность заявки для расчета важности ячеек сетки. */
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
/** Признак высокоприоритетной передачи. */
val highPriorityTransmit: Boolean,
/** Параметры оптической съемки, если заявка содержит optics. */
val optics: OpticsParamsDto? = null,
/** Параметры РСА-съемки, если заявка содержит rsa. */
val rsa: RsaParamsDto? = null,
/** Текущее состояние покрытия заявки. */
val coverage: CoverageStateDto,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
)
/**
* Краткая карточка заявки для list endpoint.
*/
data class RequestSummaryResponseDto(
/** Идентификатор заявки. */
val id: UUID,
/** Наименование задания. */
val name: String,
/** Текущий статус заявки. */
val status: RequestStatusDto,
/** Вычисленный тип заявки. */
val surveyType: SurveyTypeDto,
/** Важность заявки для расчета важности ячеек сетки. */
val importance: Double,
/** Начало временного окна выполнения заявки. */
val beginDateTime: OffsetDateTime,
/** Окончание временного окна выполнения заявки. */
val endDateTime: OffsetDateTime,
/** Список номеров КПП для сброса. */
val kpp: List<Int> = emptyList(),
/** Признак высокоприоритетной передачи. */
val highPriorityTransmit: Boolean,
/** Текущий процент покрытия заявки. */
val coveragePercent: Double,
/** Время создания заявки. */
val createdAt: OffsetDateTime,
/** Время последнего обновления заявки. */
val updatedAt: OffsetDateTime,
/** Время soft delete, если заявка удалена. */
val deletedAt: OffsetDateTime? = null,
)
/**
* Постраничный ответ list endpoint.
*/
data class RequestListResponseDto(
/** Элементы текущей страницы. */
val items: List<RequestSummaryResponseDto>,
/** Номер страницы, начиная с 0. */
val page: Int,
/** Размер страницы. */
val size: Int,
/** Общее количество элементов. */
val totalItems: Long,
/** Общее количество страниц. */
val totalPages: Int,
)
/**
* Ячейка, связанная с заявкой через request_cells projection.
*/
data class RequestCellResponseDto(
/** Номер ячейки сетки из earth_cells. */
val cellNum: Long,
/** Процент покрытия ячейки фрагментом заявки из request_cells. */
val coveragePercent: Double,
/** Snapshot важности заявки из request_cells. */
val importance: Double,
/** Контур фрагмента заявки внутри ячейки в WKT из request_cells. */
val contour: String,
)
/**
* Заявка с проекциями на ячейки сетки.
*/
data class RequestWithCellsResponseDto(
/** Полная read-модель заявки нового /v1 API. */
val request: RequestResponseDto,
/** Проекции заявки на ячейки сетки из request_cells. */
val cells: List<RequestCellResponseDto>,
)
/**
* Ответ на soft delete заявки.
*/
data class DeleteRequestResponseDto(
/** Идентификатор заявки. */
val id: UUID,
/** Итоговый статус после удаления. */
val status: DeleteRequestStatusDto,
/** Время soft delete. */
val deletedAt: OffsetDateTime,
)
/**
* Состояние покрытия заявки.
*/
data class CoverageStateDto(
/** Требуемый процент покрытия, если он задан доменной моделью. */
val requiredPercent: Double? = null,
/** Текущий процент покрытия. */
val currentPercent: Double,
)
/**
* Параметры оптической съемки.
*/
data class OpticsParamsDto(
/** Результат оптической съемки. */
@field:NotNull
val resultType: OpticsResultTypeDto,
/** Требуемое разрешение съемки, метры. */
@field:DecimalMin("0.1")
val resolution: Double,
/** Минимальная высота Солнца, градусы. */
@field:DecimalMin("0")
@field:DecimalMax("90")
val sunAngleMin: Double = 10.0,
/** Максимальная высота Солнца, градусы. */
@field:DecimalMin("0")
@field:DecimalMax("90")
val sunAngleMax: Double = 90.0,
/** Максимально допустимая облачность, проценты. */
@field:DecimalMin("0")
@field:DecimalMax("100")
val clouds: Double = 100.0,
) : StrictRequestApiDto()
/**
* Параметры радиолокационной съемки РСА.
*/
data class RsaParamsDto(
/** Тип аппаратуры РСА. */
@field:NotNull
val resultType: RsaResultTypeDto,
/** Признак интерферометрии. */
val interferometry: Boolean = false,
/** Тип поляризации. */
@field:NotNull
val polarisation: PolarisationDto,
/** Требуемое разрешение съемки, метры. */
@field:DecimalMin("0.1")
val resolution: Double,
) : StrictRequestApiDto()
/**
* Ошибка API.
*/
data class ErrorResponseDto(
/** Машиночитаемый код ошибки. */
val code: String,
/** Человекочитаемое описание ошибки. */
val message: String,
/** Детали ошибки по полям. */
val details: List<ErrorDetailDto>? = null,
)
/**
* Деталь ошибки валидации.
*/
data class ErrorDetailDto(
/** Поле, к которому относится ошибка. */
val field: String,
/** Описание ошибки поля. */
val message: String,
)
/**
* Query parameters list endpoint.
*/
data class ListRequestsQueryDto(
/** Фильтр по статусу заявки. */
val status: RequestStatusDto? = null,
/** Фильтр по вычисленному типу заявки. */
val surveyType: SurveyTypeDto? = null,
/** Фильтр по номеру КПП. */
val kpp: Int? = null,
/** Фильтр по признаку высокоприоритетной передачи. */
val highPriorityTransmit: Boolean? = null,
/** Нижняя граница beginDateTime, включительно. */
val beginFrom: OffsetDateTime? = null,
/** Верхняя граница beginDateTime, включительно. */
val beginTo: OffsetDateTime? = null,
/** Нижняя граница endDateTime, включительно. */
val endFrom: OffsetDateTime? = null,
/** Верхняя граница endDateTime, включительно. */
val endTo: OffsetDateTime? = null,
/** Включать soft-deleted заявки в результат. */
val includeDeleted: Boolean = false,
/** Номер страницы, начиная с 0. */
@field:Min(0)
val page: Int = 0,
/** Размер страницы. */
@field:Min(1)
@field:Max(500)
val size: Int = 50,
/** Сортировка в формате field,direction. */
val sort: String = "createdAt,desc",
)
/**
* Доменный статус заявки в OpenAPI.
*/
enum class RequestStatusDto {
/** Заявка прошла валидацию и принята сервисом. */
ACCEPTED,
/** Заявка актуальна для обработки. */
ACTIVE,
/** Заявка выполнена. */
COMPLETED,
/** Временное окно заявки истекло. */
EXPIRED,
/** Заявка мягко удалена. */
DELETED,
}
/**
* Статус ответа delete endpoint.
*/
enum class DeleteRequestStatusDto {
/** Заявка удалена или уже была удалена ранее. */
DELETED,
}
/**
* Вычисленный тип заявки по наличию optics/rsa.
*/
enum class SurveyTypeDto {
/** В заявке указан только optics. */
OPTICS,
/** В заявке указан только rsa. */
RSA,
/** В заявке указаны optics и rsa. */
COMBINED,
}
/**
* Результат оптической съемки.
*/
enum class OpticsResultTypeDto {
/** Панхроматическая съемка. */
PANCHROMATIC,
/** Мультиспектральная съемка. */
MULTISPECTRAL,
/** Паншарпенинг. */
PANSHARPENING,
}
/**
* Тип аппаратуры РСА.
*/
enum class RsaResultTypeDto {
/** Spotlight режим. */
SPOTLIGHT,
/** Stripmap режим. */
STRIPMAP,
/** ScanSAR режим. */
SCANSAR,
}
/**
* Тип поляризации РСА.
*/
enum class PolarisationDto {
/** Горизонтальная передача и горизонтальный прием. */
HH,
/** Вертикальная передача и вертикальный прием. */
VV,
}
@@ -0,0 +1,46 @@
package org.nstart.dep265.requestservice.dto
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import java.time.LocalDateTime
import java.util.UUID
/**
* Абсолютный диапазон крена маршрута в градусах.
*/
data class RouteAngleRangeDto(
/** Минимальное абсолютное значение крена. */
val min: Double,
/** Максимальное абсолютное значение крена. */
val max: Double,
) {
init {
require(min <= max) { "Минимальный крен маршрута не может быть больше максимального" }
}
fun average(): Double = (min + max) / 2.0
}
/**
* Внутренняя DTO маршрута, используемая request-service для matching заявок с маршрутами.
*/
data class RouteDto(
/** Идентификатор маршрута. */
val routeId: UUID,
/** Начало интервала выполнения маршрута. */
@field:NotNull(message = "Время начала маршрута обязательно")
val intervalBegin: LocalDateTime,
/** Конец интервала выполнения маршрута. */
@field:NotNull(message = "Время окончания маршрута обязательно")
val intervalEnd: LocalDateTime,
/** Абсолютный диапазон крена маршрута. */
val rollAngle: RouteAngleRangeDto,
/** Геометрия маршрута в WKT. */
@field:NotBlank(message = "Геометрия маршрута обязательна")
val geometry: String,
)
@@ -0,0 +1,69 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.CascadeType
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
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.requests.EarthCellDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
@Entity
@Table(name = "earth_cells")
class EarthCellEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "cell_id")
var cellId: Long? = null,
@Column(name = "cell_num", nullable = false)
var cellNum: Long = 0,
@Column(name = "longitude")
var longitude: Double? = null,
@Column(name = "latitude")
var latitude: Double? = null,
@Column(name = "importance", nullable = false)
var importance: Double = 0.0,
@Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT")
var contour: String = "",
/** Привязанные к ячейке фрагменты заявок для legacy earth-grid API. */
@OneToMany(fetch = FetchType.LAZY, mappedBy = "cell", cascade = [CascadeType.PERSIST, CascadeType.MERGE], orphanRemoval = true)
@Fetch(FetchMode.SUBSELECT)
var requestProjections: MutableList<RequestCellEntity> = mutableListOf(),
) {
fun toDto(): EarthCellDTO {
val dto = EarthCellDTO(
id = cellId ?: 0,
num = cellNum,
latitude = latitude ?: 0.0,
longitude = longitude ?: 0.0,
importance = importance,
contour = contour,
)
return dto
}
fun toDtoWithRequests(): EarthCellWithRequestsDTO {
val dto = EarthCellWithRequestsDTO(
id = cellId ?: 0,
num = cellNum,
latitude = latitude ?: 0.0,
longitude = longitude ?: 0.0,
importance = importance,
contour = contour,
requests = requestProjections.map { requestProjection -> requestProjection.toDto() },
)
return dto
}
}
@@ -0,0 +1,22 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
@Entity
@Table(name = "earth_grid_settings")
class EarthGridSettingsEntity(
@Id
@Column(name = "settings_id")
var settingsId: Long = DEFAULT_SETTINGS_ID,
@Column(name = "calculation_step", nullable = false)
var calculationStep: Double = DEFAULT_CALCULATION_STEP,
) {
companion object {
const val DEFAULT_SETTINGS_ID = 1L
const val DEFAULT_CALCULATION_STEP = 2.0
}
}
@@ -0,0 +1,56 @@
package org.nstart.dep265.requestservice.entity
import com.fasterxml.jackson.databind.JsonNode
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import org.hibernate.annotations.JdbcTypeCode
import org.hibernate.type.SqlTypes
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(
name = "outbox_events",
uniqueConstraints = [
UniqueConstraint(
name = "uk_outbox_events_event_type_aggregate_id",
columnNames = ["event_type", "aggregate_id"],
),
],
)
class OutboxEventEntity(
@Id
@Column(name = "id", nullable = false)
var id: UUID,
@Column(name = "event_type", nullable = false, length = 128)
var eventType: String,
@Column(name = "aggregate_type", nullable = false, length = 64)
var aggregateType: String,
@Column(name = "aggregate_id", nullable = false)
var aggregateId: UUID,
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "payload", nullable = false)
var payload: JsonNode,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
var status: OutboxEventStatus,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
@Column(name = "published_at")
var publishedAt: LocalDateTime? = null,
@Column(name = "error_message", columnDefinition = "TEXT")
var errorMessage: String? = null,
)
@@ -0,0 +1,7 @@
package org.nstart.dep265.requestservice.entity
enum class OutboxEventStatus {
NEW,
PUBLISHED,
FAILED,
}
@@ -0,0 +1,50 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT
import java.util.UUID
@Entity
@Table(name = "request_cells")
class RequestCellEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
var id: Long? = null,
@Column(name = "request_id", nullable = false)
var requestId: UUID = UUID.randomUUID(),
@Column(name = "coverage_percent")
var coveragePercent: Double? = null,
/** Snapshot важности master-заявки, нужный для расчета importance ячейки. */
@Column(name = "importance", nullable = false)
var importance: Double = 0.0,
@Column(name = "contour_wkt", nullable = false, columnDefinition = "TEXT")
var contour: String = "",
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cell_id", nullable = false)
var cell: EarthCellEntity? = null,
) {
fun toDto(): CellRequestDOT {
val dto = CellRequestDOT(
id = id ?: 0,
requestId = requestId.toString(),
covPercent = coveragePercent ?: 0.0,
importance = importance,
contour = contour,
)
return dto
}
}
@@ -0,0 +1,89 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.CollectionTable
import jakarta.persistence.ElementCollection
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.Table
import jakarta.persistence.Version
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "requests")
class RequestEntity(
@Id
@Column(name = "id", nullable = false)
var id: UUID,
@Column(name = "name", nullable = false, length = 255)
var name: String,
@Column(name = "geometry", nullable = false, columnDefinition = "TEXT")
var geometry: String,
@Column(name = "remaining_geometry", nullable = false, columnDefinition = "TEXT")
var remainingGeometry: String,
@Column(name = "geometry_area", nullable = false)
var geometryArea: Double,
@Column(name = "remaining_area", nullable = false)
var remainingArea: Double,
@Column(name = "coverage_required_percent", nullable = false)
var coverageRequiredPercent: Double = 100.0,
@Column(name = "importance", nullable = false)
var importance: Double,
@Column(name = "begin_date_time", nullable = false)
var beginDateTime: LocalDateTime,
@Column(name = "end_date_time", nullable = false)
var endDateTime: LocalDateTime,
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "request_kpp", joinColumns = [JoinColumn(name = "request_id")])
@Column(name = "kpp", nullable = false)
var kpp: MutableList<Int> = mutableListOf(),
@Column(name = "high_priority_transmit", nullable = false)
var highPriorityTransmit: Boolean = false,
@Enumerated(EnumType.STRING)
@Column(name = "survey_type", nullable = false, length = 32)
var surveyType: RequestSurveyType,
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
var status: RequestStatus,
@Column(name = "match_count", nullable = false)
var matchCount: Int = 0,
@Column(name = "last_matched_at")
var lastMatchedAt: LocalDateTime? = null,
@Column(name = "completed_at")
var completedAt: LocalDateTime? = null,
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime,
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime,
@Column(name = "deleted_at")
var deletedAt: LocalDateTime? = null,
/** Версия optimistic locking для безопасных конкурентных обновлений. */
@Version
@Column(name = "version", nullable = false)
var version: Long = 0,
)
@@ -0,0 +1,47 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.util.UUID
@Entity
@Table(name = "request_optics_params")
class RequestOpticsParamsEntity(
@Id
@Column(name = "request_id", nullable = false)
var requestId: UUID,
@Enumerated(EnumType.STRING)
@Column(name = "result_type", nullable = false, length = 32)
var resultType: OpticsResultType,
@Column(name = "resolution", nullable = false)
var resolution: Double,
@Column(name = "sun_angle_min", nullable = false)
var sunAngleMin: Double = 10.0,
@Column(name = "sun_angle_max", nullable = false)
var sunAngleMax: Double = 90.0,
@Column(name = "clouds", nullable = false)
var clouds: Double = 100.0,
)
/**
* Результат оптической съемки.
*/
enum class OpticsResultType {
/** Панхроматическая съемка. */
PANCHROMATIC,
/** Мультиспектральная съемка. */
MULTISPECTRAL,
/** Паншарпенинг. */
PANSHARPENING,
}
@@ -0,0 +1,46 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.LocalDateTime
import java.util.UUID
@Entity
@Table(name = "request_route_matches")
class RequestRouteMatchEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
var id: Long? = null,
@Column(name = "request_id", nullable = false)
var requestId: UUID,
@Column(name = "route_id", nullable = false)
var routeId: UUID,
@Column(name = "original_intersection_geometry", nullable = false, columnDefinition = "TEXT")
var originalIntersectionGeometry: String,
@Column(name = "original_intersection_area", nullable = false)
var originalIntersectionArea: Double,
@Column(name = "applied_intersection_geometry", columnDefinition = "TEXT")
var appliedIntersectionGeometry: String? = null,
@Column(name = "applied_intersection_area", nullable = false)
var appliedIntersectionArea: Double,
@Column(name = "coverage_delta_percent", nullable = false)
var coverageDeltaPercent: Double,
@Column(name = "contributes_to_coverage", nullable = false)
var contributesToCoverage: Boolean,
@Column(name = "matched_at", nullable = false)
var matchedAt: LocalDateTime,
)
@@ -0,0 +1,56 @@
package org.nstart.dep265.requestservice.entity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.util.UUID
@Entity
@Table(name = "request_rsa_params")
class RequestRsaParamsEntity(
@Id
@Column(name = "request_id", nullable = false)
var requestId: UUID,
@Enumerated(EnumType.STRING)
@Column(name = "result_type", nullable = false, length = 32)
var resultType: RsaResultType,
@Column(name = "interferometry", nullable = false)
var interferometry: Boolean = false,
@Enumerated(EnumType.STRING)
@Column(name = "polarisation", nullable = false, length = 8)
var polarisation: RsaPolarisation,
@Column(name = "resolution", nullable = false)
var resolution: Double,
)
/**
* Тип аппаратуры РСА.
*/
enum class RsaResultType {
/** Spotlight режим. */
SPOTLIGHT,
/** Stripmap режим. */
STRIPMAP,
/** ScanSAR режим. */
SCANSAR,
}
/**
* Тип поляризации РСА.
*/
enum class RsaPolarisation {
/** Горизонтальная передача и горизонтальный прием. */
HH,
/** Вертикальная передача и вертикальный прием. */
VV,
}
@@ -0,0 +1,21 @@
package org.nstart.dep265.requestservice.entity
/**
* Бизнес-статус заявки.
*/
enum class RequestStatus {
/** Заявка прошла валидацию и сохранена. */
ACCEPTED,
/** Заявка актуальна для обработки. */
ACTIVE,
/** Заявка выполнилась по проценту покрытия. */
COMPLETED,
/** Интервал заявки завершился до достижения требуемого покрытия. */
EXPIRED,
/** Заявка мягко удалена. */
DELETED,
}
@@ -0,0 +1,15 @@
package org.nstart.dep265.requestservice.entity
/**
* Вычисленный тип заявки по наличию optics/rsa.
*/
enum class RequestSurveyType {
/** В заявке указан только optics. */
OPTICS,
/** В заявке указан только rsa. */
RSA,
/** В заявке указаны optics и rsa. */
COMBINED,
}
@@ -0,0 +1,52 @@
package org.nstart.dep265.requestservice.kafka
import com.fasterxml.jackson.core.type.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import jakarta.validation.ConstraintViolationException
import jakarta.validation.Validator
import org.nstart.dep265.requestservice.mapper.RoutePassportMapper
import org.nstart.dep265.requestservice.service.RouteMatchingService
import org.slf4j.LoggerFactory
import org.springframework.kafka.annotation.KafkaListener
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import space.nstart.pcp.pcp_types_lib.message.KafkaMessage
@Component
class RequestKafkaListener(
private val objectMapper: ObjectMapper,
private val validator: Validator,
private val routeMatchingService: RouteMatchingService,
private val routePassportMapper: RoutePassportMapper,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@KafkaListener(
topics = ["\${app.kafka.topics.route}"],
groupId = "\${spring.kafka.consumer.group-id}-route",
)
fun consumeRoute(message: String) {
try {
val routeMessage = objectMapper.readValue(
message,
object : TypeReference<KafkaMessage<RoutePassportDto>>() {},
)
val route = routePassportMapper.toRouteDto(routeMessage.data)
validate(route)
routeMatchingService.process(route)
} catch (exception: Exception) {
log.error("Failed to process Kafka route payload", exception)
throw exception
}
}
private fun validate(value: Any) {
val violations = validator.validate(value)
if (violations.isNotEmpty()) {
throw ConstraintViolationException(violations)
}
}
}
@@ -0,0 +1,36 @@
package org.nstart.dep265.requestservice.mapper
import org.nstart.dep265.requestservice.dto.RouteAngleRangeDto
import org.nstart.dep265.requestservice.dto.RouteDto
import org.springframework.stereotype.Component
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import kotlin.math.abs
@Component
class RoutePassportMapper {
fun toRouteDto(routePassportDto: RoutePassportDto): RouteDto =
RouteDto(
routeId = routePassportDto.routeId,
intervalBegin = routePassportDto.intervalBegin,
intervalEnd = routePassportDto.intervalEnd,
rollAngle = routePassportDto.rollAngle.toAbsoluteRange(),
geometry = routePassportDto.geometry,
)
private fun AngleRangeDto.toAbsoluteRange(): RouteAngleRangeDto {
val minAbs = abs(min.toDouble())
val maxAbs = abs(max.toDouble())
val absoluteMin = if (min.signum() <= 0 && max.signum() >= 0) {
0.0
} else {
minOf(minAbs, maxAbs)
}
val absoluteMax = maxOf(minAbs, maxAbs)
return RouteAngleRangeDto(
min = absoluteMin,
max = absoluteMax,
)
}
}
@@ -0,0 +1,71 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.util.Optional
import java.util.UUID
interface EarthCellRepository : JpaRepository<EarthCellEntity, Long> {
@Query(
value = """
select earthCell
from EarthCellEntity earthCell
where (:minImportance is null or earthCell.importance > :minImportance)
""",
countQuery = """
select count(earthCell)
from EarthCellEntity earthCell
where (:minImportance is null or earthCell.importance > :minImportance)
"""
)
fun findCells(
@Param("minImportance") minImportance: Double?,
pageable: Pageable,
): Page<EarthCellEntity>
@EntityGraph(attributePaths = ["requestProjections"])
@Query(
"""
select distinct earthCell
from EarthCellEntity earthCell
where (:minImportance is null or earthCell.importance > :minImportance)
order by earthCell.importance desc, earthCell.cellNum asc
"""
)
fun findCellsWithRequestProjections(
@Param("minImportance") minImportance: Double?,
): List<EarthCellEntity>
@Query(
value = "SELECT * FROM earth_cells WHERE ST_Intersects(ST_GeomFromText(:polygon, 4326), contour_geom)",
nativeQuery = true,
)
fun findIntersectingByContour(@Param("polygon") polygonWkt: String): List<EarthCellEntity>
@Query(
value = "SELECT * FROM earth_cells WHERE cell_id IN (SELECT cell_id FROM request_cells WHERE request_id = :requestId)",
nativeQuery = true,
)
fun findByRequestId(@Param("requestId") requestId: UUID): List<EarthCellEntity>
fun findByCellNum(cellNum: Long): Optional<EarthCellEntity>
fun findAllByImportanceGreaterThanOrderByImportanceDesc(importance: Double): List<EarthCellEntity>
@EntityGraph(attributePaths = ["requestProjections"])
@Query("select distinct earthCell from EarthCellEntity earthCell where earthCell.importance > :importance order by earthCell.importance desc")
fun findDetailedByImportanceGreaterThanOrderByImportanceDesc(@Param("importance") importance: Double): List<EarthCellEntity>
@EntityGraph(attributePaths = ["requestProjections"])
@Query("select earthCell from EarthCellEntity earthCell where earthCell.cellId = :cellId")
fun findWithRequestProjectionsByCellId(@Param("cellId") cellId: Long): Optional<EarthCellEntity>
@EntityGraph(attributePaths = ["requestProjections"])
@Query("select earthCell from EarthCellEntity earthCell where earthCell.cellNum = :cellNum")
fun findWithRequestProjectionsByCellNum(@Param("cellNum") cellNum: Long): Optional<EarthCellEntity>
}
@@ -0,0 +1,6 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.springframework.data.jpa.repository.JpaRepository
interface EarthGridSettingsRepository : JpaRepository<EarthGridSettingsEntity, Long>
@@ -0,0 +1,83 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.OutboxEventEntity
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
import java.util.UUID
interface OutboxEventRepository : JpaRepository<OutboxEventEntity, UUID> {
fun existsByEventTypeAndAggregateId(eventType: String, aggregateId: UUID): Boolean
fun findByStatusOrderByCreatedAtAsc(status: OutboxEventStatus, pageable: Pageable): List<OutboxEventEntity>
/**
* Locks publishable REQUEST_COMPLETED events so parallel service instances skip rows already
* being published by another transaction.
*/
@Query(
value = """
SELECT *
FROM outbox_events
WHERE event_type = :eventType
AND status IN ('NEW', 'FAILED')
ORDER BY
CASE WHEN status = 'NEW' THEN 0 ELSE 1 END,
created_at ASC
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
""",
nativeQuery = true,
)
fun findRequestCompletedPublishableForUpdateSkipLocked(
@Param("eventType") eventType: String,
@Param("batchSize") batchSize: Int,
): List<OutboxEventEntity>
/**
* Inserts a new outbox event atomically and treats the event_type + aggregate_id conflict as
* an idempotent duplicate. Production PostgreSQL stores payload as JSONB.
*/
@Modifying
@Query(
value = """
INSERT INTO outbox_events (
id,
event_type,
aggregate_type,
aggregate_id,
payload,
status,
created_at,
published_at,
error_message
)
VALUES (
:id,
:eventType,
:aggregateType,
:aggregateId,
CAST(:payload AS jsonb),
:status,
:createdAt,
NULL,
NULL
)
ON CONFLICT (event_type, aggregate_id) DO NOTHING
""",
nativeQuery = true,
)
fun insertIfAbsent(
@Param("id") id: UUID,
@Param("eventType") eventType: String,
@Param("aggregateType") aggregateType: String,
@Param("aggregateId") aggregateId: UUID,
@Param("payload") payload: String,
@Param("status") status: String,
@Param("createdAt") createdAt: LocalDateTime,
): Int
}
@@ -0,0 +1,30 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
interface RequestCellRepository : JpaRepository<RequestCellEntity, Long> {
@Modifying
@Transactional
@Query("delete from RequestCellEntity requestCell where requestCell.requestId = :requestId")
fun deleteByRequestId(@Param("requestId") requestId: UUID)
fun findByRequestId(requestId: UUID): List<RequestCellEntity>
@EntityGraph(attributePaths = ["cell"])
@Query(
"""
select requestCell
from RequestCellEntity requestCell
where requestCell.requestId = :requestId
order by requestCell.id asc
"""
)
fun findWithCellByRequestId(@Param("requestId") requestId: UUID): List<RequestCellEntity>
}
@@ -0,0 +1,7 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RequestOpticsParamsRepository : JpaRepository<RequestOpticsParamsEntity, UUID>
@@ -0,0 +1,109 @@
package org.nstart.dep265.requestservice.repository
import jakarta.persistence.LockModeType
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
interface RequestRepository : JpaRepository<RequestEntity, UUID> {
@Query(
value = """
select distinct request
from RequestEntity request
left join request.kpp requestKpp
where (:includeDeleted = true or request.status <> :deletedStatus)
and (:status is null or request.status = :status)
and (:surveyType is null or request.surveyType = :surveyType)
and (:kpp is null or requestKpp = :kpp)
and (:highPriorityTransmit is null or request.highPriorityTransmit = :highPriorityTransmit)
and (cast(:beginFrom as LocalDateTime) is null or request.beginDateTime >= :beginFrom)
and (cast(:beginTo as LocalDateTime) is null or request.beginDateTime <= :beginTo)
and (cast(:endFrom as LocalDateTime) is null or request.endDateTime >= :endFrom)
and (cast(:endTo as LocalDateTime) is null or request.endDateTime <= :endTo)
""",
countQuery = """
select count(distinct request)
from RequestEntity request
left join request.kpp requestKpp
where (:includeDeleted = true or request.status <> :deletedStatus)
and (:status is null or request.status = :status)
and (:surveyType is null or request.surveyType = :surveyType)
and (:kpp is null or requestKpp = :kpp)
and (:highPriorityTransmit is null or request.highPriorityTransmit = :highPriorityTransmit)
and (cast(:beginFrom as LocalDateTime) is null or request.beginDateTime >= :beginFrom)
and (cast(:beginTo as LocalDateTime) is null or request.beginDateTime <= :beginTo)
and (cast(:endFrom as LocalDateTime) is null or request.endDateTime >= :endFrom)
and (cast(:endTo as LocalDateTime) is null or request.endDateTime <= :endTo)
"""
)
fun findRequests(
@Param("includeDeleted") includeDeleted: Boolean,
@Param("deletedStatus") deletedStatus: RequestStatus,
@Param("status") status: RequestStatus?,
@Param("surveyType") surveyType: RequestSurveyType?,
@Param("kpp") kpp: Int?,
@Param("highPriorityTransmit") highPriorityTransmit: Boolean?,
@Param("beginFrom") beginFrom: LocalDateTime?,
@Param("beginTo") beginTo: LocalDateTime?,
@Param("endFrom") endFrom: LocalDateTime?,
@Param("endTo") endTo: LocalDateTime?,
pageable: Pageable,
): Page<RequestEntity>
/**
* Возвращает идентификаторы активных заявок, окно которых пересекается с окном маршрута.
*/
@Query(
"""
select request.id
from RequestEntity request
where request.status in :statuses
and request.beginDateTime <= :routeEnd
and request.endDateTime >= :routeBegin
and request.deletedAt is null
"""
)
fun findRequestIdsForRoute(
@Param("statuses") statuses: Collection<RequestStatus>,
@Param("routeBegin") routeBegin: LocalDateTime,
@Param("routeEnd") routeEnd: LocalDateTime,
): List<UUID>
/**
* Сериализует применение маршрута к одной заявке через row-level lock.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"""
select request
from RequestEntity request
where request.id = :id
"""
)
fun findByIdForUpdate(@Param("id") id: UUID): Optional<RequestEntity>
@Query(
"""
select request
from RequestEntity request
where request.beginDateTime < :selectionEnd
and :selectionStart < request.endDateTime
and request.deletedAt is null
"""
)
fun findRequestsIntersectingSelectionInterval(
@Param("selectionStart") selectionStart: LocalDateTime,
@Param("selectionEnd") selectionEnd: LocalDateTime,
): List<RequestEntity>
fun findByStatusIn(statuses: Collection<RequestStatus>): List<RequestEntity>
}
@@ -0,0 +1,11 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RequestRouteMatchRepository : JpaRepository<RequestRouteMatchEntity, Long> {
fun existsByRequestIdAndRouteId(requestId: UUID, routeId: UUID): Boolean
fun deleteByRequestId(requestId: UUID)
}
@@ -0,0 +1,7 @@
package org.nstart.dep265.requestservice.repository
import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID
interface RequestRsaParamsRepository : JpaRepository<RequestRsaParamsEntity, UUID>
@@ -0,0 +1,206 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.dto.CellRequestFragmentResponseDto
import org.nstart.dep265.requestservice.dto.CellSummaryResponseDto
import org.nstart.dep265.requestservice.dto.CellWithRequestsResponseDto
import org.nstart.dep265.requestservice.dto.CellsListResponseDto
import org.nstart.dep265.requestservice.dto.CellsWithRequestsResponseDto
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_types_lib.dto.requests.CellRequestDOT
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
import java.util.UUID
class InvalidCellsQueryException(message: String) : IllegalArgumentException(message)
@Service
class CellsQueryService(
private val earthCellRepository: EarthCellRepository,
private val earthGridCatalogService: EarthGridCatalogService,
) {
@Transactional(readOnly = true)
fun listCells(
minImportance: Double?,
countLat: Int?,
countLong: Int?,
page: Int,
size: Int,
sort: String?,
): CellsListResponseDto {
validateQuery(minImportance, countLat, countLong, page, size)
val cells = earthCellRepository.findCells(
minImportance = minImportance,
pageable = PageRequest.of(page, size, sort.toCellsSort()),
)
return CellsListResponseDto(
items = cells.content.map { earthCell -> earthCell.toSummaryResponse() },
page = page,
size = size,
totalItems = cells.totalElements,
totalPages = cells.totalPages,
)
}
@Transactional(readOnly = true)
fun listCellsWithRequests(
minImportance: Double?,
countLat: Int?,
countLong: Int?,
): CellsWithRequestsResponseDto {
validateWithRequestsQuery(minImportance, countLat, countLong)
val items = if (countLat == null && countLong == null) {
earthCellRepository.findCellsWithRequestProjections(minImportance)
.map { earthCell -> earthCell.toWithRequestsResponse() }
} else {
earthGridCatalogService.allWithRequests(
minImportance = minImportance,
countLat = countLat,
countLong = countLong,
)
.map { earthCell -> earthCell.toWithRequestsResponse() }
}
return CellsWithRequestsResponseDto(items = items)
}
private fun validateQuery(
minImportance: Double?,
countLat: Int?,
countLong: Int?,
page: Int,
size: Int,
) {
if (minImportance != null && minImportance < 0.0) {
throw InvalidCellsQueryException("minImportance must be greater than or equal to 0")
}
if (countLat != null && countLat <= 0) {
throw InvalidCellsQueryException("countLat must be greater than 0")
}
if (countLong != null && countLong <= 0) {
throw InvalidCellsQueryException("countLong must be greater than 0")
}
if (countLat != null || countLong != null) {
throw InvalidCellsQueryException("Cell aggregation by countLat/countLong is not implemented yet")
}
if (page < 0) {
throw InvalidCellsQueryException("page must be greater than or equal to 0")
}
if (size < 1 || size > 500) {
throw InvalidCellsQueryException("size must be between 1 and 500")
}
}
private fun validateWithRequestsQuery(
minImportance: Double?,
countLat: Int?,
countLong: Int?,
) {
if (minImportance != null && minImportance < 0.0) {
throw InvalidCellsQueryException("minImportance must be greater than or equal to 0")
}
if (countLat != null && countLat <= 0) {
throw InvalidCellsQueryException("countLat must be greater than 0")
}
if (countLong != null && countLong <= 0) {
throw InvalidCellsQueryException("countLong must be greater than 0")
}
if ((countLat == null) != (countLong == null)) {
throw InvalidCellsQueryException("countLat and countLong must be provided together")
}
}
private fun String?.toCellsSort(): Sort {
if (isNullOrBlank()) {
return DEFAULT_SORT
}
val parts = split(",").map { part -> part.trim() }.filter { part -> part.isNotEmpty() }
val field = parts.firstOrNull() ?: DEFAULT_SORT_FIELD
val direction = parts.getOrNull(1)?.let { value ->
when (value.lowercase()) {
"asc" -> Sort.Direction.ASC
"desc" -> Sort.Direction.DESC
else -> throw InvalidCellsQueryException("Unsupported sort direction: $value")
}
} ?: Sort.Direction.ASC
val property = ALLOWED_SORT_FIELDS[field]
?: throw InvalidCellsQueryException("Unsupported sort field: $field")
val requestedSort = Sort.by(direction, property)
return if (property == "cellNum") {
requestedSort
} else {
requestedSort.and(Sort.by(Sort.Direction.ASC, "cellNum"))
}
}
private fun EarthCellEntity.toSummaryResponse(): CellSummaryResponseDto {
return CellSummaryResponseDto(
cellNum = cellNum,
latitude = latitude ?: 0.0,
longitude = longitude ?: 0.0,
importance = importance,
contour = contour,
)
}
private fun EarthCellEntity.toWithRequestsResponse(): CellWithRequestsResponseDto {
return CellWithRequestsResponseDto(
cellNum = cellNum,
latitude = latitude ?: 0.0,
longitude = longitude ?: 0.0,
importance = importance,
contour = contour,
requests = requestProjections.map { requestProjection -> requestProjection.toFragmentResponse() },
)
}
private fun RequestCellEntity.toFragmentResponse(): CellRequestFragmentResponseDto {
return CellRequestFragmentResponseDto(
requestId = requestId,
contour = contour,
coveragePercent = coveragePercent ?: 0.0,
importance = importance,
)
}
private fun EarthCellWithRequestsDTO.toWithRequestsResponse(): CellWithRequestsResponseDto {
return CellWithRequestsResponseDto(
cellNum = num,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = contour,
requests = requests.map { request -> request.toFragmentResponse() },
)
}
private fun CellRequestDOT.toFragmentResponse(): CellRequestFragmentResponseDto {
return CellRequestFragmentResponseDto(
requestId = UUID.fromString(requestId),
contour = contour,
coveragePercent = covPercent,
importance = importance,
)
}
private companion object {
const val DEFAULT_SORT_FIELD = "importance"
val DEFAULT_SORT: Sort = Sort.by(Sort.Direction.DESC, "importance")
.and(Sort.by(Sort.Direction.ASC, "cellNum"))
val ALLOWED_SORT_FIELDS: Map<String, String> = mapOf(
"cellNum" to "cellNum",
"importance" to "importance",
"latitude" to "latitude",
"longitude" to "longitude",
)
}
}
@@ -0,0 +1,40 @@
package org.nstart.dep265.requestservice.service
import org.geotools.geometry.jts.JTS
import org.geotools.referencing.CRS
import org.springframework.stereotype.Service
@Service
class CompatGeometryService(
private val geometryService: GeometryService,
) {
fun calculateAreaSqKm(wkt: String): Double {
/** Исходная геометрия берется в WGS84, как и в legacy earth-grid сервисе. */
val geometry = geometryService.parseGeometry(wkt)
val sourceCrs = CRS.decode("EPSG:4326")
/** Проекция World Mollweide используется для стабильного расчета площади. */
val targetCrs = CRS.decode("EPSG:54009")
val transform = CRS.findMathTransform(sourceCrs, targetCrs)
val projectedGeometry = JTS.transform(geometry, transform)
val areaSqKm = projectedGeometry.area / 1_000_000.0
return areaSqKm
}
fun toGeometryCollection(wkts: Iterable<String>): String {
/** Ненулевые геометрии фильтруются, чтобы не строить битый WKT. */
val nonBlankWkts = wkts.filter { wkt -> wkt.isNotBlank() }.toList()
if (nonBlankWkts.isEmpty()) {
return ""
}
val geometryCollection = buildString {
append("GEOMETRYCOLLECTION(")
append(nonBlankWkts.joinToString(","))
append(")")
}
return geometryCollection
}
}
@@ -0,0 +1,271 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellDTO
import space.nstart.pcp.pcp_types_lib.dto.requests.EarthCellWithRequestsDTO
import kotlin.math.PI
import kotlin.math.floor
import kotlin.math.truncate
@Service
class EarthGridCatalogService(
private val earthCellRepository: EarthCellRepository,
private val earthGridSettingsRepository: EarthGridSettingsRepository,
private val compatGeometryService: CompatGeometryService,
@Value("\${app.grid.enabled:true}")
private val gridEnabled: Boolean,
) {
private data class AggregatedGridKey(
val latitudeGroup: Long,
val longitudeGroup: Long,
)
@Transactional(readOnly = true)
fun all(countLat: Int? = null, countLong: Int? = null): List<EarthCellDTO> {
val earthCells = earthCellRepository.findAll()
return aggregateIfRequested(earthCells, countLat, countLong)
}
@Transactional(readOnly = true)
fun paintByImportance(importance: Double): String {
val earthCells = earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(importance)
/** Контуры ячеек складываются в GEOMETRYCOLLECTION для legacy paint endpoint. */
val geometryCollection = compatGeometryService.toGeometryCollection(earthCells.map { earthCell -> earthCell.contour })
return geometryCollection
}
@Transactional(readOnly = true)
fun byImportance(
importance: Double,
countLat: Int? = null,
countLong: Int? = null,
): List<EarthCellDTO> {
val earthCells = earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(importance)
return aggregateIfRequested(earthCells, countLat, countLong)
}
@Transactional(readOnly = true)
fun allWithRequests(
minImportance: Double? = null,
countLat: Int? = null,
countLong: Int? = null,
): List<EarthCellWithRequestsDTO> {
val earthCells = earthCellRepository.findCellsWithRequestProjections(minImportance)
return aggregateWithRequestsIfRequested(earthCells, countLat, countLong)
}
@Transactional(readOnly = true)
fun byId(cellId: Long) = earthCellRepository.findWithRequestProjectionsByCellId(cellId)
.map { earthCell -> earthCell.toDtoWithRequests() }
@Transactional(readOnly = true)
fun byNum(cellNum: Long) = earthCellRepository.findWithRequestProjectionsByCellNum(cellNum)
.map { earthCell -> earthCell.toDtoWithRequests() }
@Transactional(readOnly = true)
fun byNumForPaint(cellNum: Long): String {
val earthCell = earthCellRepository.findWithRequestProjectionsByCellNum(cellNum).orElse(null) ?: return ""
/** Контур самой ячейки должен идти первым, как это делал старый earth-grid. */
val wkts = mutableListOf<String>()
wkts.add(earthCell.contour)
wkts.addAll(earthCell.requestProjections.map { requestProjection -> requestProjection.contour })
val geometryCollection = compatGeometryService.toGeometryCollection(wkts)
return geometryCollection
}
@Transactional
fun init(step: Double) {
require(step > 0.0) { "Шаг сетки должен быть больше 0" }
updateCalculationStep(step)
if (!gridEnabled) {
return
}
/** Все старые ячейки удаляются, потому что init — это полный rebuild каталога. */
earthCellRepository.deleteAllInBatch()
/** Текущая широта стартует по правилам old earth-grid сервиса. */
var latitude = -90 + step
while (latitude <= 90) {
val line = mutableListOf<EarthCellEntity>()
var longitude = 0.0
while (longitude < (360-0.001)) {
/** Номер ячейки вычисляется legacy-формулой через индексы b/l. */
val cellNum = getInd(latitude * PI / 180.0, longitude * PI / 180.0)
/** Контур повторяет старую геометрию earth-grid 1x1 градус. */
val contour = "POLYGON (($longitude $latitude, ${longitude - step} $latitude, ${longitude - step} ${latitude - step}, $longitude ${latitude - step}, $longitude $latitude))"
val earthCell = EarthCellEntity(
cellId = null,
cellNum = cellNum,
longitude = longitude,
latitude = latitude,
importance = 0.0,
contour = contour,
)
line.add(earthCell)
longitude += step
}
earthCellRepository.saveAll(line)
latitude += step
}
}
private fun updateCalculationStep(step: Double) {
val settings = earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
.orElseGet {
EarthGridSettingsEntity(
settingsId = EarthGridSettingsEntity.DEFAULT_SETTINGS_ID,
calculationStep = EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP,
)
}
settings.calculationStep = step
earthGridSettingsRepository.save(settings)
}
private fun aggregateIfRequested(
earthCells: List<EarthCellEntity>,
countLat: Int?,
countLong: Int?,
): List<EarthCellDTO> {
if (countLat == null && countLong == null) {
return earthCells.map { earthCell -> earthCell.toDto() }
}
val latitudeCount = countLat ?: 1
val longitudeCount = countLong ?: 1
require(latitudeCount > 0) { "Параметр countLat должен быть больше 0" }
require(longitudeCount > 0) { "Параметр countLong должен быть больше 0" }
val calculationStep = currentCalculationStep()
return earthCells
.groupBy { earthCell -> aggregationKey(earthCell, calculationStep, latitudeCount, longitudeCount) }
.values
.map { group -> aggregateCells(group, calculationStep) }
.sortedWith(compareByDescending<EarthCellDTO> { it.importance }.thenBy { it.num })
}
private fun aggregateWithRequestsIfRequested(
earthCells: List<EarthCellEntity>,
countLat: Int?,
countLong: Int?,
): List<EarthCellWithRequestsDTO> {
if (countLat == null && countLong == null) {
return earthCells.map { earthCell -> earthCell.toDtoWithRequests() }
}
val latitudeCount = countLat ?: 1
val longitudeCount = countLong ?: 1
require(latitudeCount > 0) { "Параметр countLat должен быть больше 0" }
require(longitudeCount > 0) { "Параметр countLong должен быть больше 0" }
val calculationStep = currentCalculationStep()
return earthCells
.groupBy { earthCell -> aggregationKey(earthCell, calculationStep, latitudeCount, longitudeCount) }
.values
.map { group -> aggregateCellsWithRequests(group, calculationStep) }
.sortedWith(compareByDescending<EarthCellWithRequestsDTO> { it.importance }.thenBy { it.num })
}
private fun currentCalculationStep(): Double =
earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
.map { settings -> settings.calculationStep }
.orElse(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP)
private fun aggregationKey(
earthCell: EarthCellEntity,
calculationStep: Double,
countLat: Int,
countLong: Int,
): AggregatedGridKey {
val latitude = earthCell.latitude ?: 0.0
val longitude = earthCell.longitude ?: 0.0
val latitudeIndex = floor((latitude - (-90.0 + calculationStep)) / calculationStep).toLong().coerceAtLeast(0L)
val longitudeIndex = floor(longitude / calculationStep).toLong().coerceAtLeast(0L)
return AggregatedGridKey(
latitudeGroup = latitudeIndex / countLat,
longitudeGroup = longitudeIndex / countLong,
)
}
private fun aggregateCells(
earthCells: List<EarthCellEntity>,
calculationStep: Double,
): EarthCellDTO {
val minLongitude = earthCells.minOf { earthCell -> (earthCell.longitude ?: 0.0) - calculationStep }
val maxLongitude = earthCells.maxOf { earthCell -> earthCell.longitude ?: 0.0 }
val minLatitude = earthCells.minOf { earthCell -> (earthCell.latitude ?: 0.0) - calculationStep }
val maxLatitude = earthCells.maxOf { earthCell -> earthCell.latitude ?: 0.0 }
val importance = earthCells.sumOf { earthCell -> earthCell.importance }
val firstCell = earthCells.minWith(compareBy<EarthCellEntity> { it.latitude ?: 0.0 }.thenBy { it.longitude ?: 0.0 })
val contour = "POLYGON (($minLongitude $minLatitude, $maxLongitude $minLatitude, $maxLongitude $maxLatitude, $minLongitude $maxLatitude, $minLongitude $minLatitude))"
return EarthCellDTO(
id = firstCell.cellId ?: firstCell.cellNum,
num = firstCell.cellNum,
latitude = (minLatitude + maxLatitude) / 2.0,
longitude = (minLongitude + maxLongitude) / 2.0,
importance = importance,
contour = contour,
)
}
private fun aggregateCellsWithRequests(
earthCells: List<EarthCellEntity>,
calculationStep: Double,
): EarthCellWithRequestsDTO {
val aggregate = aggregateCells(earthCells, calculationStep)
return EarthCellWithRequestsDTO(
id = aggregate.id,
num = aggregate.num,
latitude = aggregate.latitude,
longitude = aggregate.longitude,
importance = aggregate.importance,
contour = aggregate.contour,
requests = earthCells.flatMap { earthCell -> earthCell.requestProjections.map { it.toDto() } },
)
}
fun getBInd(latitudeRadians: Double): Long {
var latitudeDegrees = latitudeRadians * 180.0 / PI
latitudeDegrees = 90.0 - latitudeDegrees
var index = truncate(latitudeDegrees).toLong()
if (index > 180) {
index = 180
}
return index
}
fun getLInd(longitudeRadians: Double): Long {
var longitudeDegrees = longitudeRadians * 180.0 / PI
if (longitudeDegrees < 0) {
longitudeDegrees += 360.0
}
var index = truncate(longitudeDegrees).toLong()
if (index > 359) {
index = 359
}
return index
}
fun getInd(latitudeRadians: Double, longitudeRadians: Double): Long {
/** Итоговый номер повторяет алгоритм old earth-grid сервиса. */
val cellNum = getBInd(latitudeRadians) * 360 + getLInd(longitudeRadians)
return cellNum
}
}
@@ -0,0 +1,118 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryCollection
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.locationtech.jts.io.WKTReader
import org.locationtech.jts.io.WKTWriter
import org.springframework.stereotype.Service
import java.io.StringReader
import kotlin.math.max
import kotlin.math.min
@Service
class GeometryService {
private val wktReader = WKTReader()
private val wktWriter = WKTWriter()
fun parseGeometry(wkt: String): Geometry {
val geometry = wktReader.read(StringReader(wkt))
return geometry
}
fun parsePolygonalGeometry(wkt: String): Geometry {
val geometry = parseGeometry(wkt)
return keepPolygonalGeometry(geometry)
}
fun toWkt(geometry: Geometry): String {
val wkt = wktWriter.write(geometry)
return wkt
}
fun area(geometry: Geometry): Double {
val area = geometry.area
return area
}
fun cutCoverage(remainingGeometryWkt: String, routeGeometryWkt: String): GeometryCutResult {
val remainingGeometry = parsePolygonalGeometry(remainingGeometryWkt)
val routeGeometry = parsePolygonalGeometry(routeGeometryWkt)
val intersection = keepPolygonalGeometry(remainingGeometry.intersection(routeGeometry))
val coveredArea = intersection.area
if (coveredArea <= 0.0) {
val result = GeometryCutResult(
remainingGeometryWkt = toWkt(remainingGeometry),
remainingArea = remainingGeometry.area,
coveredArea = 0.0,
hasCoverage = false,
)
return result
}
val updatedRemainingGeometry = keepPolygonalGeometry(remainingGeometry.difference(routeGeometry))
val result = GeometryCutResult(
remainingGeometryWkt = toWkt(updatedRemainingGeometry),
remainingArea = max(updatedRemainingGeometry.area, 0.0),
coveredArea = coveredArea,
hasCoverage = true,
)
return result
}
fun coveredPercent(originalArea: Double, remainingArea: Double): Double {
if (originalArea <= 0.0) {
return 0.0
}
val coveredArea = max(originalArea - remainingArea, 0.0)
val coveredPercent = (coveredArea / originalArea) * 100.0
return min(max(coveredPercent, 0.0), 100.0)
}
fun keepPolygonalGeometry(geometry: Geometry): Geometry {
val polygonalGeometry = when (geometry) {
is Polygon -> geometry
is MultiPolygon -> geometry
is GeometryCollection -> {
val polygons = mutableListOf<Polygon>()
collectPolygons(geometry, polygons)
when {
polygons.isEmpty() -> geometry.factory.createPolygon()
polygons.size == 1 -> polygons.first()
else -> geometry.factory.createMultiPolygon(polygons.toTypedArray())
}
}
else -> geometry.factory.createPolygon()
}
return polygonalGeometry
}
private fun collectPolygons(geometry: Geometry, polygons: MutableList<Polygon>) {
when (geometry) {
is Polygon -> if (!geometry.isEmpty) {
polygons.add(geometry)
}
is MultiPolygon,
is GeometryCollection -> {
for (index in 0 until geometry.numGeometries) {
val nestedGeometry = geometry.getGeometryN(index)
collectPolygons(nestedGeometry, polygons)
}
}
}
}
}
data class GeometryCutResult(
val remainingGeometryWkt: String,
val remainingArea: Double,
val coveredArea: Double,
val hasCoverage: Boolean,
)
@@ -0,0 +1,21 @@
package org.nstart.dep265.requestservice.service
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Service
@Service
class GridRebuildLockService(
private val jdbcTemplate: JdbcTemplate,
) {
fun tryAcquireTransactionLock(): Boolean {
return jdbcTemplate.queryForObject(
"select pg_try_advisory_xact_lock(?)",
Boolean::class.java,
GRID_REBUILD_LOCK_KEY,
) == true
}
private companion object {
const val GRID_REBUILD_LOCK_KEY = 26_501_001L
}
}
@@ -0,0 +1,51 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.OffsetDateTime
import java.time.ZoneOffset
class GridRebuildAlreadyRunningException : RuntimeException("Grid rebuild is already running")
@Service
class GridRebuildService(
private val gridSettingsService: GridSettingsService,
private val earthGridCatalogService: EarthGridCatalogService,
private val requestCellRepository: RequestCellRepository,
private val requestRepository: RequestRepository,
private val requestGridProjectionService: RequestGridProjectionService,
private val earthCellRepository: EarthCellRepository,
private val gridRebuildLockService: GridRebuildLockService,
) {
@Transactional
fun rebuildGrid(): GridRebuildResponseDto {
if (!gridRebuildLockService.tryAcquireTransactionLock()) {
throw GridRebuildAlreadyRunningException()
}
val settings = gridSettingsService.getSettings()
val activeRequests = requestRepository.findByStatusIn(ACTIVE_REQUEST_STATUSES)
requestCellRepository.deleteAllInBatch()
earthGridCatalogService.init(settings.calculationStep)
activeRequests.forEach { request ->
requestGridProjectionService.rebuildProjection(request)
}
return GridRebuildResponseDto(
cellsCount = earthCellRepository.count(),
rebuiltRequestProjectionsCount = activeRequests.size.toLong(),
rebuiltAt = OffsetDateTime.now(ZoneOffset.UTC),
)
}
private companion object {
val ACTIVE_REQUEST_STATUSES = setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE)
}
}
@@ -0,0 +1,57 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.dto.GridSettingsResponseDto
import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
class InvalidGridSettingsException(message: String) : IllegalArgumentException(message)
@Service
class GridSettingsService(
private val earthGridSettingsRepository: EarthGridSettingsRepository,
) {
@Transactional
fun getSettings(): GridSettingsResponseDto {
return getOrCreateSettings().toResponse()
}
@Transactional
fun updateSettings(request: UpdateGridSettingsRequestDto): GridSettingsResponseDto {
val calculationStep = request.calculationStep
?: throw InvalidGridSettingsException("calculationStep must be provided")
validateCalculationStep(calculationStep)
val settings = getOrCreateSettings()
settings.calculationStep = calculationStep
return earthGridSettingsRepository.save(settings).toResponse()
}
private fun getOrCreateSettings(): EarthGridSettingsEntity {
return earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
.orElseGet {
earthGridSettingsRepository.save(
EarthGridSettingsEntity(
settingsId = EarthGridSettingsEntity.DEFAULT_SETTINGS_ID,
calculationStep = EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP,
)
)
}
}
private fun validateCalculationStep(calculationStep: Double) {
if (calculationStep <= 0.0) {
throw InvalidGridSettingsException("calculationStep must be greater than 0")
}
}
private fun EarthGridSettingsEntity.toResponse(): GridSettingsResponseDto {
return GridSettingsResponseDto(
settingsId = settingsId,
calculationStep = calculationStep,
)
}
}
@@ -0,0 +1,175 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.Coordinate
import org.locationtech.jts.geom.CoordinateSequenceFilter
import org.locationtech.jts.geom.Envelope
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.GeometryCollection
import org.locationtech.jts.geom.GeometryFactory
import org.locationtech.jts.geom.LinearRing
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import kotlin.math.abs
import kotlin.math.floor
/**
* Утилита нормализации долгот для геометрий, переходящих через нулевой меридиан.
*/
object LongitudeWrapGeometry {
private val geometryFactory = GeometryFactory()
/**
* Переводит геометрию в непрерывный диапазон долгот вокруг [0..360].
*/
fun normalizeToContinuous360(geometry: Geometry): Geometry {
val normalized = shiftNegativeLongitudesTo360(geometry.copy())
return when (normalized) {
is Polygon -> normalizePolygon(normalized)
is MultiPolygon -> normalizeMultiPolygon(normalized)
else -> normalized
}.let { candidate -> if (candidate.isValid) candidate else candidate.buffer(0.0) }
}
/**
* Строит поисковые варианты geometry в соседних полосах долгот.
*/
fun buildSearchVariants(geometry: Geometry): List<Geometry> {
val normalized = normalizeToContinuous360(geometry)
return listOf(
shiftLongitude(normalized, -360.0),
normalized,
shiftLongitude(normalized, 360.0),
).distinctBy { variant -> variant.norm().toText() }
}
/**
* Выравнивает геометрию к полосе долгот опорной геометрии.
*/
fun alignToReference(geometry: Geometry, reference: Geometry): Geometry {
val normalizedReference = normalizeToContinuous360(reference)
val referenceCenter = envelopeCenterX(normalizedReference.envelopeInternal)
return buildSearchVariants(geometry)
.minByOrNull { candidate -> abs(envelopeCenterX(candidate.envelopeInternal) - referenceCenter) }
?: normalizeToContinuous360(geometry)
}
private fun shiftNegativeLongitudesTo360(geometry: Geometry): Geometry {
geometry.apply(object : CoordinateSequenceFilter {
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
val longitude = seq.getX(i)
if (longitude < 0.0) {
seq.setOrdinate(i, 0, longitude + 360.0)
}
}
override fun isDone(): Boolean = false
override fun isGeometryChanged(): Boolean = true
})
geometry.geometryChanged()
return geometry
}
private fun normalizePolygon(polygon: Polygon): Geometry {
val shell = unwrapRing(polygon.exteriorRing)
val holes = Array(polygon.numInteriorRing) { index ->
unwrapRing(polygon.getInteriorRingN(index))
}
return shiftIntoCanonicalBand(repairGeometry(polygon.factory.createPolygon(shell, holes)))
}
private fun normalizeMultiPolygon(multiPolygon: MultiPolygon): Geometry {
val parts = mutableListOf<Polygon>()
for (index in 0 until multiPolygon.numGeometries) {
collectPolygons(normalizePolygon(multiPolygon.getGeometryN(index) as Polygon), parts)
}
return when (parts.size) {
0 -> geometryFactory.createPolygon()
1 -> parts.first()
else -> geometryFactory.createMultiPolygon(parts.toTypedArray())
}
}
private fun collectPolygons(geometry: Geometry, parts: MutableList<Polygon>) {
when (geometry) {
is Polygon -> parts += geometry
is MultiPolygon -> {
for (index in 0 until geometry.numGeometries) {
parts += geometry.getGeometryN(index) as Polygon
}
}
is GeometryCollection -> {
for (index in 0 until geometry.numGeometries) {
collectPolygons(geometry.getGeometryN(index), parts)
}
}
}
}
private fun repairGeometry(geometry: Geometry): Geometry =
if (geometry.isValid) geometry else geometry.buffer(0.0)
private fun shiftIntoCanonicalBand(geometry: Geometry): Geometry {
val minX = geometry.coordinates.minOfOrNull { coordinate -> coordinate.x } ?: return geometry
if (minX >= 0.0) {
return geometry
}
val shift = 360.0 * (floor(-minX / 360.0) + 1.0)
return shiftLongitude(geometry, shift)
}
private fun shiftLongitude(geometry: Geometry, delta: Double): Geometry {
if (delta == 0.0) {
return geometry
}
val shifted = geometry.copy()
shifted.apply(object : CoordinateSequenceFilter {
override fun filter(seq: org.locationtech.jts.geom.CoordinateSequence, i: Int) {
seq.setOrdinate(i, 0, seq.getX(i) + delta)
}
override fun isDone(): Boolean = false
override fun isGeometryChanged(): Boolean = true
})
shifted.geometryChanged()
return shifted
}
private fun envelopeCenterX(envelope: Envelope): Double = (envelope.minX + envelope.maxX) / 2.0
private fun unwrapRing(ring: org.locationtech.jts.geom.LineString): LinearRing {
val source = ring.coordinates
if (source.isEmpty()) {
return ring.factory.createLinearRing()
}
val first = source.first()
val unwrapped = ArrayList<Coordinate>(source.size)
var previousX = first.x
unwrapped.add(Coordinate(previousX, first.y, first.z))
for (index in 1 until source.size - 1) {
val current = source[index]
var adjustedX = current.x
while (adjustedX - previousX > 180.0) {
adjustedX -= 360.0
}
while (adjustedX - previousX < -180.0) {
adjustedX += 360.0
}
unwrapped.add(Coordinate(adjustedX, current.y, current.z))
previousX = adjustedX
}
val start = unwrapped.first()
unwrapped.add(Coordinate(start.x, start.y, start.z))
return ring.factory.createLinearRing(unwrapped.toTypedArray())
}
}
@@ -0,0 +1,114 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.config.OutboxProperties
import org.nstart.dep265.requestservice.entity.OutboxEventEntity
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
import java.util.concurrent.ExecutionException
@Service
class OutboxPublisherService(
private val outboxEventRepository: OutboxEventRepository,
private val kafkaTemplate: KafkaTemplate<String, String>,
private val outboxProperties: OutboxProperties,
@param:Qualifier("outboxTransactionOperations")
private val transactionOperations: TransactionOperations,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Scheduled(fixedDelayString = "\${pcp.outbox.publish-fixed-delay-ms:5000}")
fun publishScheduledBatch() {
publishPending()
}
fun publishPending(): Int {
val batchSize = outboxProperties.publishBatchSize.coerceAtLeast(0)
if (batchSize == 0) {
return 0
}
return try {
transactionOperations.execute(
TransactionCallback {
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked(
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
batchSize = batchSize,
).distinctBy { it.id }
events.forEach { event ->
publishAndUpdateStatus(event)
}
events.size
},
) ?: 0
} catch (exception: Exception) {
log.error("Failed to process REQUEST_COMPLETED outbox event batch", exception)
0
}
}
private fun publishAndUpdateStatus(event: OutboxEventEntity) {
try {
kafkaTemplate
.send(
outboxProperties.requestCompletedTopic,
event.aggregateId.toString(),
event.payload.toString(),
)
.get()
event.status = OutboxEventStatus.PUBLISHED
event.publishedAt = LocalDateTime.now()
event.errorMessage = null
outboxEventRepository.save(event)
log.info(
"Published REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}",
event.id,
event.aggregateId,
outboxProperties.requestCompletedTopic,
)
} catch (exception: Exception) {
val publishException = exception.unwrapExecutionException()
event.status = OutboxEventStatus.FAILED
event.errorMessage = publishException.shortMessage()
outboxEventRepository.save(event)
log.warn(
"Failed to publish REQUEST_COMPLETED outbox event: eventId={}, requestId={}, topic={}",
event.id,
event.aggregateId,
outboxProperties.requestCompletedTopic,
publishException,
)
}
}
private fun Exception.unwrapExecutionException(): Throwable {
return if (this is ExecutionException && cause != null) {
cause!!
} else {
this
}
}
private fun Throwable.shortMessage(): String {
val message = message?.takeIf { it.isNotBlank() } ?: javaClass.simpleName
return message.take(MAX_ERROR_MESSAGE_LENGTH)
}
companion object {
private const val MAX_ERROR_MESSAGE_LENGTH = 512
}
}
@@ -0,0 +1,8 @@
package org.nstart.dep265.requestservice.service
import java.util.UUID
class RequestAlreadyExistsException(
val requestId: UUID,
cause: Throwable? = null,
) : RuntimeException("Request already exists: $requestId", cause)
@@ -0,0 +1,68 @@
package org.nstart.dep265.requestservice.service
import com.fasterxml.jackson.databind.ObjectMapper
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.util.UUID
@Service
class RequestCompletedOutboxService(
private val outboxEventRepository: OutboxEventRepository,
private val objectMapper: ObjectMapper,
private val geometryService: GeometryService,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Transactional(propagation = Propagation.MANDATORY)
fun createRequestCompletedIfAbsent(request: RequestEntity, eventCreatedAt: LocalDateTime) {
val completedAt = request.completedAt ?: return
val payload = RequestCompletedPayload(
requestId = request.id,
status = RequestStatus.COMPLETED.name,
completedAt = completedAt,
coveragePercent = geometryService.coveredPercent(request.geometryArea, request.remainingArea),
matchedAt = request.lastMatchedAt,
eventCreatedAt = eventCreatedAt,
)
val inserted = outboxEventRepository.insertIfAbsent(
id = UUID.randomUUID(),
eventType = REQUEST_COMPLETED_EVENT_TYPE,
aggregateType = REQUEST_AGGREGATE_TYPE,
aggregateId = request.id,
payload = objectMapper.writeValueAsString(payload),
status = OutboxEventStatus.NEW.name,
createdAt = eventCreatedAt,
)
if (inserted == 0) {
log.debug(
"REQUEST_COMPLETED outbox event already exists for requestId={}",
request.id,
)
}
}
private data class RequestCompletedPayload(
val requestId: UUID,
val status: String,
val completedAt: LocalDateTime,
val coveragePercent: Double,
val matchedAt: LocalDateTime?,
val eventCreatedAt: LocalDateTime,
)
companion object {
const val REQUEST_COMPLETED_EVENT_TYPE = "REQUEST_COMPLETED"
const val REQUEST_AGGREGATE_TYPE = "REQUEST"
}
}
@@ -0,0 +1,122 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.Geometry
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service
class RequestGridProjectionService(
private val requestRepository: RequestRepository,
private val earthCellRepository: EarthCellRepository,
private val requestCellRepository: RequestCellRepository,
private val geometryService: GeometryService,
@Value("\${app.grid.enabled:true}")
private val gridEnabled: Boolean,
) {
private val log = LoggerFactory.getLogger(this::class.java)
@Transactional
fun rebuildProjection(requestId: UUID) {
if (!gridEnabled) {
return
}
val requestEntity = requestRepository.findById(requestId).orElse(null) ?: return
rebuildProjection(requestEntity)
}
@Transactional
fun rebuildProjection(requestEntity: RequestEntity) {
if (!gridEnabled) {
return
}
/** Старая проекция удаляется целиком, потому что rebuild должен быть идемпотентным. */
requestCellRepository.deleteByRequestId(requestEntity.id)
val projectionGeometry = geometryService.parsePolygonalGeometry(requestEntity.geometry)
/** Для пересечения с сеткой [0..360] геометрию нужно перевести в непрерывный диапазон. */
val normalizedProjectionGeometry = LongitudeWrapGeometry.normalizeToContinuous360(projectionGeometry)
/** Поиск по одному WKT недостаточен для заявок, пересекающих нулевой меридиан. */
val intersectingCells = LongitudeWrapGeometry.buildSearchVariants(normalizedProjectionGeometry)
.flatMap { projectionVariant ->
earthCellRepository.findIntersectingByContour(geometryService.toWkt(projectionVariant))
}
.distinctBy { earthCell -> earthCell.cellId ?: earthCell.cellNum }
val requestCellEntities = mutableListOf<RequestCellEntity>()
intersectingCells.forEach { earthCell ->
val cellGeometry = geometryService.parsePolygonalGeometry(earthCell.contour)
/** Ячейка выравнивается к полосе долгот заявки, чтобы точное пересечение было корректным. */
val alignedCellGeometry = LongitudeWrapGeometry.alignToReference(cellGeometry, normalizedProjectionGeometry)
/** Пересечение заявка ∩ ячейка может дать Polygon, MultiPolygon или пустую геометрию. */
val intersectionGeometry = geometryService.keepPolygonalGeometry(
normalizedProjectionGeometry.intersection(alignedCellGeometry)
)
when (intersectionGeometry) {
is Polygon -> addProjectionPiece(requestCellEntities, requestEntity, earthCell, cellGeometry, intersectionGeometry)
is MultiPolygon -> {
for (geometryIndex in 0 until intersectionGeometry.numGeometries) {
/** Отдельная часть MultiPolygon добавляется как самостоятельный request_cells fragment. */
val geometryPart = intersectionGeometry.getGeometryN(geometryIndex)
if (geometryPart is Polygon && !geometryPart.isEmpty) {
addProjectionPiece(requestCellEntities, requestEntity, earthCell, cellGeometry, geometryPart)
}
}
}
}
}
requestCellRepository.saveAll(requestCellEntities)
log.info("Grid projection rebuilt: requestId={}, fragments={}", requestEntity.id, requestCellEntities.size)
}
private fun addProjectionPiece(
requestCellEntities: MutableList<RequestCellEntity>,
requestEntity: RequestEntity,
earthCell: EarthCellEntity,
cellGeometry: Geometry,
intersectionPolygon: Polygon,
) {
if (intersectionPolygon.isEmpty || cellGeometry.area <= 0.0) {
return
}
/** Процент покрытия нужен trigger'у importance и compat DTO CellRequestDOT. */
val coveragePercent = (intersectionPolygon.area / cellGeometry.area) * 100.0
val normalizedForCellBand = LongitudeWrapGeometry.alignToReference(intersectionPolygon, cellGeometry)
val intersectionWkt = geometryService.toWkt(normalizedForCellBand)
val requestCellEntity = RequestCellEntity(
id = null,
requestId = requestEntity.id,
coveragePercent = coveragePercent,
importance = requestEntity.importance,
contour = intersectionWkt,
cell = earthCell,
)
requestCellEntities.add(requestCellEntity)
}
}
@@ -0,0 +1,55 @@
package org.nstart.dep265.requestservice.service
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.springframework.stereotype.Service
import java.time.LocalDateTime
@Service
class RequestLifecycleService(
private val geometryService: GeometryService,
) {
fun refreshStatus(request: RequestEntity, now: LocalDateTime): RequestLifecycleRefreshResult {
if (request.status == RequestStatus.DELETED) {
return RequestLifecycleRefreshResult(statusChanged = false, completedNow = false)
}
val previousStatus: RequestStatus = request.status
val previousCompletedAt = request.completedAt
val coveredPercent: Double = geometryService.coveredPercent(request.geometryArea, request.remainingArea)
val requiredPercent = request.coverageRequiredPercent
when {
coveredPercent >= requiredPercent -> {
request.status = RequestStatus.COMPLETED
if (request.completedAt == null) {
request.completedAt = now
}
}
now.isBefore(request.beginDateTime) -> {
request.status = RequestStatus.ACCEPTED
request.completedAt = null
}
now.isAfter(request.endDateTime) -> {
request.status = RequestStatus.EXPIRED
request.completedAt = null
}
else -> {
request.status = RequestStatus.ACTIVE
request.completedAt = null
}
}
return RequestLifecycleRefreshResult(
statusChanged = previousStatus != request.status || previousCompletedAt != request.completedAt,
completedNow = previousStatus != RequestStatus.COMPLETED && request.status == RequestStatus.COMPLETED,
)
}
}
data class RequestLifecycleRefreshResult(
val statusChanged: Boolean,
val completedNow: Boolean,
)
@@ -0,0 +1,358 @@
package org.nstart.dep265.requestservice.service
import jakarta.persistence.EntityExistsException
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceException
import org.hibernate.exception.ConstraintViolationException
import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto
import org.nstart.dep265.requestservice.dto.CreateRequestResponseDto
import org.nstart.dep265.requestservice.dto.CoverageStateDto
import org.nstart.dep265.requestservice.dto.DeleteRequestResponseDto
import org.nstart.dep265.requestservice.dto.DeleteRequestStatusDto
import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto
import org.nstart.dep265.requestservice.dto.OpticsParamsDto
import org.nstart.dep265.requestservice.dto.RequestListResponseDto
import org.nstart.dep265.requestservice.dto.RequestCellResponseDto
import org.nstart.dep265.requestservice.dto.RequestResponseDto
import org.nstart.dep265.requestservice.dto.RequestSummaryResponseDto
import org.nstart.dep265.requestservice.dto.RequestWithCellsResponseDto
import org.nstart.dep265.requestservice.dto.RequestStatusDto
import org.nstart.dep265.requestservice.dto.RsaParamsDto
import org.nstart.dep265.requestservice.dto.SurveyTypeDto
import org.nstart.dep265.requestservice.entity.OpticsResultType
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity
import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.entity.RsaPolarisation
import org.nstart.dep265.requestservice.entity.RsaResultType
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.UUID
class InvalidRequestListSortException(message: String) : IllegalArgumentException(message)
@Service
class RequestService(
private val requestRepository: RequestRepository,
private val requestOpticsParamsRepository: RequestOpticsParamsRepository,
private val requestRsaParamsRepository: RequestRsaParamsRepository,
private val requestCellRepository: RequestCellRepository,
private val geometryService: GeometryService,
private val requestGridProjectionService: RequestGridProjectionService,
private val entityManager: EntityManager,
) {
@Transactional
fun createRequest(request: CreateRequestRequestDto): CreateRequestResponseDto {
val requestId = request.id
if (requestRepository.existsById(requestId)) {
throw RequestAlreadyExistsException(requestId)
}
require(request.beginDateTime.isBefore(request.endDateTime)) {
"beginDateTime должен быть раньше endDateTime"
}
val now = LocalDateTime.now()
val normalizedGeometry = geometryService.parsePolygonalGeometry(request.geometry)
val normalizedGeometryWkt = geometryService.toWkt(normalizedGeometry)
val geometryArea = geometryService.area(normalizedGeometry)
val surveyType = request.surveyType()
val importance = requireNotNull(request.importance) { "importance обязателен" }
val requestEntity = RequestEntity(
id = requestId,
name = request.name,
geometry = normalizedGeometryWkt,
remainingGeometry = normalizedGeometryWkt,
geometryArea = geometryArea,
remainingArea = geometryArea,
coverageRequiredPercent = DEFAULT_COVERAGE_REQUIRED_PERCENT,
importance = importance,
beginDateTime = request.beginDateTime.toUtcLocalDateTime(),
endDateTime = request.endDateTime.toUtcLocalDateTime(),
kpp = request.kpp.toMutableList(),
highPriorityTransmit = request.highPriorityTransmit,
surveyType = surveyType,
status = RequestStatus.ACCEPTED,
createdAt = now,
updatedAt = now,
)
persistNewRequest(requestEntity)
replaceSurveyParams(requestEntity.id, request.optics, request.rsa)
requestGridProjectionService.rebuildProjection(requestEntity)
return CreateRequestResponseDto(
id = requestEntity.id,
name = requestEntity.name,
status = RequestStatusDto.ACCEPTED,
surveyType = requestEntity.surveyType.toDto(),
importance = requestEntity.importance,
message = "Заявка успешно создана",
)
}
@Transactional(readOnly = true)
fun listRequests(query: ListRequestsQueryDto): RequestListResponseDto {
val requests = requestRepository.findRequests(
includeDeleted = query.includeDeleted,
deletedStatus = RequestStatus.DELETED,
status = query.status?.toEntity(),
surveyType = query.surveyType?.toEntity(),
kpp = query.kpp,
highPriorityTransmit = query.highPriorityTransmit,
beginFrom = query.beginFrom?.toUtcLocalDateTime(),
beginTo = query.beginTo?.toUtcLocalDateTime(),
endFrom = query.endFrom?.toUtcLocalDateTime(),
endTo = query.endTo?.toUtcLocalDateTime(),
pageable = PageRequest.of(query.page, query.size, query.sort.toRequestSort()),
)
return RequestListResponseDto(
items = requests.content.map { request -> request.toSummaryResponse() },
page = query.page,
size = query.size,
totalItems = requests.totalElements,
totalPages = requests.totalPages,
)
}
@Transactional(readOnly = true)
fun getRequestById(id: UUID): RequestResponseDto? {
val request = requestRepository.findById(id).orElse(null) ?: return null
if (request.status == RequestStatus.DELETED) {
return null
}
return request.toResponse()
}
@Transactional(readOnly = true)
fun getRequestWithCells(id: UUID): RequestWithCellsResponseDto? {
val request = requestRepository.findById(id).orElse(null) ?: return null
val cells = requestCellRepository.findWithCellByRequestId(id)
.map { requestCell -> requestCell.toRequestCellResponse() }
return RequestWithCellsResponseDto(
request = request.toResponse(),
cells = cells,
)
}
@Transactional
fun deleteRequest(id: UUID): DeleteRequestResponseDto? {
val request = requestRepository.findById(id).orElse(null) ?: return null
val deletedAt = request.deletedAt ?: LocalDateTime.now()
request.status = RequestStatus.DELETED
request.deletedAt = deletedAt
request.updatedAt = deletedAt
requestRepository.save(request)
requestCellRepository.deleteByRequestId(request.id)
return DeleteRequestResponseDto(
id = request.id,
status = DeleteRequestStatusDto.DELETED,
deletedAt = deletedAt.toOffsetDateTime(),
)
}
private fun persistNewRequest(requestEntity: RequestEntity) {
try {
entityManager.persist(requestEntity)
entityManager.flush()
} catch (exception: EntityExistsException) {
throw RequestAlreadyExistsException(requestEntity.id, exception)
} catch (exception: PersistenceException) {
if (exception.isDuplicateRequestId()) {
throw RequestAlreadyExistsException(requestEntity.id, exception)
}
throw exception
}
}
private fun replaceSurveyParams(requestId: UUID, optics: OpticsParamsDto?, rsa: RsaParamsDto?) {
if (optics == null) {
if (requestOpticsParamsRepository.existsById(requestId)) {
requestOpticsParamsRepository.deleteById(requestId)
}
} else {
requestOpticsParamsRepository.save(optics.toEntity(requestId))
}
if (rsa == null) {
if (requestRsaParamsRepository.existsById(requestId)) {
requestRsaParamsRepository.deleteById(requestId)
}
} else {
requestRsaParamsRepository.save(rsa.toEntity(requestId))
}
}
private fun RequestEntity.toResponse(): RequestResponseDto {
val optics = requestOpticsParamsRepository.findById(id).orElse(null)?.toDto()
val rsa = requestRsaParamsRepository.findById(id).orElse(null)?.toDto()
return RequestResponseDto(
id = id,
name = name,
status = status.toDto(),
surveyType = surveyType.toDto(),
geometry = geometry,
importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(),
endDateTime = endDateTime.toOffsetDateTime(),
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
optics = optics,
rsa = rsa,
coverage = coverage(),
createdAt = createdAt.toOffsetDateTime(),
updatedAt = updatedAt.toOffsetDateTime(),
deletedAt = deletedAt?.toOffsetDateTime(),
)
}
private fun RequestCellEntity.toRequestCellResponse(): RequestCellResponseDto {
return RequestCellResponseDto(
cellNum = requireNotNull(cell?.cellNum) { "request_cell must be linked to earth_cell" },
coveragePercent = coveragePercent ?: 0.0,
importance = importance,
contour = contour,
)
}
private fun RequestEntity.toSummaryResponse(): RequestSummaryResponseDto {
return RequestSummaryResponseDto(
id = id,
name = name,
status = status.toDto(),
surveyType = surveyType.toDto(),
importance = importance,
beginDateTime = beginDateTime.toOffsetDateTime(),
endDateTime = endDateTime.toOffsetDateTime(),
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
coveragePercent = coverage().currentPercent,
createdAt = createdAt.toOffsetDateTime(),
updatedAt = updatedAt.toOffsetDateTime(),
deletedAt = deletedAt?.toOffsetDateTime(),
)
}
private fun RequestEntity.coverage(): CoverageStateDto {
return CoverageStateDto(
requiredPercent = coverageRequiredPercent,
currentPercent = geometryService.coveredPercent(geometryArea, remainingArea),
)
}
private fun CreateRequestRequestDto.surveyType(): RequestSurveyType {
return when {
optics != null && rsa != null -> RequestSurveyType.COMBINED
optics != null -> RequestSurveyType.OPTICS
rsa != null -> RequestSurveyType.RSA
else -> throw IllegalArgumentException("Должен быть указан хотя бы один блок съемки: optics или rsa")
}
}
private fun OpticsParamsDto.toEntity(requestId: UUID): RequestOpticsParamsEntity {
return RequestOpticsParamsEntity(
requestId = requestId,
resultType = OpticsResultType.valueOf(resultType.name),
resolution = resolution,
sunAngleMin = sunAngleMin,
sunAngleMax = sunAngleMax,
clouds = clouds,
)
}
private fun RequestOpticsParamsEntity.toDto(): OpticsParamsDto {
return OpticsParamsDto(
resultType = org.nstart.dep265.requestservice.dto.OpticsResultTypeDto.valueOf(resultType.name),
resolution = resolution,
sunAngleMin = sunAngleMin,
sunAngleMax = sunAngleMax,
clouds = clouds,
)
}
private fun RsaParamsDto.toEntity(requestId: UUID): RequestRsaParamsEntity {
return RequestRsaParamsEntity(
requestId = requestId,
resultType = RsaResultType.valueOf(resultType.name),
interferometry = interferometry,
polarisation = RsaPolarisation.valueOf(polarisation.name),
resolution = resolution,
)
}
private fun RequestRsaParamsEntity.toDto(): RsaParamsDto {
return RsaParamsDto(
resultType = org.nstart.dep265.requestservice.dto.RsaResultTypeDto.valueOf(resultType.name),
interferometry = interferometry,
polarisation = org.nstart.dep265.requestservice.dto.PolarisationDto.valueOf(polarisation.name),
resolution = resolution,
)
}
private fun RequestStatus.toDto(): RequestStatusDto = RequestStatusDto.valueOf(name)
private fun RequestSurveyType.toDto(): SurveyTypeDto = SurveyTypeDto.valueOf(name)
private fun RequestStatusDto.toEntity(): RequestStatus = RequestStatus.valueOf(name)
private fun SurveyTypeDto.toEntity(): RequestSurveyType = RequestSurveyType.valueOf(name)
private fun String.toRequestSort(): Sort {
val parts = split(",").map { part -> part.trim() }.filter { part -> part.isNotEmpty() }
val field = parts.firstOrNull() ?: DEFAULT_SORT_FIELD
val direction = parts.getOrNull(1)?.let { value ->
when (value.lowercase()) {
"asc" -> Sort.Direction.ASC
"desc" -> Sort.Direction.DESC
else -> throw InvalidRequestListSortException("Unsupported sort direction: $value")
}
} ?: DEFAULT_SORT_DIRECTION
val property = ALLOWED_SORT_FIELDS[field]
?: throw InvalidRequestListSortException("Unsupported sort field: $field")
return Sort.by(direction, property)
}
private fun OffsetDateTime.toUtcLocalDateTime(): LocalDateTime = withOffsetSameInstant(ZoneOffset.UTC).toLocalDateTime()
private fun LocalDateTime.toOffsetDateTime(): OffsetDateTime = atOffset(ZoneOffset.UTC)
private fun Throwable.isDuplicateRequestId(): Boolean {
return generateSequence(this) { throwable -> throwable.cause }
.filterIsInstance<ConstraintViolationException>()
.any { exception ->
exception.sqlState == POSTGRES_UNIQUE_VIOLATION &&
(exception.constraintName == null || exception.constraintName == REQUESTS_PRIMARY_KEY)
}
}
private companion object {
const val DEFAULT_COVERAGE_REQUIRED_PERCENT = 100.0
const val POSTGRES_UNIQUE_VIOLATION = "23505"
const val REQUESTS_PRIMARY_KEY = "requests_pkey"
const val DEFAULT_SORT_FIELD = "createdAt"
val DEFAULT_SORT_DIRECTION: Sort.Direction = Sort.Direction.DESC
val ALLOWED_SORT_FIELDS: Map<String, String> = mapOf(
"createdAt" to "createdAt",
"updatedAt" to "updatedAt",
"beginDateTime" to "beginDateTime",
"endDateTime" to "endDateTime",
"status" to "status",
"surveyType" to "surveyType",
"importance" to "importance",
)
}
}
@@ -0,0 +1,36 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.MultiPolygon
import org.locationtech.jts.geom.Polygon
import org.nstart.dep265.requestservice.dto.RouteDto
import org.springframework.stereotype.Service
@Service
class RequestValidationService(
private val geometryService: GeometryService,
) {
private val maxAllowedRollAngle: Double = 90.0
fun validate(route: RouteDto) {
require(route.intervalBegin.isBefore(route.intervalEnd)) {
"Начало интервала маршрута должно быть раньше окончания"
}
require(route.rollAngle.min >= 0.0) {
"Минимальный крен маршрута не может быть отрицательным"
}
require(route.rollAngle.max <= maxAllowedRollAngle) {
"Максимальный крен маршрута должен быть в диапазоне от 0 до $maxAllowedRollAngle градусов"
}
require(route.rollAngle.min <= route.rollAngle.max) {
"Диапазон крена маршрута задан некорректно"
}
val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry)
require(routeGeometry is Polygon || routeGeometry is MultiPolygon) {
"Геометрия маршрута должна быть POLYGON или MULTIPOLYGON в формате WKT"
}
require(routeGeometry.area > 0.0) {
"Геометрия маршрута должна иметь положительную площадь"
}
}
}
@@ -0,0 +1,230 @@
package org.nstart.dep265.requestservice.service
import org.locationtech.jts.geom.Geometry
import org.nstart.dep265.requestservice.dto.RouteDto
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRouteMatchRepository
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
import java.util.UUID
import kotlin.math.max
import kotlin.math.min
@Service
class RouteMatchingService(
private val requestRepository: RequestRepository,
private val requestRouteMatchRepository: RequestRouteMatchRepository,
private val requestValidationService: RequestValidationService,
private val geometryService: GeometryService,
private val requestLifecycleService: RequestLifecycleService,
private val requestCompletedOutboxService: RequestCompletedOutboxService,
@param:Qualifier("routeMatchingTransactionOperations")
private val routeMatchingTransactionOperations: TransactionOperations,
) {
private val log = LoggerFactory.getLogger(this::class.java)
fun process(route: RouteDto) {
requestValidationService.validate(route)
val routeId = route.routeId
val routeRollRange = route.rollAngle
val matchedAt: LocalDateTime = LocalDateTime.now()
val routeGeometry = geometryService.parsePolygonalGeometry(route.geometry)
val candidateRequestIds = requestRepository.findRequestIdsForRoute(
statuses = listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE),
routeBegin = route.intervalBegin,
routeEnd = route.intervalEnd,
)
var actualMatches = 0
candidateRequestIds.forEach { requestId ->
if (requestRouteMatchRepository.existsByRequestIdAndRouteId(requestId, routeId)) {
return@forEach
}
try {
val contributed = routeMatchingTransactionOperations.execute(
TransactionCallback {
applyRouteToRequest(
requestId = requestId,
routeId = routeId,
routeGeometry = routeGeometry,
matchedAt = matchedAt,
)
},
) ?: false
if (contributed) {
actualMatches += 1
}
} catch (exception: DataIntegrityViolationException) {
if (!exception.isDuplicateRouteMatchViolation()) {
throw exception
}
log.debug(
"Route match already exists after concurrent insert: requestId={}, routeId={}",
requestId,
routeId,
)
}
}
log.info(
"Route processed: routeId={}, candidatesMatched={}, actualMatches={}, roll={}",
routeId,
candidateRequestIds.size,
actualMatches,
routeRollRange,
)
}
private fun applyRouteToRequest(
requestId: UUID,
routeId: UUID,
routeGeometry: Geometry,
matchedAt: LocalDateTime,
): Boolean {
val request = requestRepository.findByIdForUpdate(requestId).orElse(null) ?: return false
if (request.status !in ACTIVE_ROUTE_MATCHING_STATUSES || request.deletedAt != null) {
return false
}
if (requestRouteMatchRepository.existsByRequestIdAndRouteId(request.id, routeId)) {
return false
}
val originalGeometry = geometryService.parsePolygonalGeometry(request.geometry)
val originalIntersection = geometryService.keepPolygonalGeometry(originalGeometry.intersection(routeGeometry))
val originalIntersectionArea = geometryService.area(originalIntersection)
if (!originalIntersection.hasPositiveArea(originalIntersectionArea)) {
return false
}
val remainingGeometry = geometryService.parsePolygonalGeometry(request.remainingGeometry)
val remainingIntersection = geometryService.keepPolygonalGeometry(remainingGeometry.intersection(routeGeometry))
val remainingIntersectionArea = geometryService.area(remainingIntersection)
if (!remainingIntersection.hasPositiveArea(remainingIntersectionArea)) {
saveRouteMatch(
request = request,
routeId = routeId,
originalIntersection = originalIntersection,
originalIntersectionArea = originalIntersectionArea,
appliedIntersection = null,
appliedIntersectionArea = 0.0,
coverageDeltaPercent = 0.0,
contributesToCoverage = false,
matchedAt = matchedAt,
)
return false
}
val updatedRemainingGeometry = geometryService.keepPolygonalGeometry(
remainingGeometry.difference(remainingIntersection),
)
val updatedRemainingArea = max(geometryService.area(updatedRemainingGeometry), 0.0)
val coverageDeltaPercent = coverageDeltaPercent(
originalArea = request.geometryArea,
appliedIntersectionArea = remainingIntersectionArea,
)
saveRouteMatch(
request = request,
routeId = routeId,
originalIntersection = originalIntersection,
originalIntersectionArea = originalIntersectionArea,
appliedIntersection = remainingIntersection,
appliedIntersectionArea = remainingIntersectionArea,
coverageDeltaPercent = coverageDeltaPercent,
contributesToCoverage = true,
matchedAt = matchedAt,
)
request.remainingGeometry = geometryService.toWkt(updatedRemainingGeometry)
request.remainingArea = updatedRemainingArea
request.matchCount = request.matchCount + 1
request.lastMatchedAt = matchedAt
request.updatedAt = matchedAt
val statusRefresh = requestLifecycleService.refreshStatus(request, matchedAt)
requestRepository.save(request)
if (statusRefresh.completedNow) {
requestCompletedOutboxService.createRequestCompletedIfAbsent(request, matchedAt)
}
return true
}
private fun saveRouteMatch(
request: RequestEntity,
routeId: UUID,
originalIntersection: Geometry,
originalIntersectionArea: Double,
appliedIntersection: Geometry?,
appliedIntersectionArea: Double,
coverageDeltaPercent: Double,
contributesToCoverage: Boolean,
matchedAt: LocalDateTime,
) {
val requestRouteMatch = RequestRouteMatchEntity(
requestId = request.id,
routeId = routeId,
originalIntersectionGeometry = geometryService.toWkt(originalIntersection),
originalIntersectionArea = originalIntersectionArea,
appliedIntersectionGeometry = appliedIntersection?.let { geometryService.toWkt(it) },
appliedIntersectionArea = appliedIntersectionArea,
coverageDeltaPercent = coverageDeltaPercent,
contributesToCoverage = contributesToCoverage,
matchedAt = matchedAt,
)
requestRouteMatchRepository.saveAndFlush(requestRouteMatch)
}
private fun coverageDeltaPercent(originalArea: Double, appliedIntersectionArea: Double): Double {
if (originalArea <= 0.0) {
return 0.0
}
val percent = (appliedIntersectionArea / originalArea) * 100.0
return min(max(percent, 0.0), 100.0)
}
private fun Geometry.hasPositiveArea(area: Double): Boolean = !isEmpty && area > 0.0
private fun DataIntegrityViolationException.isDuplicateRouteMatchViolation(): Boolean {
if (this is DuplicateKeyException) {
return true
}
val message = listOfNotNull(message, mostSpecificCause.message)
.joinToString(separator = " ")
.lowercase()
return message.contains("uk_request_route_match") ||
message.contains("request_id, route_id") ||
(message.contains("duplicate") && message.contains("unique"))
}
companion object {
private val ACTIVE_ROUTE_MATCHING_STATUSES = setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE)
}
}
@@ -0,0 +1,19 @@
spring:
application:
name: pcp-request-service
profiles:
default: local
config:
import: "configserver:"
cloud:
config:
uri: ${CONFIG_SERVER_URI:http://localhost:8888}
fail-fast: ${CONFIG_SERVER_FAIL_FAST:true}
profile: ${SPRING_CLOUD_CONFIG_PROFILE:${SPRING_PROFILES_ACTIVE:${spring.profiles.default}}}
label: ${SPRING_CLOUD_CONFIG_LABEL:master}
pcp:
outbox:
request-completed-topic: pcp.request.completed.v1
publish-batch-size: 50
publish-fixed-delay-ms: 5000
@@ -0,0 +1,210 @@
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE TABLE IF NOT EXISTS requests (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
geometry TEXT NOT NULL,
remaining_geometry TEXT NOT NULL,
geometry_area DOUBLE PRECISION NOT NULL,
remaining_area DOUBLE PRECISION NOT NULL,
coverage_required_percent DOUBLE PRECISION NOT NULL DEFAULT 100.0,
importance DOUBLE PRECISION NOT NULL,
begin_date_time TIMESTAMP NOT NULL,
end_date_time TIMESTAMP NOT NULL,
high_priority_transmit BOOLEAN NOT NULL DEFAULT false,
survey_type VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL,
match_count INTEGER NOT NULL DEFAULT 0,
last_matched_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
deleted_at TIMESTAMP NULL,
version BIGINT NOT NULL DEFAULT 0,
CONSTRAINT chk_requests_status
CHECK (status IN ('ACCEPTED', 'ACTIVE', 'COMPLETED', 'EXPIRED', 'DELETED')),
CONSTRAINT chk_requests_survey_type
CHECK (survey_type IN ('OPTICS', 'RSA', 'COMBINED')),
CONSTRAINT chk_requests_time_window
CHECK (begin_date_time < end_date_time),
CONSTRAINT chk_requests_importance_non_negative
CHECK (importance >= 0),
CONSTRAINT chk_requests_name_not_blank
CHECK (length(trim(name)) > 0),
CONSTRAINT chk_requests_soft_delete
CHECK ((status = 'DELETED' AND deleted_at IS NOT NULL) OR (status <> 'DELETED'))
);
CREATE TABLE IF NOT EXISTS request_kpp (
request_id UUID NOT NULL,
kpp INTEGER NOT NULL,
CONSTRAINT fk_request_kpp_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS request_optics_params (
request_id UUID PRIMARY KEY,
result_type VARCHAR(32) NOT NULL,
resolution DOUBLE PRECISION NOT NULL,
sun_angle_min DOUBLE PRECISION NOT NULL DEFAULT 10.0,
sun_angle_max DOUBLE PRECISION NOT NULL DEFAULT 90.0,
clouds DOUBLE PRECISION NOT NULL DEFAULT 100.0,
CONSTRAINT fk_request_optics_params_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE,
CONSTRAINT chk_request_optics_result_type
CHECK (result_type IN ('PANCHROMATIC', 'MULTISPECTRAL', 'PANSHARPENING'))
);
CREATE TABLE IF NOT EXISTS request_rsa_params (
request_id UUID PRIMARY KEY,
result_type VARCHAR(32) NOT NULL,
interferometry BOOLEAN NOT NULL DEFAULT false,
polarisation VARCHAR(8) NOT NULL,
resolution DOUBLE PRECISION NOT NULL,
CONSTRAINT fk_request_rsa_params_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE,
CONSTRAINT chk_request_rsa_result_type
CHECK (result_type IN ('SPOTLIGHT', 'STRIPMAP', 'SCANSAR')),
CONSTRAINT chk_request_rsa_polarisation
CHECK (polarisation IN ('HH', 'VV'))
);
CREATE TABLE IF NOT EXISTS request_route_matches (
id BIGSERIAL PRIMARY KEY,
request_id UUID NOT NULL,
route_id UUID NOT NULL,
original_intersection_geometry TEXT NOT NULL,
original_intersection_area DOUBLE PRECISION NOT NULL,
applied_intersection_geometry TEXT NULL,
applied_intersection_area DOUBLE PRECISION NOT NULL DEFAULT 0.0,
coverage_delta_percent DOUBLE PRECISION NOT NULL DEFAULT 0.0,
contributes_to_coverage BOOLEAN NOT NULL DEFAULT false,
matched_at TIMESTAMP NOT NULL,
CONSTRAINT fk_request_route_matches_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE,
CONSTRAINT uk_request_route_match UNIQUE (request_id, route_id)
);
CREATE TABLE IF NOT EXISTS outbox_events (
id UUID PRIMARY KEY,
event_type VARCHAR(128) NOT NULL,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id UUID NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP NULL,
error_message TEXT NULL,
CONSTRAINT chk_outbox_events_status
CHECK (status IN ('NEW', 'PUBLISHED', 'FAILED')),
CONSTRAINT uk_outbox_events_event_type_aggregate_id
UNIQUE (event_type, aggregate_id)
);
CREATE TABLE IF NOT EXISTS earth_cells (
cell_id BIGSERIAL PRIMARY KEY,
cell_num BIGINT NOT NULL,
longitude DOUBLE PRECISION,
latitude DOUBLE PRECISION,
importance DOUBLE PRECISION NOT NULL DEFAULT 0.0,
contour_wkt TEXT NOT NULL,
contour_geom geometry(Polygon, 4326)
);
CREATE TABLE IF NOT EXISTS request_cells (
id BIGSERIAL PRIMARY KEY,
request_id UUID NOT NULL,
cell_id BIGINT NOT NULL,
coverage_percent DOUBLE PRECISION,
importance DOUBLE PRECISION NOT NULL,
contour_wkt TEXT NOT NULL,
contour_geom geometry(Polygon, 4326),
CONSTRAINT fk_request_cells_request
FOREIGN KEY (request_id)
REFERENCES requests(id)
ON DELETE CASCADE,
CONSTRAINT fk_request_cells_cell
FOREIGN KEY (cell_id)
REFERENCES earth_cells(cell_id)
ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS earth_grid_settings (
settings_id BIGINT PRIMARY KEY,
calculation_step DOUBLE PRECISION NOT NULL
);
INSERT INTO earth_grid_settings(settings_id, calculation_step)
VALUES (1, 2.0)
ON CONFLICT (settings_id) DO NOTHING;
CREATE OR REPLACE FUNCTION fill_earth_cell_geometry()
RETURNS TRIGGER AS $$
BEGIN
NEW.contour_geom := ST_GeomFromText(NEW.contour_wkt, 4326)::geometry(Polygon, 4326);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION fill_request_cell_geometry()
RETURNS TRIGGER AS $$
BEGIN
NEW.contour_geom := ST_GeomFromText(NEW.contour_wkt, 4326)::geometry(Polygon, 4326);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION update_earth_cell_importance()
RETURNS TRIGGER AS $$
BEGIN
UPDATE earth_cells earth_cell
SET importance = (
SELECT COALESCE(SUM(request_cell.importance * (COALESCE(request_cell.coverage_percent, 0.0) / 100.0)), 0.0)
FROM request_cells request_cell
WHERE request_cell.cell_id = COALESCE(NEW.cell_id, OLD.cell_id)
)
WHERE earth_cell.cell_id = COALESCE(NEW.cell_id, OLD.cell_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_fill_earth_cell_geometry ON earth_cells;
CREATE TRIGGER trg_fill_earth_cell_geometry
BEFORE INSERT OR UPDATE OF contour_wkt ON earth_cells
FOR EACH ROW EXECUTE FUNCTION fill_earth_cell_geometry();
DROP TRIGGER IF EXISTS trg_fill_request_cell_geometry ON request_cells;
CREATE TRIGGER trg_fill_request_cell_geometry
BEFORE INSERT OR UPDATE OF contour_wkt ON request_cells
FOR EACH ROW EXECUTE FUNCTION fill_request_cell_geometry();
DROP TRIGGER IF EXISTS request_cells_importance_trigger ON request_cells;
CREATE TRIGGER request_cells_importance_trigger
AFTER INSERT OR UPDATE OR DELETE ON request_cells
FOR EACH ROW EXECUTE FUNCTION update_earth_cell_importance();
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_survey_type ON requests(survey_type);
CREATE INDEX IF NOT EXISTS idx_requests_begin_date_time ON requests(begin_date_time);
CREATE INDEX IF NOT EXISTS idx_requests_end_date_time ON requests(end_date_time);
CREATE INDEX IF NOT EXISTS idx_requests_deleted_at ON requests(deleted_at);
CREATE INDEX IF NOT EXISTS idx_requests_completed_at ON requests(completed_at);
CREATE INDEX IF NOT EXISTS idx_requests_cleanup_status_end ON requests(status, end_date_time);
CREATE INDEX IF NOT EXISTS idx_request_kpp_request_id ON request_kpp(request_id);
CREATE INDEX IF NOT EXISTS idx_request_kpp_kpp ON request_kpp(kpp);
CREATE INDEX IF NOT EXISTS idx_request_route_matches_route_id ON request_route_matches(route_id);
CREATE INDEX IF NOT EXISTS idx_outbox_events_status_created_at ON outbox_events(status, created_at);
CREATE INDEX IF NOT EXISTS idx_earth_cells_cell_num ON earth_cells(cell_num);
CREATE INDEX IF NOT EXISTS idx_earth_cells_contour_geom ON earth_cells USING GIST (contour_geom);
CREATE INDEX IF NOT EXISTS idx_request_cells_request_id ON request_cells(request_id);
CREATE INDEX IF NOT EXISTS idx_request_cells_cell_id ON request_cells(cell_id);
CREATE INDEX IF NOT EXISTS idx_request_cells_contour_geom ON request_cells USING GIST (contour_geom);
@@ -0,0 +1,97 @@
package org.nstart.dep265.requestservice
import java.nio.file.Files
import java.nio.file.Path
import kotlin.io.path.readText
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MigrationSchemaTest {
@Test
fun `request service has a single fresh schema migration`() {
val migrations = migrationDir()
.toFile()
.listFiles { file -> file.isFile && file.name.endsWith(".sql") }
.orEmpty()
.map { it.name }
.sorted()
assertEquals(listOf("V1__create_schema.sql"), migrations)
}
@Test
fun `fresh schema defines mandatory cell importance columns`() {
val schema = migration("V1__create_schema.sql").normalizedSql()
val requests = schema.tableBlock("requests")
val earthCells = schema.tableBlock("earth_cells")
val requestCells = schema.tableBlock("request_cells")
assertTrue(requests.contains("name varchar(255) not null"))
assertTrue(requests.contains("constraint chk_requests_name_not_blank check (length(trim(name)) > 0)"))
assertTrue(earthCells.contains("importance double precision not null default 0.0"))
assertTrue(requestCells.contains("importance double precision not null"))
assertTrue(!requestCells.contains("importance double precision not null default"))
}
@Test
fun `earth cell importance trigger writes numeric zero when no request cells remain`() {
val schema = migration("V1__create_schema.sql").normalizedSql()
assertTrue(schema.contains("after insert or update or delete on request_cells"))
assertTrue(schema.contains("coalesce(sum(request_cell.importance * (coalesce(request_cell.coverage_percent, 0.0) / 100.0)), 0.0)"))
}
@Test
fun `outbox schema stores payload as jsonb and deduplicates request completion events`() {
val schema = migration("V1__create_schema.sql").normalizedSql()
val outboxEvents = schema.tableBlock("outbox_events")
assertTrue(outboxEvents.contains("payload jsonb not null"))
assertTrue(outboxEvents.contains("status varchar(32) not null"))
assertTrue(outboxEvents.contains("constraint uk_outbox_events_event_type_aggregate_id unique (event_type, aggregate_id)"))
assertTrue(schema.contains("on outbox_events(status, created_at)"))
}
@Test
fun `request route matches schema stores suitability and applied coverage separately`() {
val schema = migration("V1__create_schema.sql").normalizedSql()
val requestRouteMatches = schema.tableBlock("request_route_matches")
assertTrue(requestRouteMatches.contains("original_intersection_geometry text not null"))
assertTrue(requestRouteMatches.contains("original_intersection_area double precision not null"))
assertTrue(requestRouteMatches.contains("applied_intersection_geometry text null"))
assertTrue(requestRouteMatches.contains("applied_intersection_area double precision not null default 0.0"))
assertTrue(requestRouteMatches.contains("coverage_delta_percent double precision not null default 0.0"))
assertTrue(requestRouteMatches.contains("contributes_to_coverage boolean not null default false"))
assertTrue(requestRouteMatches.contains("constraint uk_request_route_match unique (request_id, route_id)"))
}
private fun migration(fileName: String): Path {
val modulePath = Path.of("src/main/resources/db/migration", fileName)
if (Files.exists(modulePath)) {
return modulePath
}
return Path.of("services/pcp-request-service/src/main/resources/db/migration", fileName)
}
private fun migrationDir(): Path {
val modulePath = Path.of("src/main/resources/db/migration")
if (Files.exists(modulePath)) {
return modulePath
}
return Path.of("services/pcp-request-service/src/main/resources/db/migration")
}
private fun Path.normalizedSql(): String =
readText()
.lowercase()
.replace(Regex("\\s+"), " ")
private fun String.tableBlock(tableName: String): String {
val start = indexOf("create table if not exists $tableName")
require(start >= 0) { "Table $tableName is not defined" }
val nextTable = indexOf("create table if not exists", start + 1).takeIf { it >= 0 } ?: length
return substring(start, nextTable)
}
}
@@ -0,0 +1,208 @@
package org.nstart.dep265.requestservice.controller
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.isNull
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.nstart.dep265.requestservice.service.CellsQueryService
import org.nstart.dep265.requestservice.service.CompatGeometryService
import org.nstart.dep265.requestservice.service.EarthGridCatalogService
import org.nstart.dep265.requestservice.service.GeometryService
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.http.MediaType
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
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.util.Optional
import java.util.UUID
class CellsControllerTest {
private val requestId = UUID.fromString("00000000-0000-0000-0000-000000000301")
private val cellWithRequest = EarthCellEntity(
cellNum = 42,
latitude = 55.0,
longitude = 37.0,
importance = 10.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
).also { earthCell ->
earthCell.requestProjections = mutableListOf(
RequestCellEntity(
requestId = requestId,
contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))",
coveragePercent = 25.0,
importance = 7.0,
cell = earthCell,
)
)
}
private val earthCellRepository = mock(EarthCellRepository::class.java).also { repository ->
doReturn(
PageImpl(
listOf(cellWithRequest),
PageRequest.of(0, 50),
1,
)
)
.`when`(repository)
.findCells(isNull(), pageableMatcher())
doReturn(
listOf(
cellWithRequest,
EarthCellEntity(
cellNum = 43,
latitude = 54.0,
longitude = 38.0,
importance = 0.0,
contour = "POLYGON ((37 54, 38 54, 38 55, 37 55, 37 54))",
)
)
)
.`when`(repository)
.findCellsWithRequestProjections(isNull())
}
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
.`when`(repository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
}
private val earthGridCatalogService = EarthGridCatalogService(
earthCellRepository = earthCellRepository,
earthGridSettingsRepository = earthGridSettingsRepository,
compatGeometryService = CompatGeometryService(GeometryService()),
gridEnabled = false,
)
private val mockMvc = MockMvcBuilders
.standaloneSetup(CellsController(CellsQueryService(earthCellRepository, earthGridCatalogService)))
.setControllerAdvice(CellsApiExceptionHandler())
.setMessageConverters(MappingJackson2HttpMessageConverter(jacksonObjectMapper()))
.build()
@Test
fun `get v1 cells returns pageable list response`() {
mockMvc.perform(get("/v1/cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.items").isArray)
.andExpect(jsonPath("$.items[0].cellNum").value(42))
.andExpect(jsonPath("$.items[0].latitude").value(55.0))
.andExpect(jsonPath("$.items[0].longitude").value(37.0))
.andExpect(jsonPath("$.items[0].importance").value(10.0))
.andExpect(jsonPath("$.items[0].contour").value("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))"))
.andExpect(jsonPath("$.page").value(0))
.andExpect(jsonPath("$.size").value(50))
.andExpect(jsonPath("$.totalItems").value(1))
.andExpect(jsonPath("$.totalPages").value(1))
}
@Test
fun `get v1 cells rejects unsupported sort field`() {
mockMvc.perform(
get("/v1/cells")
.queryParam("sort", "geometry,asc")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("Unsupported sort field: geometry"))
}
@Test
fun `get v1 cells rejects countLat before aggregation implementation`() {
mockMvc.perform(
get("/v1/cells")
.queryParam("countLat", "2")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("Cell aggregation by countLat/countLong is not implemented yet"))
}
@Test
fun `get v1 cells with requests returns request fragments`() {
mockMvc.perform(get("/v1/cells/with-requests").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.items").isArray)
.andExpect(jsonPath("$.items[0].cellNum").value(42))
.andExpect(jsonPath("$.items[0].latitude").value(55.0))
.andExpect(jsonPath("$.items[0].longitude").value(37.0))
.andExpect(jsonPath("$.items[0].importance").value(10.0))
.andExpect(jsonPath("$.items[0].contour").value("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))"))
.andExpect(jsonPath("$.items[0].requests[0].requestId").value(requestId.toString()))
.andExpect(jsonPath("$.items[0].requests[0].contour").value("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))"))
.andExpect(jsonPath("$.items[0].requests[0].coveragePercent").value(25.0))
.andExpect(jsonPath("$.items[0].requests[0].importance").value(7.0))
.andExpect(jsonPath("$.items[1].requests").isEmpty)
}
@Test
fun `get v1 cells with requests rejects partial aggregation parameters`() {
mockMvc.perform(
get("/v1/cells/with-requests")
.queryParam("countLat", "2")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("countLat and countLong must be provided together"))
}
@Test
fun `get v1 cells with requests rejects invalid numeric parameters`() {
mockMvc.perform(
get("/v1/cells/with-requests")
.queryParam("minImportance", "-1")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("minImportance must be greater than or equal to 0"))
mockMvc.perform(
get("/v1/cells/with-requests")
.queryParam("countLat", "0")
.queryParam("countLong", "1")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
.andExpect(jsonPath("$.message").value("countLat must be greater than 0"))
}
@Test
fun `get v1 cells with requests accepts complete aggregation parameters`() {
mockMvc.perform(
get("/v1/cells/with-requests")
.queryParam("countLat", "1")
.queryParam("countLong", "1")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.items").isArray)
}
@Test
fun `legacy earth grid with requests endpoint is not restored`() {
mockMvc.perform(get("/api/earth-grid/with-requests").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
}
private fun pageableMatcher(): Pageable {
any(Pageable::class.java)
return PageRequest.of(0, 50)
}
}
@@ -0,0 +1,127 @@
package org.nstart.dep265.requestservice.controller
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.mock
import org.nstart.dep265.requestservice.dto.GridRebuildResponseDto
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.nstart.dep265.requestservice.service.GridRebuildAlreadyRunningException
import org.nstart.dep265.requestservice.service.GridRebuildService
import org.nstart.dep265.requestservice.service.GridSettingsService
import org.springframework.http.MediaType
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
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.util.Optional
class GridManagementControllerTest {
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<EarthGridSettingsEntity>(0) }
.`when`(repository)
.save(any(EarthGridSettingsEntity::class.java))
}
private val gridRebuildService = mock(GridRebuildService::class.java)
private val mockMvc = MockMvcBuilders
.standaloneSetup(GridManagementController(GridSettingsService(earthGridSettingsRepository), gridRebuildService))
.setControllerAdvice(GridManagementApiExceptionHandler())
.setMessageConverters(MappingJackson2HttpMessageConverter(jacksonObjectMapper().findAndRegisterModules()))
.build()
@Test
fun `get v1 grid settings returns current settings`() {
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 4.0)))
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
mockMvc.perform(get("/v1/grid/settings").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.settingsId").value(1))
.andExpect(jsonPath("$.calculationStep").value(4.0))
}
@Test
fun `get v1 grid settings creates and returns default settings when missing`() {
doReturn(Optional.empty<EarthGridSettingsEntity>())
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
mockMvc.perform(get("/v1/grid/settings").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.settingsId").value(1))
.andExpect(jsonPath("$.calculationStep").value(2.0))
}
@Test
fun `put v1 grid settings updates settings`() {
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
mockMvc.perform(
put("/v1/grid/settings")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content("""{"calculationStep": 3.5}"""),
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.settingsId").value(1))
.andExpect(jsonPath("$.calculationStep").value(3.5))
}
@Test
fun `put v1 grid settings rejects invalid settings request`() {
mockMvc.perform(
put("/v1/grid/settings")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content("""{"calculationStep": 0.0}"""),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 grid rebuild returns rebuild result`() {
doReturn(
GridRebuildResponseDto(
cellsCount = 2,
rebuiltRequestProjectionsCount = 2,
rebuiltAt = OffsetDateTime.parse("2026-05-20T10:15:30Z"),
)
).`when`(gridRebuildService).rebuildGrid()
mockMvc.perform(post("/v1/grid/rebuild").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.cellsCount").value(2))
.andExpect(jsonPath("$.rebuiltRequestProjectionsCount").value(2))
.andExpect(jsonPath("$.rebuiltAt").value("2026-05-20T10:15:30Z"))
}
@Test
fun `post v1 grid rebuild returns conflict when lock is busy`() {
doThrow(GridRebuildAlreadyRunningException())
.`when`(gridRebuildService)
.rebuildGrid()
mockMvc.perform(post("/v1/grid/rebuild").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isConflict)
.andExpect(jsonPath("$.code").value("GRID_REBUILD_ALREADY_RUNNING"))
}
@Test
fun `legacy earth grid endpoint is not restored`() {
mockMvc.perform(get("/api/earth-grid").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
}
}
@@ -0,0 +1,508 @@
package org.nstart.dep265.requestservice.controller
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import jakarta.persistence.EntityManager
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.isNull
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity
import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
import org.nstart.dep265.requestservice.service.GeometryService
import org.nstart.dep265.requestservice.service.RequestGridProjectionService
import org.nstart.dep265.requestservice.service.RequestService
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import org.springframework.http.MediaType
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
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.LocalDateTime
import java.util.Optional
import java.util.UUID
class RequestControllerOpenApiSkeletonTest {
private val requestRepository = mock(RequestRepository::class.java).also { repository ->
doReturn(Optional.empty<RequestEntity>())
.`when`(repository)
.findById(any())
doReturn(PageImpl(emptyList<RequestEntity>(), PageRequest.of(0, 50), 0))
.`when`(repository)
.findRequests(
anyBoolean(),
deletedStatusMatcher(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
pageableMatcher(),
)
doAnswer { invocation -> invocation.getArgument<RequestEntity>(0) }
.`when`(repository)
.save(any(RequestEntity::class.java))
}
private val requestOpticsParamsRepository = mock(RequestOpticsParamsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<RequestOpticsParamsEntity>(0) }
.`when`(repository)
.save(any(RequestOpticsParamsEntity::class.java))
doReturn(Optional.empty<RequestOpticsParamsEntity>())
.`when`(repository)
.findById(any())
}
private val requestRsaParamsRepository = mock(RequestRsaParamsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<RequestRsaParamsEntity>(0) }
.`when`(repository)
.save(any(RequestRsaParamsEntity::class.java))
doReturn(Optional.empty<RequestRsaParamsEntity>())
.`when`(repository)
.findById(any())
}
private val requestCellRepository = mock(RequestCellRepository::class.java).also { repository ->
doReturn(emptyList<RequestCellEntity>())
.`when`(repository)
.findWithCellByRequestId(uuidMatcher())
}
private val entityManager = mock(EntityManager::class.java)
private val mockMvc = MockMvcBuilders
.standaloneSetup(
RequestController(
RequestService(
requestRepository = requestRepository,
requestOpticsParamsRepository = requestOpticsParamsRepository,
requestRsaParamsRepository = requestRsaParamsRepository,
requestCellRepository = requestCellRepository,
geometryService = GeometryService(),
requestGridProjectionService = mock(RequestGridProjectionService::class.java),
entityManager = entityManager,
)
)
)
.setControllerAdvice(RequestApiExceptionHandler())
.setMessageConverters(
MappingJackson2HttpMessageConverter(
jacksonObjectMapper()
.registerModule(JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS),
),
)
.build()
@Test
fun `post v1 requests returns create response`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"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",
"kpp": [1, 2, 3],
"highPriorityTransmit": false,
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isCreated)
.andExpect(jsonPath("$.id").value("3fa85f64-5717-4562-b3fc-2c963f66afa6"))
.andExpect(jsonPath("$.name").value("Тест1"))
.andExpect(jsonPath("$.status").value("ACCEPTED"))
.andExpect(jsonPath("$.surveyType").value("OPTICS"))
.andExpect(jsonPath("$.importance").value(10.0))
}
@Test
fun `post v1 requests returns conflict when request id already exists`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
doReturn(true)
.`when`(requestRepository)
.existsById(id)
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "$id",
"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",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isConflict)
.andExpect(jsonPath("$.code").value("REQUEST_ALREADY_EXISTS"))
}
@Test
fun `post v1 requests rejects missing name`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"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",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 requests rejects blank name`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": " ",
"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",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 requests rejects missing importance`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Тест1",
"geometry": "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))",
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 requests rejects negative importance`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"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": -1.0,
"beginDateTime": "2026-01-01T00:00:00Z",
"endDateTime": "2026-01-31T23:59:59Z",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `post v1 requests rejects missing optics and rsa`() {
mockMvc.perform(
post("/v1/requests")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.content(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"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"
}
""".trimIndent(),
),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `get v1 requests returns list response`() {
mockMvc.perform(
get("/v1/requests")
.queryParam("page", "2")
.queryParam("size", "25")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isOk)
.andExpect(jsonPath("$.items").isArray)
.andExpect(jsonPath("$.page").value(2))
.andExpect(jsonPath("$.size").value(25))
.andExpect(jsonPath("$.totalItems").value(0))
.andExpect(jsonPath("$.totalPages").value(0))
}
@Test
fun `get v1 requests returns importance in summary`() {
doReturn(PageImpl(listOf(existingRequest())))
.`when`(requestRepository)
.findRequests(
anyBoolean(),
deletedStatusMatcher(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
pageableMatcher(),
)
mockMvc.perform(get("/v1/requests").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.items[0].name").value("Тест1"))
.andExpect(jsonPath("$.items[0].importance").value(10.0))
}
@Test
fun `get v1 requests rejects unsupported sort field`() {
mockMvc.perform(
get("/v1/requests")
.queryParam("sort", "geometry,asc")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isBadRequest)
.andExpect(jsonPath("$.code").value("VALIDATION_ERROR"))
}
@Test
fun `get v1 requests by id returns importance`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
doReturn(Optional.of(existingRequest()))
.`when`(requestRepository)
.findById(id)
mockMvc.perform(get("/v1/requests/$id").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.name").value("Тест1"))
.andExpect(jsonPath("$.importance").value(10.0))
}
@Test
fun `get v1 request with cells returns request and cell projection fields`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
val earthCell = EarthCellEntity(
cellId = 1,
cellNum = 32400,
latitude = 55.0,
longitude = 37.0,
importance = 5.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
doReturn(Optional.of(existingRequest()))
.`when`(requestRepository)
.findById(id)
doReturn(
listOf(
RequestCellEntity(
requestId = id,
coveragePercent = 25.0,
importance = 7.0,
contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))",
cell = earthCell,
)
)
)
.`when`(requestCellRepository)
.findWithCellByRequestId(id)
mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.request.id").value(id.toString()))
.andExpect(jsonPath("$.request.name").value("Тест1"))
.andExpect(jsonPath("$.request.importance").value(10.0))
.andExpect(jsonPath("$.request.geometry").value("POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"))
.andExpect(jsonPath("$.request.coverage.currentPercent").value(50.0))
.andExpect(jsonPath("$.cells[0].cellNum").value(32400))
.andExpect(jsonPath("$.cells[0].coveragePercent").value(25.0))
.andExpect(jsonPath("$.cells[0].importance").value(7.0))
.andExpect(jsonPath("$.cells[0].contour").value("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))"))
}
@Test
fun `get v1 request with cells returns empty cells for request without projections`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
doReturn(Optional.of(existingRequest()))
.`when`(requestRepository)
.findById(id)
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(id)
mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.request.id").value(id.toString()))
.andExpect(jsonPath("$.cells").isEmpty)
}
@Test
fun `get v1 request with cells returns deleted request with empty cells`() {
val id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
val request = existingRequest().also { existingRequest ->
existingRequest.status = RequestStatus.DELETED
existingRequest.deletedAt = LocalDateTime.parse("2026-02-01T00:00:00")
}
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(id)
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(id)
mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk)
.andExpect(jsonPath("$.request.status").value("DELETED"))
.andExpect(jsonPath("$.request.deletedAt").exists())
.andExpect(jsonPath("$.cells").isEmpty)
}
@Test
fun `get and delete by id return not found when request is absent`() {
val id = "3fa85f64-5717-4562-b3fc-2c963f66afa6"
mockMvc.perform(get("/v1/requests/$id").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
mockMvc.perform(get("/v1/requests/$id/with-cells").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
mockMvc.perform(delete("/v1/requests/$id").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound)
}
@Test
fun `legacy request with cells endpoint is not restored`() {
mockMvc.perform(
get("/api/requests/by-number/3fa85f64-5717-4562-b3fc-2c963f66afa6/with-cells")
.accept(MediaType.APPLICATION_JSON),
)
.andExpect(status().isNotFound)
}
private fun existingRequest(): RequestEntity {
val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"
return RequestEntity(
id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 0.5,
coverageRequiredPercent = 100.0,
importance = 10.0,
beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"),
endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"),
highPriorityTransmit = false,
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.ACTIVE,
createdAt = LocalDateTime.parse("2025-12-31T00:00:00"),
updatedAt = LocalDateTime.parse("2025-12-31T00:00:00"),
)
}
private fun deletedStatusMatcher(): RequestStatus {
any(RequestStatus::class.java)
return RequestStatus.DELETED
}
private fun pageableMatcher(): Pageable {
any(Pageable::class.java)
return PageRequest.of(0, 50)
}
private fun uuidMatcher(): UUID {
any(UUID::class.java)
return UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
}
}
@@ -0,0 +1,126 @@
package org.nstart.dep265.requestservice.dto
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.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
class RequestApiDtoContractTest {
private val objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
@Test
fun `create request dto matches openapi input fields`() {
val request = objectMapper.readValue<CreateRequestRequestDto>(
"""
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"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",
"kpp": [1, 2, 3],
"highPriorityTransmit": false,
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0,
"sunAngleMin": 10.0,
"sunAngleMax": 45.0,
"clouds": 90.0
}
}
""".trimIndent(),
)
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(OpticsResultTypeDto.PANCHROMATIC, request.optics?.resultType)
}
@Test
fun `create request dto rejects survey type as input field`() {
val payloadWithSurveyType = """
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"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",
"surveyType": "OPTICS",
"optics": {
"resultType": "PANCHROMATIC",
"resolution": 1.0
}
}
""".trimIndent()
assertFailsWith<Exception> {
objectMapper.readValue<CreateRequestRequestDto>(payloadWithSurveyType)
}
}
@Test
fun `create request serialization does not expose survey type`() {
val json = objectMapper.writeValueAsString(
CreateRequestRequestDto(
id = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6"),
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"),
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,
),
),
)
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(),
)
}
}
@@ -0,0 +1,46 @@
package org.nstart.dep265.requestservice.mapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import space.nstart.pcp.pcp_types_lib.dto.routes.AngleRangeDto
import space.nstart.pcp.pcp_types_lib.dto.routes.RoutePassportDto
import java.math.BigDecimal
import java.time.LocalDateTime
import java.util.UUID
class RoutePassportMapperTest {
private val mapper = RoutePassportMapper()
@Test
fun `should convert route roll angle to absolute range`() {
val routePassportDto = RoutePassportDto(
routeId = UUID.fromString("51b07d12-1823-4bd5-b55d-de02a5304c03"),
kaShort = "S1A",
kaFull = "sentinel-1a",
routeNameFull = "ROUTE-001",
routeNameShort = "S1A-GRDH",
orbitNumber = 59796L,
orbitState = "Descending",
intervalBegin = LocalDateTime.of(2026, 4, 1, 10, 15, 0),
intervalEnd = LocalDateTime.of(2026, 4, 1, 10, 16, 30),
rollAngle = AngleRangeDto(
min = BigDecimal("-5.0"),
max = BigDecimal("12.0"),
),
visirAngle = AngleRangeDto(
min = BigDecimal("21.0"),
max = BigDecimal("24.5"),
),
resolutionRange = BigDecimal("10.0"),
resolutionAzimuth = BigDecimal("10.0"),
polarisation = listOf("VH", "VV"),
processingLevel = "GRD",
geometry = "POLYGON((0 0, 1 0, 1 1, 0 0))",
)
val route = mapper.toRouteDto(routePassportDto)
assertEquals(0.0, route.rollAngle.min)
assertEquals(12.0, route.rollAngle.max)
}
}
@@ -0,0 +1,109 @@
package org.nstart.dep265.requestservice.repository
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
import org.nstart.dep265.requestservice.entity.OutboxEventEntity
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@SpringBootTest(
classes = [PcpRequestServiceApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = [
"spring.config.import=",
"spring.cloud.config.enabled=false",
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.datasource.url=jdbc:h2:mem:outbox-events;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.kafka.listener.auto-startup=false",
"spring.kafka.consumer.group-id=request-service-test",
"app.kafka.topics.route=request-service-test-route",
],
)
class OutboxEventRepositoryJpaTest @Autowired constructor(
private val outboxEventRepository: OutboxEventRepository,
) {
private val objectMapper = jacksonObjectMapper()
@BeforeEach
fun setUp() {
outboxEventRepository.deleteAll()
}
@Test
fun `unique constraint prevents duplicate request completed event for same request`() {
val requestId = UUID.fromString("73da563f-448b-4303-a1df-41d618d3cb6d")
outboxEventRepository.saveAndFlush(outboxEvent(requestId = requestId))
assertFailsWith<DataIntegrityViolationException> {
outboxEventRepository.saveAndFlush(outboxEvent(requestId = requestId))
}
}
@Test
@Transactional
fun `publishable query returns new events before failed and excludes published events`() {
val oldFailedEvent = outboxEvent(
requestId = UUID.fromString("58c9fb48-3930-4c5d-8486-4385130c4706"),
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
status = OutboxEventStatus.FAILED,
)
val newEvent = outboxEvent(
requestId = UUID.fromString("85cb5fb8-9666-4705-bf02-1376a5d74975"),
createdAt = LocalDateTime.parse("2026-01-01T10:00:00"),
status = OutboxEventStatus.NEW,
)
val newerFailedEvent = outboxEvent(
requestId = UUID.fromString("62faf96d-8bd5-46ba-a541-c10e78851f86"),
createdAt = LocalDateTime.parse("2026-01-01T10:01:00"),
status = OutboxEventStatus.FAILED,
)
val publishedEvent = outboxEvent(
requestId = UUID.fromString("75051f40-9e67-4f72-9279-b4c64da409d9"),
status = OutboxEventStatus.PUBLISHED,
)
val otherEventType = outboxEvent(
requestId = UUID.fromString("d911b183-2ba5-493f-81fa-944366db63b5"),
eventType = "REQUEST_CREATED",
status = OutboxEventStatus.NEW,
)
outboxEventRepository.saveAllAndFlush(listOf(oldFailedEvent, newEvent, newerFailedEvent, publishedEvent, otherEventType))
val events = outboxEventRepository.findRequestCompletedPublishableForUpdateSkipLocked(
eventType = "REQUEST_COMPLETED",
batchSize = 10,
)
assertEquals(listOf(newEvent.id, oldFailedEvent.id, newerFailedEvent.id), events.map { it.id })
}
private fun outboxEvent(
requestId: UUID,
eventType: String = "REQUEST_COMPLETED",
status: OutboxEventStatus = OutboxEventStatus.NEW,
createdAt: LocalDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
): OutboxEventEntity {
return OutboxEventEntity(
id = UUID.randomUUID(),
eventType = eventType,
aggregateType = "REQUEST",
aggregateId = requestId,
payload = objectMapper.readTree("""{"requestId":"$requestId","status":"COMPLETED"}"""),
status = status,
createdAt = createdAt,
)
}
}
@@ -0,0 +1,293 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
import jakarta.persistence.EntityManager
import kotlin.test.assertEquals
@SpringBootTest(
classes = [PcpRequestServiceApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = [
"spring.config.import=",
"spring.cloud.config.enabled=false",
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.datasource.url=jdbc:h2:mem:cells-query;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.kafka.listener.auto-startup=false",
"spring.kafka.consumer.group-id=request-service-test",
"app.kafka.topics.route=request-service-test-route",
],
)
@Transactional
class CellsQueryServiceJpaTest @Autowired constructor(
private val earthCellRepository: EarthCellRepository,
private val requestCellRepository: RequestCellRepository,
private val entityManager: EntityManager,
private val service: CellsQueryService,
) {
@Test
fun `list cells without min importance returns all cells paged`() {
saveCell(cellNum = 1, importance = 3.0)
saveCell(cellNum = 2, importance = 2.0)
saveCell(cellNum = 3, importance = 1.0)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 1,
size = 2,
sort = null,
)
assertEquals(listOf(3L), response.items.map { cell -> cell.cellNum })
assertEquals(1, response.page)
assertEquals(2, response.size)
assertEquals(3, response.totalItems)
assertEquals(2, response.totalPages)
}
@Test
fun `list cells filters by min importance in repository query`() {
saveCell(cellNum = 1, importance = 3.0)
saveCell(cellNum = 2, importance = 1.0)
saveCell(cellNum = 3, importance = 2.0)
val response = service.listCells(
minImportance = 2.0,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(1L), response.items.map { cell -> cell.cellNum })
assertEquals(1, response.totalItems)
}
@Test
fun `list cells uses strict min importance filter and counts only matching cells`() {
saveCell(cellNum = 1, importance = 0.0)
saveCell(cellNum = 2, importance = 0.5)
val response = service.listCells(
minImportance = 0.0,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(2L), response.items.map { cell -> cell.cellNum })
assertEquals(1, response.totalItems)
assertEquals(1, response.totalPages)
}
@Test
fun `list cells excludes cells equal to legacy min importance threshold`() {
saveCell(cellNum = 1, importance = 0.001)
saveCell(cellNum = 2, importance = 0.002)
val response = service.listCells(
minImportance = 0.001,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(2L), response.items.map { cell -> cell.cellNum })
assertEquals(1, response.totalItems)
}
@Test
fun `list cells without min importance includes zero importance cells`() {
saveCell(cellNum = 1, importance = 0.0)
saveCell(cellNum = 2, importance = 0.5)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(2L, 1L), response.items.map { cell -> cell.cellNum })
assertEquals(2, response.totalItems)
}
@Test
fun `list cells uses default importance desc and cell num asc sort`() {
saveCell(cellNum = 3, importance = 5.0)
saveCell(cellNum = 1, importance = 5.0)
saveCell(cellNum = 2, importance = 7.0)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
assertEquals(listOf(2L, 1L, 3L), response.items.map { cell -> cell.cellNum })
}
@Test
fun `list cells supports whitelisted sort field`() {
saveCell(cellNum = 1, latitude = 55.0)
saveCell(cellNum = 2, latitude = 53.0)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = "latitude,asc",
)
assertEquals(listOf(2L, 1L), response.items.map { cell -> cell.cellNum })
}
@Test
fun `list cells returns response fields matching openapi schema`() {
saveCell(
cellNum = 42,
latitude = 55.0,
longitude = 37.0,
importance = 10.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
val response = service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
val cell = response.items.single()
assertEquals(42L, cell.cellNum)
assertEquals(55.0, cell.latitude)
assertEquals(37.0, cell.longitude)
assertEquals(10.0, cell.importance)
assertEquals("POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))", cell.contour)
}
@Test
fun `list cells with requests returns all cells and request fragment fields`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000201")
val cellWithRequest = saveCell(cellNum = 10, importance = 5.0)
saveCell(cellNum = 11, importance = 0.0)
requestCellRepository.saveAndFlush(
RequestCellEntity(
requestId = requestId,
coveragePercent = 33.0,
importance = 7.0,
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))",
cell = cellWithRequest,
)
)
entityManager.clear()
val response = service.listCellsWithRequests(
minImportance = null,
countLat = null,
countLong = null,
)
assertEquals(listOf(10L, 11L), response.items.map { cell -> cell.cellNum })
assertEquals(emptyList(), response.items[1].requests)
val fragment = response.items.first().requests.single()
assertEquals(requestId, fragment.requestId)
assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", fragment.contour)
assertEquals(33.0, fragment.coveragePercent)
assertEquals(7.0, fragment.importance)
}
@Test
fun `list cells with requests filters by earth cell importance`() {
saveCell(cellNum = 10, importance = 5.0)
saveCell(cellNum = 11, importance = 2.0)
entityManager.clear()
val response = service.listCellsWithRequests(
minImportance = 2.0,
countLat = null,
countLong = null,
)
assertEquals(listOf(10L), response.items.map { cell -> cell.cellNum })
}
@Test
fun `list cells with requests uses strict min importance filter`() {
saveCell(cellNum = 10, importance = 0.0)
saveCell(cellNum = 11, importance = 1.0)
entityManager.clear()
val response = service.listCellsWithRequests(
minImportance = 0.0,
countLat = null,
countLong = null,
)
assertEquals(listOf(11L), response.items.map { cell -> cell.cellNum })
}
@Test
fun `list cells with requests excludes cells equal to legacy min importance threshold`() {
saveCell(cellNum = 10, importance = 0.001)
saveCell(cellNum = 11, importance = 0.002)
entityManager.clear()
val response = service.listCellsWithRequests(
minImportance = 0.001,
countLat = null,
countLong = null,
)
assertEquals(listOf(11L), response.items.map { cell -> cell.cellNum })
}
private fun saveCell(
cellNum: Long,
latitude: Double = 55.0,
longitude: Double = 37.0,
importance: Double = 0.0,
contour: String = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
): EarthCellEntity {
return earthCellRepository.saveAndFlush(
EarthCellEntity(
cellNum = cellNum,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = contour,
)
)
}
}
@@ -0,0 +1,258 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.isNull
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Pageable
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
class CellsQueryServiceTest {
@Test
fun `list cells uses repository pageable query and does not call find all`() {
val earthCellRepository = emptyCellsRepository()
val service = service(earthCellRepository)
service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
verify(earthCellRepository).findCells(isNull(), pageableMatcher())
verify(earthCellRepository, never()).findAll()
}
@Test
fun `list cells builds default sort`() {
val capturedPageables = mutableListOf<Pageable>()
val earthCellRepository = emptyCellsRepository(capturedPageables)
val service = service(earthCellRepository)
service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
val sort = capturedPageables.single().sort.toList()
assertEquals("importance", sort[0].property)
assertEquals(false, sort[0].isAscending)
assertEquals("cellNum", sort[1].property)
assertEquals(true, sort[1].isAscending)
}
@Test
fun `list cells rejects invalid query values`() {
val service = service(emptyCellsRepository())
assertThrows<InvalidCellsQueryException> {
service.listCells(
minImportance = -1.0,
countLat = null,
countLong = null,
page = 0,
size = 50,
sort = null,
)
}
assertThrows<InvalidCellsQueryException> {
service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = -1,
size = 50,
sort = null,
)
}
assertThrows<InvalidCellsQueryException> {
service.listCells(
minImportance = null,
countLat = null,
countLong = null,
page = 0,
size = 501,
sort = null,
)
}
}
@Test
fun `list cells with requests returns fragments from request cells`() {
val requestId = UUID.fromString("00000000-0000-0000-0000-000000000101")
val cellWithRequest = cell(cellNum = 10, importance = 5.0)
cellWithRequest.requestProjections = mutableListOf(
RequestCellEntity(
requestId = requestId,
contour = "POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))",
coveragePercent = 25.0,
importance = 7.0,
cell = cellWithRequest,
)
)
val cellWithoutRequests = cell(cellNum = 11, importance = 0.0)
val earthCellRepository = emptyCellsRepository().also { repository ->
doReturn(listOf(cellWithRequest, cellWithoutRequests))
.`when`(repository)
.findCellsWithRequestProjections(isNull())
}
val service = service(earthCellRepository)
val response = service.listCellsWithRequests(
minImportance = null,
countLat = null,
countLong = null,
)
assertEquals(listOf(10L, 11L), response.items.map { cell -> cell.cellNum })
assertEquals(emptyList(), response.items[1].requests)
val fragment = response.items.first().requests.single()
assertEquals(requestId, fragment.requestId)
assertEquals("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", fragment.contour)
assertEquals(25.0, fragment.coveragePercent)
assertEquals(7.0, fragment.importance)
verify(earthCellRepository).findCellsWithRequestProjections(isNull())
verify(earthCellRepository, never()).findCells(isNull(), pageableMatcher())
verify(earthCellRepository, never()).findAll()
}
@Test
fun `list cells with requests passes min importance to repository`() {
val earthCellRepository = emptyCellsRepository().also { repository ->
doReturn(listOf(cell(cellNum = 10, importance = 5.0)))
.`when`(repository)
.findCellsWithRequestProjections(2.0)
}
val service = service(earthCellRepository)
val response = service.listCellsWithRequests(
minImportance = 2.0,
countLat = null,
countLong = null,
)
assertEquals(listOf(10L), response.items.map { cell -> cell.cellNum })
verify(earthCellRepository).findCellsWithRequestProjections(2.0)
}
@Test
fun `list cells with requests rejects partial aggregation parameters`() {
val service = service(emptyCellsRepository())
assertThrows<InvalidCellsQueryException> {
service.listCellsWithRequests(
minImportance = null,
countLat = 2,
countLong = null,
)
}
assertThrows<InvalidCellsQueryException> {
service.listCellsWithRequests(
minImportance = null,
countLat = null,
countLong = 2,
)
}
}
@Test
fun `list cells with requests delegates to existing aggregation when both counts are present`() {
val earthCellRepository = emptyCellsRepository().also { repository ->
doReturn(
listOf(
cell(cellNum = 10, latitude = -88.0, longitude = 0.0, importance = 1.0),
cell(cellNum = 11, latitude = -88.0, longitude = 2.0, importance = 2.0),
)
)
.`when`(repository)
.findCellsWithRequestProjections(isNull())
}
val service = service(earthCellRepository)
val response = service.listCellsWithRequests(
minImportance = null,
countLat = 1,
countLong = 2,
)
assertEquals(1, response.items.size)
assertEquals(10L, response.items.first().cellNum)
assertEquals(3.0, response.items.first().importance)
assertEquals("POLYGON ((-2.0 -90.0, 2.0 -90.0, 2.0 -88.0, -2.0 -88.0, -2.0 -90.0))", response.items.first().contour)
}
private fun emptyCellsRepository(capturedPageables: MutableList<Pageable> = mutableListOf()): EarthCellRepository {
return mock(EarthCellRepository::class.java).also { repository ->
doAnswer { invocation ->
capturedPageables += invocation.getArgument<Pageable>(1)
PageImpl(emptyList<EarthCellEntity>(), PageRequest.of(0, 50), 0)
}
.`when`(repository)
.findCells(isNull(), pageableMatcher())
doReturn(emptyList<EarthCellEntity>())
.`when`(repository)
.findCellsWithRequestProjections(isNull())
}
}
private fun service(earthCellRepository: EarthCellRepository): CellsQueryService {
val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
.`when`(repository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
}
val earthGridCatalogService = EarthGridCatalogService(
earthCellRepository = earthCellRepository,
earthGridSettingsRepository = earthGridSettingsRepository,
compatGeometryService = CompatGeometryService(GeometryService()),
gridEnabled = false,
)
return CellsQueryService(earthCellRepository, earthGridCatalogService)
}
private fun cell(
cellNum: Long,
latitude: Double = 55.0,
longitude: Double = 37.0,
importance: Double = 0.0,
contour: String = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
): EarthCellEntity {
return EarthCellEntity(
cellNum = cellNum,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = contour,
)
}
private fun pageableMatcher(): Pageable {
any(Pageable::class.java)
return PageRequest.of(0, 50)
}
}
@@ -0,0 +1,177 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyList
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class EarthGridCatalogServiceTest {
private val earthCellRepository = mock(EarthCellRepository::class.java)
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java)
private val geometryService = GeometryService()
private val compatGeometryService = CompatGeometryService(geometryService)
@Test
fun `init stores requested calculation step`() {
val currentSettings = EarthGridSettingsEntity(calculationStep = 2.0)
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(currentSettings))
val service = service(gridEnabled = false)
service.init(3.5)
val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java)
verify(earthGridSettingsRepository).save(settingsCaptor.capture())
verifyNoInteractions(earthCellRepository)
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId)
assertEquals(3.5, settingsCaptor.value.calculationStep)
}
@Test
fun `init creates settings row when it is missing`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.empty())
val service = service(gridEnabled = false)
service.init(1.0)
val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java)
verify(earthGridSettingsRepository).save(settingsCaptor.capture())
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId)
assertEquals(1.0, settingsCaptor.value.calculationStep)
}
@Test
fun `init creates earth cells with zero non-null importance`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
val savedCells = mutableListOf<EarthCellEntity>()
org.mockito.Mockito.doAnswer { invocation ->
val cells = invocation.getArgument<List<EarthCellEntity>>(0)
savedCells += cells
cells
}.`when`(earthCellRepository).saveAll(anyList())
val service = service(gridEnabled = true)
service.init(180.0)
assertTrue(savedCells.isNotEmpty())
assertTrue(savedCells.all { earthCell -> earthCell.importance == 0.0 })
}
@Test
fun `by importance returns source cells when aggregation params are absent`() {
`when`(earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(0.0))
.thenReturn(listOf(cell(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 2.0)))
val service = service(gridEnabled = false)
val cells = service.byImportance(0.0)
assertEquals(1, cells.size)
assertEquals(10L, cells.first().num)
assertEquals(2.0, cells.first().importance)
}
@Test
fun `by importance aggregates neighbor cells using stored calculation step`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
`when`(earthCellRepository.findAllByImportanceGreaterThanOrderByImportanceDesc(0.0))
.thenReturn(
listOf(
cell(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 1.0),
cell(id = 2L, num = 11L, latitude = -88.0, longitude = 2.0, importance = 2.0),
cell(id = 3L, num = 12L, latitude = -86.0, longitude = 0.0, importance = 3.0),
cell(id = 4L, num = 13L, latitude = -86.0, longitude = 2.0, importance = 4.0),
)
)
val service = service(gridEnabled = false)
val cells = service.byImportance(0.0, countLat = 2, countLong = 2)
assertEquals(1, cells.size)
assertEquals(10.0, cells.first().importance)
assertEquals(-88.0, cells.first().latitude)
assertEquals(0.0, cells.first().longitude)
assertEquals(
"POLYGON ((-2.0 -90.0, 2.0 -90.0, 2.0 -86.0, -2.0 -86.0, -2.0 -90.0))",
cells.first().contour,
)
}
@Test
fun `with requests aggregates cells and keeps request fragments`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
`when`(earthCellRepository.findCellsWithRequestProjections(null))
.thenReturn(
listOf(
cellWithRequest(id = 1L, num = 10L, latitude = -88.0, longitude = 0.0, importance = 1.0),
cellWithRequest(id = 2L, num = 11L, latitude = -88.0, longitude = 2.0, importance = 2.0),
)
)
val service = service(gridEnabled = false)
val cells = service.allWithRequests(countLat = 1, countLong = 2)
assertEquals(1, cells.size)
assertEquals(3.0, cells.first().importance)
assertEquals(2, cells.first().requests.count())
}
private fun service(gridEnabled: Boolean) =
EarthGridCatalogService(
earthCellRepository = earthCellRepository,
earthGridSettingsRepository = earthGridSettingsRepository,
compatGeometryService = compatGeometryService,
gridEnabled = gridEnabled,
)
private fun cell(
id: Long,
num: Long,
latitude: Double,
longitude: Double,
importance: Double,
) = EarthCellEntity(
cellId = id,
cellNum = num,
latitude = latitude,
longitude = longitude,
importance = importance,
contour = "",
)
private fun cellWithRequest(
id: Long,
num: Long,
latitude: Double,
longitude: Double,
importance: Double,
): EarthCellEntity {
val earthCell = cell(id, num, latitude, longitude, importance)
earthCell.requestProjections = mutableListOf(
RequestCellEntity(
id = id,
requestId = UUID.fromString("00000000-0000-0000-0000-${id.toString().padStart(12, '0')}"),
contour = "POLYGON EMPTY",
importance = importance,
cell = earthCell,
)
)
return earthCell
}
}
@@ -0,0 +1,162 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyCollection
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.inOrder
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class GridRebuildServiceTest {
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<EarthGridSettingsEntity>(0) }
.`when`(repository)
.save(org.mockito.ArgumentMatchers.any(EarthGridSettingsEntity::class.java))
}
private val gridSettingsService = GridSettingsService(earthGridSettingsRepository)
private val earthGridCatalogService = mock(EarthGridCatalogService::class.java)
private val requestCellRepository = mock(RequestCellRepository::class.java)
private val requestRepository = mock(RequestRepository::class.java)
private val requestGridProjectionService = mock(RequestGridProjectionService::class.java)
private val earthCellRepository = mock(EarthCellRepository::class.java)
private val gridRebuildLockService = mock(GridRebuildLockService::class.java)
@Test
fun `rebuild grid rebuilds catalog and projections only for active requests`() {
val acceptedRequest = request(status = RequestStatus.ACCEPTED)
val activeRequest = request(status = RequestStatus.ACTIVE)
val completedRequest = request(status = RequestStatus.COMPLETED)
val expiredRequest = request(status = RequestStatus.EXPIRED)
val deletedRequest = request(status = RequestStatus.DELETED)
doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock()
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 180.0)))
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
doReturn(listOf(acceptedRequest, activeRequest))
.`when`(requestRepository)
.findByStatusIn(anyCollection())
doReturn(2L).`when`(earthCellRepository).count()
val response = service().rebuildGrid()
assertEquals(2L, response.cellsCount)
assertEquals(2L, response.rebuiltRequestProjectionsCount)
val order = inOrder(requestCellRepository, earthGridCatalogService, requestGridProjectionService)
order.verify(requestCellRepository).deleteAllInBatch()
order.verify(earthGridCatalogService).init(180.0)
order.verify(requestGridProjectionService).rebuildProjection(acceptedRequest)
order.verify(requestGridProjectionService).rebuildProjection(activeRequest)
verify(requestRepository).findByStatusIn(setOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE))
verify(requestGridProjectionService, never()).rebuildProjection(completedRequest)
verify(requestGridProjectionService, never()).rebuildProjection(expiredRequest)
verify(requestGridProjectionService, never()).rebuildProjection(deletedRequest)
}
@Test
fun `rebuild grid uses default settings when settings row is missing`() {
doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock()
doReturn(Optional.empty<EarthGridSettingsEntity>())
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
doReturn(emptyList<RequestEntity>())
.`when`(requestRepository)
.findByStatusIn(anyCollection())
doReturn(0L).`when`(earthCellRepository).count()
val response = service().rebuildGrid()
assertEquals(0L, response.rebuiltRequestProjectionsCount)
verify(earthGridCatalogService).init(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP)
}
@Test
fun `rebuild grid fails with conflict before side effects when lock is busy`() {
doReturn(false).`when`(gridRebuildLockService).tryAcquireTransactionLock()
assertFailsWith<GridRebuildAlreadyRunningException> {
service().rebuildGrid()
}
verifyNoInteractions(
earthGridSettingsRepository,
earthGridCatalogService,
requestCellRepository,
requestRepository,
requestGridProjectionService,
earthCellRepository,
)
}
@Test
fun `rebuild grid does not mutate request domain fields`() {
val activeRequest = request(status = RequestStatus.ACTIVE)
val remainingGeometry = activeRequest.remainingGeometry
val remainingArea = activeRequest.remainingArea
doReturn(true).`when`(gridRebuildLockService).tryAcquireTransactionLock()
doReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 180.0)))
.`when`(earthGridSettingsRepository)
.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID)
doReturn(listOf(activeRequest))
.`when`(requestRepository)
.findByStatusIn(anyCollection())
doReturn(2L).`when`(earthCellRepository).count()
service().rebuildGrid()
assertEquals(remainingGeometry, activeRequest.remainingGeometry)
assertEquals(remainingArea, activeRequest.remainingArea)
assertEquals(RequestStatus.ACTIVE, activeRequest.status)
}
private fun service() =
GridRebuildService(
gridSettingsService = gridSettingsService,
earthGridCatalogService = earthGridCatalogService,
requestCellRepository = requestCellRepository,
requestRepository = requestRepository,
requestGridProjectionService = requestGridProjectionService,
earthCellRepository = earthCellRepository,
gridRebuildLockService = gridRebuildLockService,
)
private fun request(status: RequestStatus): RequestEntity {
val geometry = "POLYGON ((0 1, 1 1, 1 0, 0 0, 0 1))"
val now = LocalDateTime.parse("2026-05-20T10:00:00")
return RequestEntity(
id = UUID.randomUUID(),
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 0.5,
importance = 7.0,
beginDateTime = now,
endDateTime = now.plusDays(1),
surveyType = RequestSurveyType.OPTICS,
status = status,
createdAt = now,
updatedAt = now,
deletedAt = if (status == RequestStatus.DELETED) now else null,
)
}
}
@@ -0,0 +1,98 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.nstart.dep265.requestservice.dto.UpdateGridSettingsRequestDto
import org.nstart.dep265.requestservice.entity.EarthGridSettingsEntity
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.EarthGridSettingsRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import java.util.Optional
import kotlin.test.assertEquals
class GridSettingsServiceTest {
private val earthGridSettingsRepository = mock(EarthGridSettingsRepository::class.java).also { repository ->
doAnswer { invocation -> invocation.getArgument<EarthGridSettingsEntity>(0) }
.`when`(repository)
.save(any(EarthGridSettingsEntity::class.java))
}
@Test
fun `get settings returns current settings`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 4.0)))
val service = GridSettingsService(earthGridSettingsRepository)
val response = service.getSettings()
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, response.settingsId)
assertEquals(4.0, response.calculationStep)
verify(earthGridSettingsRepository, never()).save(any(EarthGridSettingsEntity::class.java))
}
@Test
fun `get settings creates and returns default settings when missing`() {
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.empty())
val service = GridSettingsService(earthGridSettingsRepository)
val response = service.getSettings()
val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java)
verify(earthGridSettingsRepository).save(settingsCaptor.capture())
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId)
assertEquals(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP, settingsCaptor.value.calculationStep)
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, response.settingsId)
assertEquals(EarthGridSettingsEntity.DEFAULT_CALCULATION_STEP, response.calculationStep)
}
@Test
fun `put settings updates calculation step`() {
val currentSettings = EarthGridSettingsEntity(calculationStep = 2.0)
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(currentSettings))
val service = GridSettingsService(earthGridSettingsRepository)
val response = service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 3.5))
val settingsCaptor = ArgumentCaptor.forClass(EarthGridSettingsEntity::class.java)
verify(earthGridSettingsRepository).save(settingsCaptor.capture())
assertEquals(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID, settingsCaptor.value.settingsId)
assertEquals(3.5, settingsCaptor.value.calculationStep)
assertEquals(3.5, response.calculationStep)
}
@Test
fun `put settings does not rebuild earth cells delete request cells or project requests`() {
val earthCellRepository = mock(EarthCellRepository::class.java)
val requestCellRepository = mock(RequestCellRepository::class.java)
val requestGridProjectionService = mock(RequestGridProjectionService::class.java)
`when`(earthGridSettingsRepository.findById(EarthGridSettingsEntity.DEFAULT_SETTINGS_ID))
.thenReturn(Optional.of(EarthGridSettingsEntity(calculationStep = 2.0)))
val service = GridSettingsService(earthGridSettingsRepository)
service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 1.0))
verifyNoInteractions(earthCellRepository)
verifyNoInteractions(requestCellRepository)
verifyNoInteractions(requestGridProjectionService)
}
@Test
fun `put settings rejects non-positive calculation step`() {
val service = GridSettingsService(earthGridSettingsRepository)
assertThrows<InvalidGridSettingsException> {
service.updateSettings(UpdateGridSettingsRequestDto(calculationStep = 0.0))
}
verify(earthGridSettingsRepository, never()).save(any(EarthGridSettingsEntity::class.java))
}
}
@@ -0,0 +1,282 @@
package org.nstart.dep265.requestservice.service
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.nstart.dep265.requestservice.config.OutboxProperties
import org.nstart.dep265.requestservice.entity.OutboxEventEntity
import org.nstart.dep265.requestservice.entity.OutboxEventStatus
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import org.springframework.kafka.core.KafkaTemplate
import org.springframework.kafka.support.SendResult
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.SimpleTransactionStatus
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
import java.util.UUID
import java.util.concurrent.CompletableFuture
class OutboxPublisherServiceTest {
private val objectMapper = jacksonObjectMapper()
private val properties = OutboxProperties(
requestCompletedTopic = "pcp.request.completed.v1",
publishBatchSize = 50,
publishFixedDelayMs = 5_000,
)
@Test
fun `new request completed event is published and marked as published`() {
val requestId = UUID.fromString("6cc8ed4b-0db1-4cfb-bef0-d56eed2edce4")
val event = outboxEvent(
requestId = requestId,
payload = """{"requestId":"$requestId","status":"COMPLETED","source":"outbox"}""",
)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(successfulSend())
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(1, processedCount)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
requestId.toString(),
event.payload.toString(),
)
assertEquals(OutboxEventStatus.PUBLISHED, event.status)
assertNotNull(event.publishedAt)
assertNull(event.errorMessage)
verify(outboxEventRepository).save(event)
}
@Test
fun `failed request completed event is picked for retry`() {
val requestId = UUID.fromString("9986e096-886a-429e-af7f-3a6178c29b4f")
val event = outboxEvent(
requestId = requestId,
status = OutboxEventStatus.FAILED,
errorMessage = "previous failure",
)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(successfulSend())
val service = publisherService(outboxEventRepository, kafkaTemplate)
service.publishPending()
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
requestId.toString(),
event.payload.toString(),
)
assertEquals(OutboxEventStatus.PUBLISHED, event.status)
assertNull(event.errorMessage)
}
@Test
fun `payload from outbox event is sent without rebuilding it`() {
val requestId = UUID.fromString("e390c558-a09f-41c6-9e45-307c04bb3365")
val savedPayload = """{"requestId":"$requestId","status":"COMPLETED","customField":"kept-from-outbox"}"""
val event = outboxEvent(requestId = requestId, payload = savedPayload)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(successfulSend())
val service = publisherService(outboxEventRepository, kafkaTemplate)
service.publishPending()
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
requestId.toString(),
objectMapper.readTree(savedPayload).toString(),
)
}
@Test
fun `kafka failure marks event as failed and stores short error message`() {
val requestId = UUID.fromString("116eb4c7-cd6d-4837-8525-21c1d16ecbbb")
val event = outboxEvent(requestId = requestId)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("broker unavailable")))
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(1, processedCount)
assertEquals(OutboxEventStatus.FAILED, event.status)
assertTrue(event.errorMessage!!.contains("broker unavailable"))
assertNull(event.publishedAt)
verify(outboxEventRepository).save(event)
}
@Test
fun `failed event is not retried repeatedly in same publish pending call`() {
val requestId = UUID.fromString("f31992a5-efae-4d9a-8262-78381da835f7")
val event = outboxEvent(
requestId = requestId,
status = OutboxEventStatus.FAILED,
errorMessage = "previous failure",
)
val outboxEventRepository = repositoryReturning(listOf(event))
val kafkaTemplate = kafkaTemplateReturning(failedSend(IllegalStateException("still broken")))
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(1, processedCount)
verify(kafkaTemplate, times(1)).send(
properties.requestCompletedTopic,
requestId.toString(),
event.payload.toString(),
)
verify(outboxEventRepository, times(1)).findRequestCompletedPublishableForUpdateSkipLocked(
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
properties.publishBatchSize,
)
assertEquals(OutboxEventStatus.FAILED, event.status)
assertTrue(event.errorMessage!!.contains("still broken"))
}
@Test
fun `failed event does not block other events from same batch`() {
val failedRequestId = UUID.fromString("7000c57f-a41a-4788-8929-42a181ff2135")
val newRequestId = UUID.fromString("e5ca9291-4d59-457c-995f-91c618dbd872")
val failedEvent = outboxEvent(
requestId = failedRequestId,
status = OutboxEventStatus.FAILED,
errorMessage = "previous failure",
)
val newEvent = outboxEvent(requestId = newRequestId)
val outboxEventRepository = repositoryReturning(listOf(newEvent, failedEvent))
val kafkaTemplate = kafkaTemplateFailingForKeys(setOf(failedRequestId.toString()))
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(2, processedCount)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
failedRequestId.toString(),
failedEvent.payload.toString(),
)
verify(kafkaTemplate).send(
properties.requestCompletedTopic,
newRequestId.toString(),
newEvent.payload.toString(),
)
assertEquals(OutboxEventStatus.FAILED, failedEvent.status)
assertEquals(OutboxEventStatus.PUBLISHED, newEvent.status)
assertNotNull(newEvent.publishedAt)
}
@Test
fun `published event is not published again when repository returns no publishable rows`() {
val outboxEventRepository = repositoryReturning(emptyList())
val kafkaTemplate = kafkaTemplateReturning(successfulSend())
val service = publisherService(outboxEventRepository, kafkaTemplate)
val processedCount = service.publishPending()
assertEquals(0, processedCount)
verifyNoInteractions(kafkaTemplate)
}
private fun publisherService(
outboxEventRepository: OutboxEventRepository,
kafkaTemplate: KafkaTemplate<String, String>,
): OutboxPublisherService {
return OutboxPublisherService(
outboxEventRepository = outboxEventRepository,
kafkaTemplate = kafkaTemplate,
outboxProperties = properties,
transactionOperations = ImmediateTransactionOperations(),
)
}
private fun repositoryReturning(events: List<OutboxEventEntity>): OutboxEventRepository {
val outboxEventRepository = mock(OutboxEventRepository::class.java)
doReturn(events)
.`when`(outboxEventRepository)
.findRequestCompletedPublishableForUpdateSkipLocked(
RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
properties.publishBatchSize,
)
return outboxEventRepository
}
@Suppress("UNCHECKED_CAST")
private fun kafkaTemplateReturning(
result: CompletableFuture<SendResult<String, String>>,
): KafkaTemplate<String, String> {
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
doReturn(result)
.`when`(kafkaTemplate)
.send(anyString(), anyString(), anyString())
return kafkaTemplate
}
@Suppress("UNCHECKED_CAST")
private fun kafkaTemplateFailingForKeys(failedKeys: Set<String>): KafkaTemplate<String, String> {
val kafkaTemplate = mock(KafkaTemplate::class.java) as KafkaTemplate<String, String>
doAnswer { invocation ->
val key = invocation.arguments[1] as String
if (key in failedKeys) {
failedSend(IllegalStateException("broker unavailable for $key"))
} else {
successfulSend()
}
}
.`when`(kafkaTemplate)
.send(anyString(), anyString(), anyString())
return kafkaTemplate
}
@Suppress("UNCHECKED_CAST")
private fun successfulSend(): CompletableFuture<SendResult<String, String>> {
val sendResult = mock(SendResult::class.java) as SendResult<String, String>
return CompletableFuture.completedFuture(sendResult)
}
private fun failedSend(exception: Exception): CompletableFuture<SendResult<String, String>> {
val future = CompletableFuture<SendResult<String, String>>()
future.completeExceptionally(exception)
return future
}
private fun outboxEvent(
requestId: UUID,
payload: String = """{"requestId":"$requestId","status":"COMPLETED"}""",
status: OutboxEventStatus = OutboxEventStatus.NEW,
errorMessage: String? = null,
): OutboxEventEntity {
return OutboxEventEntity(
id = UUID.randomUUID(),
eventType = RequestCompletedOutboxService.REQUEST_COMPLETED_EVENT_TYPE,
aggregateType = RequestCompletedOutboxService.REQUEST_AGGREGATE_TYPE,
aggregateId = requestId,
payload = objectMapper.readTree(payload),
status = status,
createdAt = LocalDateTime.parse("2026-01-01T10:00:00"),
errorMessage = errorMessage,
)
}
private class ImmediateTransactionOperations : TransactionOperations {
override fun <T : Any?> execute(action: TransactionCallback<T>): T {
return action.doInTransaction(SimpleTransactionStatus())
}
override fun executeWithoutResult(action: java.util.function.Consumer<TransactionStatus>) {
action.accept(SimpleTransactionStatus())
}
}
}
@@ -0,0 +1,65 @@
package org.nstart.dep265.requestservice.service
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.mockingDetails
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import java.time.LocalDateTime
import java.util.UUID
class RequestCompletedOutboxServiceTest {
private val objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.findAndRegisterModules()
@Test
fun `request completed payload contains required fields`() {
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val service = RequestCompletedOutboxService(
outboxEventRepository = outboxEventRepository,
objectMapper = objectMapper,
geometryService = GeometryService(),
)
val requestId = UUID.fromString("5f57cd22-44a7-4fe8-956f-7fd0c52df846")
val completedAt = LocalDateTime.parse("2026-01-01T10:15:30")
val matchedAt = LocalDateTime.parse("2026-01-01T10:14:00")
val eventCreatedAt = LocalDateTime.parse("2026-01-01T10:16:00")
val request = RequestEntity(
id = requestId,
name = "Тест1",
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
remainingGeometry = "POLYGON EMPTY",
geometryArea = 1.0,
remainingArea = 0.0,
importance = 10.0,
beginDateTime = LocalDateTime.parse("2026-01-01T10:00:00"),
endDateTime = LocalDateTime.parse("2026-01-01T11:00:00"),
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.COMPLETED,
lastMatchedAt = matchedAt,
completedAt = completedAt,
createdAt = LocalDateTime.parse("2026-01-01T09:00:00"),
updatedAt = completedAt,
)
service.createRequestCompletedIfAbsent(request, eventCreatedAt)
val insertInvocation = mockingDetails(outboxEventRepository).invocations.single {
invocation -> invocation.method.name == "insertIfAbsent"
}
val payload = objectMapper.readTree(insertInvocation.arguments[4] as String)
assertEquals(requestId.toString(), payload["requestId"].asText())
assertEquals("COMPLETED", payload["status"].asText())
assertNotNull(payload["completedAt"])
assertEquals(100.0, payload["coveragePercent"].asDouble())
assertNotNull(payload["matchedAt"])
assertNotNull(payload["eventCreatedAt"])
}
}
@@ -0,0 +1,142 @@
package org.nstart.dep265.requestservice.service
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.anyString
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.mock
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import java.time.LocalDateTime
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class RequestGridProjectionServiceTest {
private val geometryService = GeometryService()
@Test
fun `binds cells correctly for request in minus180 plus180 format crossing zero meridian`() {
val savedFragments = rebuildProjection(
geometry = "POLYGON ((-1 1, 1 1, 1 -1, -1 -1, -1 1))",
)
assertEquals(setOf(100L, 101L), savedFragments.map { fragment -> fragment.cell?.cellNum }.toSet())
assertFragmentInCellBand(savedFragments, 100L, 359.0, 360.0)
assertFragmentInCellBand(savedFragments, 101L, 0.0, 1.0)
}
@Test
fun `binds cells correctly for request in zero360 format crossing zero meridian`() {
val savedFragments = rebuildProjection(
geometry = "POLYGON ((359 1, 1 1, 1 -1, 359 -1, 359 1))",
)
assertEquals(setOf(100L, 101L), savedFragments.map { fragment -> fragment.cell?.cellNum }.toSet())
assertFragmentInCellBand(savedFragments, 100L, 359.0, 360.0)
assertFragmentInCellBand(savedFragments, 101L, 0.0, 1.0)
}
@Test
fun `request cells inherit request importance and make earth cell importance positive`() {
val savedFragments = rebuildProjection(
geometry = "POLYGON ((0 1, 1 1, 1 -1, 0 -1, 0 1))",
importance = 7.5,
)
assertTrue(savedFragments.isNotEmpty())
assertTrue(savedFragments.all { fragment -> fragment.importance == 7.5 })
val earthCellImportance = savedFragments.sumOf { fragment ->
fragment.importance * ((fragment.coveragePercent ?: 0.0) / 100.0)
}
assertTrue(earthCellImportance > 0.0)
}
private fun rebuildProjection(
geometry: String,
importance: Double = 10.0,
): List<RequestCellEntity> {
val requestRepository = mock(RequestRepository::class.java)
val earthCellRepository = mock(EarthCellRepository::class.java)
val requestCellRepository = mock(RequestCellRepository::class.java)
val savedFragments = mutableListOf<RequestCellEntity>()
val service = RequestGridProjectionService(
requestRepository = requestRepository,
earthCellRepository = earthCellRepository,
requestCellRepository = requestCellRepository,
geometryService = geometryService,
gridEnabled = true,
)
val cells = listOf(
EarthCellEntity(
cellId = 1L,
cellNum = 100L,
contour = "POLYGON ((359 1, 360 1, 360 -1, 359 -1, 359 1))",
),
EarthCellEntity(
cellId = 2L,
cellNum = 101L,
contour = "POLYGON ((0 1, 1 1, 1 -1, 0 -1, 0 1))",
),
)
doAnswer { invocation ->
val polygonWkt = invocation.getArgument<String>(0)
val queryGeometry = geometryService.parsePolygonalGeometry(polygonWkt)
cells.filter { cell ->
val cellGeometry = geometryService.parsePolygonalGeometry(cell.contour)
queryGeometry.intersects(cellGeometry)
}
}.`when`(earthCellRepository).findIntersectingByContour(anyString())
doAnswer { invocation ->
val fragments = invocation.getArgument<List<RequestCellEntity>>(0)
savedFragments += fragments
fragments
}.`when`(requestCellRepository).saveAll(org.mockito.ArgumentMatchers.anyList())
service.rebuildProjection(
RequestEntity(
id = UUID.randomUUID(),
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 1.0,
importance = importance,
beginDateTime = LocalDateTime.now(),
endDateTime = LocalDateTime.now().plusDays(1),
highPriorityTransmit = false,
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.ACTIVE,
matchCount = 0,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
)
)
return savedFragments
}
private fun assertFragmentInCellBand(
savedFragments: List<RequestCellEntity>,
cellNum: Long,
expectedMinX: Double,
expectedMaxX: Double,
) {
val fragment = savedFragments.first { requestCell -> requestCell.cell?.cellNum == cellNum }
val geometry = geometryService.parsePolygonalGeometry(fragment.contour)
assertEquals(expectedMinX, geometry.envelopeInternal.minX)
assertEquals(expectedMaxX, geometry.envelopeInternal.maxX)
assertTrue(geometry.area > 0.0)
}
}
@@ -0,0 +1,255 @@
package org.nstart.dep265.requestservice.service
import jakarta.persistence.EntityManager
import org.junit.jupiter.api.Test
import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto
import org.nstart.dep265.requestservice.dto.RequestStatusDto
import org.nstart.dep265.requestservice.dto.SurveyTypeDto
import org.nstart.dep265.requestservice.PcpRequestServiceApplication
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.EarthCellRepository
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
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
@SpringBootTest(
classes = [PcpRequestServiceApplication::class],
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = [
"spring.config.import=",
"spring.cloud.config.enabled=false",
"spring.flyway.enabled=false",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.datasource.url=jdbc:h2:mem:request-list;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.database-platform=org.hibernate.dialect.H2Dialect",
"spring.kafka.listener.auto-startup=false",
"spring.kafka.consumer.group-id=request-service-test",
"app.kafka.topics.route=request-service-test-route",
],
)
@Transactional
class RequestServiceListRequestsJpaTest @Autowired constructor(
private val earthCellRepository: EarthCellRepository,
private val requestRepository: RequestRepository,
private val service: RequestService,
@Suppress("unused") private val requestOpticsParamsRepository: RequestOpticsParamsRepository,
@Suppress("unused") private val requestRsaParamsRepository: RequestRsaParamsRepository,
@Suppress("unused") private val requestCellRepository: RequestCellRepository,
@Suppress("unused") private val entityManager: EntityManager,
) {
@Test
fun `list requests excludes deleted by default`() {
val activeId = saveRequest(status = RequestStatus.ACTIVE, createdAt = "2026-01-02T00:00:00")
saveRequest(status = RequestStatus.DELETED, createdAt = "2026-01-03T00:00:00")
val response = service.listRequests(ListRequestsQueryDto())
assertEquals(listOf(activeId), response.items.map { request -> request.id })
assertEquals("Тест1", response.items.single().name)
assertEquals(1, response.totalItems)
assertEquals(1, response.totalPages)
}
@Test
fun `list requests includes deleted when includeDeleted is true`() {
val activeId = saveRequest(status = RequestStatus.ACTIVE, createdAt = "2026-01-02T00:00:00")
val deletedId = saveRequest(status = RequestStatus.DELETED, createdAt = "2026-01-03T00:00:00")
val response = service.listRequests(ListRequestsQueryDto(includeDeleted = true))
assertEquals(listOf(deletedId, activeId), response.items.map { request -> request.id })
assertEquals(2, response.totalItems)
}
@Test
fun `list requests filters by status`() {
val activeId = saveRequest(status = RequestStatus.ACTIVE)
saveRequest(status = RequestStatus.ACCEPTED)
val response = service.listRequests(ListRequestsQueryDto(status = RequestStatusDto.ACTIVE))
assertEquals(listOf(activeId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by survey type`() {
val rsaId = saveRequest(surveyType = RequestSurveyType.RSA)
saveRequest(surveyType = RequestSurveyType.OPTICS)
val response = service.listRequests(ListRequestsQueryDto(surveyType = SurveyTypeDto.RSA))
assertEquals(listOf(rsaId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by kpp in database collection`() {
val matchedId = saveRequest(kpp = mutableListOf(1, 2))
saveRequest(kpp = mutableListOf(3, 4))
val response = service.listRequests(ListRequestsQueryDto(kpp = 2))
assertEquals(listOf(matchedId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by high priority transmit`() {
val highPriorityId = saveRequest(highPriorityTransmit = true)
saveRequest(highPriorityTransmit = false)
val response = service.listRequests(ListRequestsQueryDto(highPriorityTransmit = true))
assertEquals(listOf(highPriorityId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by begin date range inclusively`() {
saveRequest(beginDateTime = "2026-01-01T00:00:00")
val matchedId = saveRequest(beginDateTime = "2026-01-15T00:00:00")
saveRequest(beginDateTime = "2026-02-01T00:00:00", endDateTime = "2026-02-28T00:00:00")
val response = service.listRequests(
ListRequestsQueryDto(
beginFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"),
beginTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
)
)
assertEquals(listOf(matchedId), response.items.map { request -> request.id })
}
@Test
fun `list requests filters by end date range inclusively`() {
saveRequest(endDateTime = "2026-01-09T00:00:00")
val matchedId = saveRequest(endDateTime = "2026-01-20T00:00:00")
saveRequest(endDateTime = "2026-02-01T00:00:00")
val response = service.listRequests(
ListRequestsQueryDto(
endFrom = OffsetDateTime.parse("2026-01-10T00:00:00Z"),
endTo = OffsetDateTime.parse("2026-01-31T23:59:59Z"),
)
)
assertEquals(listOf(matchedId), response.items.map { request -> request.id })
}
@Test
fun `list requests paginates with total items and total pages from database page`() {
saveRequest(createdAt = "2026-01-03T00:00:00")
saveRequest(createdAt = "2026-01-02T00:00:00")
val oldestId = saveRequest(createdAt = "2026-01-01T00:00:00")
val response = service.listRequests(ListRequestsQueryDto(page = 1, size = 2))
assertEquals(listOf(oldestId), response.items.map { request -> request.id })
assertEquals(1, response.page)
assertEquals(2, response.size)
assertEquals(3, response.totalItems)
assertEquals(2, response.totalPages)
}
@Test
fun `list requests sorts by created at descending by default`() {
val oldestId = saveRequest(createdAt = "2026-01-01T00:00:00")
val newestId = saveRequest(createdAt = "2026-01-03T00:00:00")
val middleId = saveRequest(createdAt = "2026-01-02T00:00:00")
val response = service.listRequests(ListRequestsQueryDto())
assertEquals(listOf(newestId, middleId, oldestId), response.items.map { request -> request.id })
}
@Test
fun `list requests supports whitelisted sort field`() {
val latestBeginId = saveRequest(beginDateTime = "2026-01-03T00:00:00")
val earliestBeginId = saveRequest(beginDateTime = "2026-01-01T00:00:00")
val response = service.listRequests(ListRequestsQueryDto(sort = "beginDateTime,asc"))
assertEquals(listOf(earliestBeginId, latestBeginId), response.items.map { request -> request.id })
}
@Test
fun `get request with cells loads request cell projections with earth cell numbers`() {
val requestId = saveRequest()
val earthCell = earthCellRepository.saveAndFlush(
EarthCellEntity(
cellNum = 32400,
latitude = 55.0,
longitude = 37.0,
importance = 5.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
)
requestCellRepository.saveAndFlush(
RequestCellEntity(
requestId = requestId,
coveragePercent = 25.0,
importance = 7.0,
contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))",
cell = earthCell,
)
)
entityManager.clear()
val response = service.getRequestWithCells(requestId)
assertEquals(requestId, response?.request?.id)
assertEquals("Тест1", response?.request?.name)
val cell = response?.cells?.single()
assertEquals(32400L, cell?.cellNum)
assertEquals(25.0, cell?.coveragePercent)
assertEquals(7.0, cell?.importance)
assertEquals("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", cell?.contour)
}
private fun saveRequest(
status: RequestStatus = RequestStatus.ACTIVE,
surveyType: RequestSurveyType = RequestSurveyType.OPTICS,
kpp: MutableList<Int> = mutableListOf(1),
highPriorityTransmit: Boolean = false,
beginDateTime: String = "2026-01-01T00:00:00",
endDateTime: String = "2026-01-31T00:00:00",
createdAt: String = "2026-01-01T00:00:00",
): UUID {
val id = UUID.randomUUID()
val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"
requestRepository.saveAndFlush(
RequestEntity(
id = id,
name = "Тест1",
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 0.5,
coverageRequiredPercent = 100.0,
importance = 10.0,
beginDateTime = LocalDateTime.parse(beginDateTime),
endDateTime = LocalDateTime.parse(endDateTime),
kpp = kpp,
highPriorityTransmit = highPriorityTransmit,
surveyType = surveyType,
status = status,
createdAt = LocalDateTime.parse(createdAt),
updatedAt = LocalDateTime.parse(createdAt),
deletedAt = if (status == RequestStatus.DELETED) LocalDateTime.parse(createdAt) else null,
)
)
return id
}
}
@@ -0,0 +1,314 @@
package org.nstart.dep265.requestservice.service
import jakarta.persistence.EntityManager
import org.junit.jupiter.api.Test
import org.mockito.ArgumentMatchers.any
import org.mockito.ArgumentMatchers.anyBoolean
import org.mockito.ArgumentMatchers.isNull
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.nstart.dep265.requestservice.dto.CreateRequestRequestDto
import org.nstart.dep265.requestservice.dto.ListRequestsQueryDto
import org.nstart.dep265.requestservice.dto.OpticsParamsDto
import org.nstart.dep265.requestservice.dto.OpticsResultTypeDto
import org.nstart.dep265.requestservice.entity.EarthCellEntity
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestCellEntity
import org.nstart.dep265.requestservice.entity.RequestOpticsParamsEntity
import org.nstart.dep265.requestservice.entity.RequestRsaParamsEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.RequestCellRepository
import org.nstart.dep265.requestservice.repository.RequestOpticsParamsRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRsaParamsRepository
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
import kotlin.test.assertFailsWith
class RequestServiceTest {
private val requestRepository = mock(RequestRepository::class.java)
private val requestOpticsParamsRepository = mock(RequestOpticsParamsRepository::class.java)
private val requestRsaParamsRepository = mock(RequestRsaParamsRepository::class.java)
private val requestCellRepository = mock(RequestCellRepository::class.java)
private val requestGridProjectionService = mock(RequestGridProjectionService::class.java)
private val entityManager = mock(EntityManager::class.java)
private val service = RequestService(
requestRepository = requestRepository,
requestOpticsParamsRepository = requestOpticsParamsRepository,
requestRsaParamsRepository = requestRsaParamsRepository,
requestCellRepository = requestCellRepository,
geometryService = GeometryService(),
requestGridProjectionService = requestGridProjectionService,
entityManager = entityManager,
)
init {
doAnswer { invocation -> invocation.getArgument<RequestOpticsParamsEntity>(0) }
.`when`(requestOpticsParamsRepository)
.save(any(RequestOpticsParamsEntity::class.java))
doAnswer { invocation -> invocation.getArgument<RequestRsaParamsEntity>(0) }
.`when`(requestRsaParamsRepository)
.save(any(RequestRsaParamsEntity::class.java))
doReturn(Optional.empty<RequestOpticsParamsEntity>())
.`when`(requestOpticsParamsRepository)
.findById(any())
doReturn(Optional.empty<RequestRsaParamsEntity>())
.`when`(requestRsaParamsRepository)
.findById(any())
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(uuidMatcher())
}
@Test
fun `create request inserts new request with default required coverage and input importance`() {
val persistedRequests = mutableListOf<RequestEntity>()
doReturn(false)
.`when`(requestRepository)
.existsById(REQUEST_ID)
doAnswer { invocation ->
persistedRequests += invocation.getArgument<RequestEntity>(0)
null
}.`when`(entityManager).persist(any(RequestEntity::class.java))
val response = service.createRequest(createRequestDto())
assertEquals(REQUEST_ID, response.id)
assertEquals(REQUEST_NAME, response.name)
assertEquals(REQUEST_IMPORTANCE, response.importance)
assertEquals(1, persistedRequests.size)
assertEquals(REQUEST_NAME, persistedRequests.single().name)
assertEquals(100.0, persistedRequests.single().coverageRequiredPercent)
assertEquals(REQUEST_IMPORTANCE, persistedRequests.single().importance)
verify(requestRepository, never()).save(any(RequestEntity::class.java))
verify(requestGridProjectionService).rebuildProjection(persistedRequests.single())
}
@Test
fun `get request by id returns name and importance`() {
val request = existingRequest()
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(REQUEST_ID)
val response = service.getRequestById(REQUEST_ID)
assertEquals(REQUEST_NAME, response?.name)
assertEquals(REQUEST_IMPORTANCE, response?.importance)
}
@Test
fun `get request with cells returns request read model and request cell projection fields`() {
val request = existingRequest()
val earthCell = EarthCellEntity(
cellId = 101,
cellNum = 32400,
latitude = 55.0,
longitude = 37.0,
importance = 5.0,
contour = "POLYGON ((36 55, 37 55, 37 56, 36 56, 36 55))",
)
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(REQUEST_ID)
doReturn(
listOf(
RequestCellEntity(
requestId = REQUEST_ID,
coveragePercent = 25.0,
importance = 7.0,
contour = "POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))",
cell = earthCell,
)
)
)
.`when`(requestCellRepository)
.findWithCellByRequestId(REQUEST_ID)
val response = service.getRequestWithCells(REQUEST_ID)
assertEquals(REQUEST_ID, response?.request?.id)
assertEquals(REQUEST_NAME, response?.request?.name)
assertEquals(REQUEST_IMPORTANCE, response?.request?.importance)
val cell = response?.cells?.single()
assertEquals(32400L, cell?.cellNum)
assertEquals(25.0, cell?.coveragePercent)
assertEquals(7.0, cell?.importance)
assertEquals("POLYGON ((36.5 55.5, 37 55.5, 37 56, 36.5 56, 36.5 55.5))", cell?.contour)
verify(requestCellRepository).findWithCellByRequestId(REQUEST_ID)
}
@Test
fun `get request with cells returns empty cells for request without projections`() {
doReturn(Optional.of(existingRequest()))
.`when`(requestRepository)
.findById(REQUEST_ID)
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(REQUEST_ID)
val response = service.getRequestWithCells(REQUEST_ID)
assertEquals(REQUEST_ID, response?.request?.id)
assertEquals(emptyList(), response?.cells)
}
@Test
fun `get request with cells returns deleted request with empty cells`() {
val request = existingRequest().also { existingRequest ->
existingRequest.status = RequestStatus.DELETED
existingRequest.deletedAt = LocalDateTime.parse("2026-02-01T00:00:00")
}
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(REQUEST_ID)
doReturn(emptyList<RequestCellEntity>())
.`when`(requestCellRepository)
.findWithCellByRequestId(REQUEST_ID)
val response = service.getRequestWithCells(REQUEST_ID)
assertEquals(RequestStatus.DELETED.name, response?.request?.status?.name)
assertEquals(emptyList(), response?.cells)
}
@Test
fun `get request with cells returns null when request is absent`() {
doReturn(Optional.empty<RequestEntity>())
.`when`(requestRepository)
.findById(REQUEST_ID)
val response = service.getRequestWithCells(REQUEST_ID)
assertEquals(null, response)
verify(requestCellRepository, never()).findWithCellByRequestId(REQUEST_ID)
}
@Test
fun `list requests returns name and importance in summary`() {
doReturn(PageImpl(listOf(existingRequest())))
.`when`(requestRepository)
.findRequests(
anyBoolean(),
deletedStatusMatcher(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
isNull(),
pageableMatcher(),
)
val response = service.listRequests(ListRequestsQueryDto())
assertEquals(REQUEST_NAME, response.items.single().name)
assertEquals(REQUEST_IMPORTANCE, response.items.single().importance)
}
@Test
fun `create request rejects duplicate id without touching existing request state`() {
doReturn(true)
.`when`(requestRepository)
.existsById(REQUEST_ID)
assertFailsWith<RequestAlreadyExistsException> {
service.createRequest(createRequestDto())
}
verify(entityManager, never()).persist(any(RequestEntity::class.java))
verify(requestRepository, never()).save(any(RequestEntity::class.java))
verifyNoInteractions(requestGridProjectionService)
verify(requestCellRepository, never()).deleteByRequestId(REQUEST_ID)
}
@Test
fun `delete request soft deletes request and removes request cells projection`() {
val request = existingRequest()
doReturn(Optional.of(request))
.`when`(requestRepository)
.findById(REQUEST_ID)
doAnswer { invocation -> invocation.getArgument<RequestEntity>(0) }
.`when`(requestRepository)
.save(any(RequestEntity::class.java))
val response = service.deleteRequest(REQUEST_ID)
assertEquals(REQUEST_ID, response?.id)
assertEquals(RequestStatus.DELETED, request.status)
verify(requestCellRepository).deleteByRequestId(REQUEST_ID)
}
private fun createRequestDto(): CreateRequestRequestDto {
return CreateRequestRequestDto(
id = REQUEST_ID,
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"),
optics = OpticsParamsDto(
resultType = OpticsResultTypeDto.PANCHROMATIC,
resolution = 1.0,
),
)
}
private fun existingRequest(): RequestEntity {
val geometry = "POLYGON ((37.5 55.5, 37.7 55.5, 37.7 55.7, 37.5 55.7, 37.5 55.5))"
return RequestEntity(
id = REQUEST_ID,
name = REQUEST_NAME,
geometry = geometry,
remainingGeometry = geometry,
geometryArea = 1.0,
remainingArea = 0.5,
coverageRequiredPercent = 100.0,
importance = REQUEST_IMPORTANCE,
beginDateTime = LocalDateTime.parse("2026-01-01T00:00:00"),
endDateTime = LocalDateTime.parse("2026-01-31T23:59:59"),
surveyType = RequestSurveyType.OPTICS,
status = RequestStatus.ACTIVE,
matchCount = 1,
completedAt = null,
createdAt = LocalDateTime.parse("2025-12-31T00:00:00"),
updatedAt = LocalDateTime.parse("2025-12-31T00:00:00"),
)
}
private companion object {
val REQUEST_ID: UUID = UUID.fromString("3fa85f64-5717-4562-b3fc-2c963f66afa6")
const val REQUEST_NAME = "Тест1"
const val REQUEST_IMPORTANCE = 10.0
}
private fun deletedStatusMatcher(): RequestStatus {
any(RequestStatus::class.java)
return RequestStatus.DELETED
}
private fun pageableMatcher(): Pageable {
any(Pageable::class.java)
return PageRequest.of(0, 50)
}
private fun uuidMatcher(): UUID {
any(UUID::class.java)
return REQUEST_ID
}
}
@@ -0,0 +1,412 @@
package org.nstart.dep265.requestservice.service
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.mockito.ArgumentCaptor
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.doThrow
import org.mockito.Mockito.mock
import org.mockito.Mockito.mockingDetails
import org.mockito.Mockito.never
import org.mockito.Mockito.verify
import org.mockito.Mockito.verifyNoInteractions
import org.nstart.dep265.requestservice.dto.RouteAngleRangeDto
import org.nstart.dep265.requestservice.dto.RouteDto
import org.nstart.dep265.requestservice.entity.RequestEntity
import org.nstart.dep265.requestservice.entity.RequestRouteMatchEntity
import org.nstart.dep265.requestservice.entity.RequestStatus
import org.nstart.dep265.requestservice.entity.RequestSurveyType
import org.nstart.dep265.requestservice.repository.OutboxEventRepository
import org.nstart.dep265.requestservice.repository.RequestRepository
import org.nstart.dep265.requestservice.repository.RequestRouteMatchRepository
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.transaction.TransactionStatus
import org.springframework.transaction.support.SimpleTransactionStatus
import org.springframework.transaction.support.TransactionCallback
import org.springframework.transaction.support.TransactionOperations
import java.time.LocalDateTime
import java.util.Optional
import java.util.UUID
class RouteMatchingServiceTest {
@Test
fun `route intersecting original and remaining stores contributing match and updates coverage`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalEnd = intervalBegin.plusHours(1)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalEnd,
geometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))",
remainingGeometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))",
geometryArea = 4.0,
remainingArea = 4.0,
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
routeMatchingService.process(route)
val routeMatch = captureSavedRouteMatch(requestRouteMatchRepository)
assertEquals(request.id, routeMatch.requestId)
assertEquals(route.routeId, routeMatch.routeId)
assertTrue(routeMatch.contributesToCoverage)
assertTrue(routeMatch.originalIntersectionArea > 0.0)
assertTrue(routeMatch.appliedIntersectionArea > 0.0)
assertTrue(routeMatch.coverageDeltaPercent > 0.0)
assertEquals(3.0, request.remainingArea)
assertEquals(1, request.matchCount)
assertEquals(RequestStatus.ACTIVE, request.status)
verify(requestRepository).findByIdForUpdate(request.id)
verify(requestRepository).save(request)
verifyNoInteractions(outboxEventRepository)
}
@Test
fun `route intersecting original but not remaining stores non contributing match without coverage update`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val intervalEnd = intervalBegin.plusHours(1)
val originalRemainingGeometry = "POLYGON((1 1, 2 1, 2 2, 1 2, 1 1))"
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalEnd,
geometry = "POLYGON((0 0, 2 0, 2 2, 0 2, 0 0))",
remainingGeometry = originalRemainingGeometry,
geometryArea = 4.0,
remainingArea = 1.0,
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = "POLYGON((0 0, 0.5 0, 0.5 0.5, 0 0.5, 0 0))",
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
routeMatchingService.process(route)
val routeMatch = captureSavedRouteMatch(requestRouteMatchRepository)
assertEquals(request.id, routeMatch.requestId)
assertEquals(route.routeId, routeMatch.routeId)
assertEquals(false, routeMatch.contributesToCoverage)
assertTrue(routeMatch.originalIntersectionArea > 0.0)
assertNull(routeMatch.appliedIntersectionGeometry)
assertEquals(0.0, routeMatch.appliedIntersectionArea)
assertEquals(0.0, routeMatch.coverageDeltaPercent)
assertEquals(originalRemainingGeometry, request.remainingGeometry)
assertEquals(1.0, request.remainingArea)
assertEquals(0, request.matchCount)
verify(requestRepository, never()).save(request)
verifyNoInteractions(outboxEventRepository)
}
@Test
fun `route outside original geometry does not create match or update request`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = "POLYGON((5 5, 6 5, 6 6, 5 6, 5 5))",
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
routeMatchingService.process(route)
verify(requestRouteMatchRepository, never()).saveAndFlush(org.mockito.ArgumentMatchers.any())
verify(requestRepository, never()).save(request)
verifyNoInteractions(outboxEventRepository)
assertEquals(1.0, request.remainingArea)
assertEquals(0, request.matchCount)
}
@Test
fun `duplicate route match after lock skips geometry and coverage update`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = request.geometry,
)
doReturn(listOf(request.id))
.`when`(requestRepository)
.findRequestIdsForRoute(
listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE),
route.intervalBegin,
route.intervalEnd,
)
doReturn(false, true)
.`when`(requestRouteMatchRepository)
.existsByRequestIdAndRouteId(request.id, route.routeId)
doReturn(Optional.of(request))
.`when`(requestRepository)
.findByIdForUpdate(request.id)
routeMatchingService.process(route)
verify(requestRouteMatchRepository, never()).saveAndFlush(org.mockito.ArgumentMatchers.any())
verify(requestRepository, never()).save(request)
verifyNoInteractions(outboxEventRepository)
assertEquals(1.0, request.remainingArea)
assertEquals(0, request.matchCount)
}
@Test
fun `duplicate unique violation on match insert is handled as duplicate delivery`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = request.geometry,
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
doThrow(DataIntegrityViolationException("duplicate key violates unique constraint uk_request_route_match"))
.`when`(requestRouteMatchRepository)
.saveAndFlush(org.mockito.ArgumentMatchers.any())
routeMatchingService.process(route)
verify(requestRepository, never()).save(request)
verifyNoInteractions(outboxEventRepository)
assertEquals(1.0, request.remainingArea)
assertEquals(0, request.matchCount)
}
@Test
fun `route completing request creates exactly one request completed outbox event`() {
val requestRepository = mock(RequestRepository::class.java)
val requestRouteMatchRepository = mock(RequestRouteMatchRepository::class.java)
val outboxEventRepository = mock(OutboxEventRepository::class.java)
val geometryService = GeometryService()
val routeMatchingService = routeMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
outboxEventRepository = outboxEventRepository,
geometryService = geometryService,
)
val intervalBegin = LocalDateTime.now().minusMinutes(5).withNano(0)
val request = request(
beginDateTime = intervalBegin,
endDateTime = intervalBegin.plusHours(1),
status = RequestStatus.ACTIVE,
)
val route = route(
intervalBegin = intervalBegin.plusMinutes(1),
intervalEnd = intervalBegin.plusMinutes(2),
geometry = request.geometry,
)
stubCandidate(requestRepository, requestRouteMatchRepository, request, route)
routeMatchingService.process(route)
val insertInvocations = mockingDetails(outboxEventRepository).invocations.filter {
invocation -> invocation.method.name == "insertIfAbsent"
}
assertEquals(1, insertInvocations.size)
assertEquals("REQUEST_COMPLETED", insertInvocations.single().arguments[1])
assertEquals(request.id, insertInvocations.single().arguments[3])
assertEquals(RequestStatus.COMPLETED, request.status)
}
private fun routeMatchingService(
requestRepository: RequestRepository,
requestRouteMatchRepository: RequestRouteMatchRepository,
outboxEventRepository: OutboxEventRepository,
geometryService: GeometryService,
): RouteMatchingService {
return RouteMatchingService(
requestRepository = requestRepository,
requestRouteMatchRepository = requestRouteMatchRepository,
requestValidationService = RequestValidationService(geometryService),
geometryService = geometryService,
requestLifecycleService = RequestLifecycleService(geometryService),
requestCompletedOutboxService = requestCompletedOutboxService(outboxEventRepository, geometryService),
routeMatchingTransactionOperations = ImmediateTransactionOperations(),
)
}
private fun requestCompletedOutboxService(
outboxEventRepository: OutboxEventRepository,
geometryService: GeometryService,
): RequestCompletedOutboxService {
return RequestCompletedOutboxService(
outboxEventRepository = outboxEventRepository,
objectMapper = jacksonObjectMapper()
.registerModule(JavaTimeModule())
.findAndRegisterModules(),
geometryService = geometryService,
)
}
private fun stubCandidate(
requestRepository: RequestRepository,
requestRouteMatchRepository: RequestRouteMatchRepository,
request: RequestEntity,
route: RouteDto,
) {
doReturn(listOf(request.id))
.`when`(requestRepository)
.findRequestIdsForRoute(
listOf(RequestStatus.ACCEPTED, RequestStatus.ACTIVE),
route.intervalBegin,
route.intervalEnd,
)
doReturn(false)
.`when`(requestRouteMatchRepository)
.existsByRequestIdAndRouteId(request.id, route.routeId)
doReturn(Optional.of(request))
.`when`(requestRepository)
.findByIdForUpdate(request.id)
doReturn(request)
.`when`(requestRepository)
.save(request)
}
private fun captureSavedRouteMatch(
requestRouteMatchRepository: RequestRouteMatchRepository,
): RequestRouteMatchEntity {
val routeMatchCaptor = ArgumentCaptor.forClass(RequestRouteMatchEntity::class.java)
verify(requestRouteMatchRepository).saveAndFlush(routeMatchCaptor.capture())
return routeMatchCaptor.value
}
private fun request(
beginDateTime: LocalDateTime,
endDateTime: LocalDateTime,
geometry: String = "POLYGON((0 0, 1 0, 1 1, 0 1, 0 0))",
remainingGeometry: String = geometry,
geometryArea: Double = 1.0,
remainingArea: Double = geometryArea,
status: RequestStatus,
completedAt: LocalDateTime? = null,
): RequestEntity {
val createdAt = beginDateTime.minusHours(1)
return RequestEntity(
id = UUID.randomUUID(),
name = "Тест1",
geometry = geometry,
remainingGeometry = remainingGeometry,
geometryArea = geometryArea,
remainingArea = remainingArea,
importance = 10.0,
beginDateTime = beginDateTime,
endDateTime = endDateTime,
highPriorityTransmit = false,
surveyType = RequestSurveyType.OPTICS,
status = status,
matchCount = 0,
completedAt = completedAt,
createdAt = createdAt,
updatedAt = createdAt,
)
}
private fun route(
intervalBegin: LocalDateTime,
intervalEnd: LocalDateTime,
geometry: String,
): RouteDto {
return RouteDto(
routeId = UUID.randomUUID(),
intervalBegin = intervalBegin,
intervalEnd = intervalEnd,
rollAngle = RouteAngleRangeDto(
min = 10.0,
max = 14.0,
),
geometry = geometry,
)
}
private class ImmediateTransactionOperations : TransactionOperations {
override fun <T> execute(action: TransactionCallback<T>): T {
val status: TransactionStatus = SimpleTransactionStatus()
return action.doInTransaction(status)
}
}
}
@@ -0,0 +1,6 @@
spring:
config:
import: ""
cloud:
config:
enabled: false