Init
This commit is contained in:
+15
@@ -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)
|
||||
}
|
||||
+22
@@ -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()
|
||||
}
|
||||
}
|
||||
+34
@@ -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)
|
||||
}
|
||||
}
|
||||
+20
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
@@ -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,
|
||||
)
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+47
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+63
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+38
@@ -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()
|
||||
}
|
||||
}
|
||||
+70
@@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+89
@@ -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()
|
||||
}
|
||||
}
|
||||
+91
@@ -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>,
|
||||
)
|
||||
+42
@@ -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,
|
||||
)
|
||||
+467
@@ -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,
|
||||
}
|
||||
+46
@@ -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,
|
||||
)
|
||||
+69
@@ -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
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
}
|
||||
+56
@@ -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,
|
||||
)
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package org.nstart.dep265.requestservice.entity
|
||||
|
||||
enum class OutboxEventStatus {
|
||||
NEW,
|
||||
PUBLISHED,
|
||||
FAILED,
|
||||
}
|
||||
+50
@@ -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
|
||||
}
|
||||
}
|
||||
+89
@@ -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,
|
||||
)
|
||||
+47
@@ -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,
|
||||
}
|
||||
+46
@@ -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,
|
||||
)
|
||||
+56
@@ -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,
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package org.nstart.dep265.requestservice.entity
|
||||
|
||||
/**
|
||||
* Бизнес-статус заявки.
|
||||
*/
|
||||
enum class RequestStatus {
|
||||
/** Заявка прошла валидацию и сохранена. */
|
||||
ACCEPTED,
|
||||
|
||||
/** Заявка актуальна для обработки. */
|
||||
ACTIVE,
|
||||
|
||||
/** Заявка выполнилась по проценту покрытия. */
|
||||
COMPLETED,
|
||||
|
||||
/** Интервал заявки завершился до достижения требуемого покрытия. */
|
||||
EXPIRED,
|
||||
|
||||
/** Заявка мягко удалена. */
|
||||
DELETED,
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package org.nstart.dep265.requestservice.entity
|
||||
|
||||
/**
|
||||
* Вычисленный тип заявки по наличию optics/rsa.
|
||||
*/
|
||||
enum class RequestSurveyType {
|
||||
/** В заявке указан только optics. */
|
||||
OPTICS,
|
||||
|
||||
/** В заявке указан только rsa. */
|
||||
RSA,
|
||||
|
||||
/** В заявке указаны optics и rsa. */
|
||||
COMBINED,
|
||||
}
|
||||
+52
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+71
@@ -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>
|
||||
}
|
||||
+6
@@ -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>
|
||||
+83
@@ -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
|
||||
}
|
||||
+30
@@ -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>
|
||||
}
|
||||
+7
@@ -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>
|
||||
+109
@@ -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>
|
||||
}
|
||||
+11
@@ -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)
|
||||
}
|
||||
+7
@@ -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>
|
||||
+206
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+271
@@ -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
|
||||
}
|
||||
}
|
||||
+118
@@ -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,
|
||||
)
|
||||
+21
@@ -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
|
||||
}
|
||||
}
|
||||
+51
@@ -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)
|
||||
}
|
||||
}
|
||||
+57
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+175
@@ -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())
|
||||
}
|
||||
}
|
||||
+114
@@ -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
|
||||
}
|
||||
}
|
||||
+8
@@ -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)
|
||||
+68
@@ -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"
|
||||
}
|
||||
}
|
||||
+122
@@ -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)
|
||||
}
|
||||
}
|
||||
+55
@@ -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,
|
||||
)
|
||||
+358
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
+36
@@ -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) {
|
||||
"Геометрия маршрута должна иметь положительную площадь"
|
||||
}
|
||||
}
|
||||
}
|
||||
+230
@@ -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);
|
||||
+97
@@ -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)
|
||||
}
|
||||
}
|
||||
+208
@@ -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)
|
||||
}
|
||||
}
|
||||
+127
@@ -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)
|
||||
}
|
||||
}
|
||||
+508
@@ -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")
|
||||
}
|
||||
}
|
||||
+126
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
}
|
||||
+109
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+293
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+258
@@ -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)
|
||||
}
|
||||
}
|
||||
+177
@@ -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
|
||||
}
|
||||
}
|
||||
+162
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+98
@@ -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))
|
||||
}
|
||||
}
|
||||
+282
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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"])
|
||||
}
|
||||
}
|
||||
+142
@@ -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)
|
||||
}
|
||||
}
|
||||
+255
@@ -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
|
||||
}
|
||||
}
|
||||
+314
@@ -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
|
||||
}
|
||||
}
|
||||
+412
@@ -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
|
||||
Reference in New Issue
Block a user