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);
|
||||
Reference in New Issue
Block a user