pcp-mission-planing: Ф4 — валидация-заглушка POST /missions/{id}/validate (контракт конфликтов, пустой diff)

This commit is contained in:
Дмитрий Соловьев
2026-06-03 13:27:31 +03:00
parent f5211fa5a0
commit 8133a5144f
5 changed files with 115 additions and 1 deletions
@@ -262,8 +262,13 @@ P2 = DRAFT (base_plan_id=P0, окно=W)
- **Приёмка:** правка/удаление затрагивают только `DRAFT`; живая миссия неизменна; контур пересчитан корректно; каскады при `DELETE` (связь `drop_mode ↔ survey`) целы.
- **Тесты:** PATCH меняет контур; DELETE с каскадами.
### Фаза 4 — Валидация-заглушка
### Фаза 4 — Валидация-заглушка — ✅ СДЕЛАНО
**Модель: Sonnet.**
> **Реализовано:**
> - `POST /api/missions/{id}/validate` в [MissionController](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionController.kt) → [validateMission](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/service/MissionPlaningService.kt): проверяет существование миссии (404, если нет) и возвращает корректный контракт с **пустым** списком конфликтов. Реальные правила — TODO раздела 6.
> - Контракт `MissionValidationResponse { missionId, conflicts: [ModeConflict] }`, где `ModeConflict { modeId, rule, severity, message, relatedModeId? }` ([MissionValidationDTOs.kt](services/pcp-mission-planing-service/src/main/kotlin/space/nstart/pcp/pcp_mission_planing_service/controller/MissionValidationDTOs.kt)). Коды правил (`ConflictRule`: TIME_OVERLAP/TARGET_VISIBILITY/COMMS_SESSION/DROP_ORDER/ONBOARD_LIMIT) и уровни (`ConflictSeverity`: error/warning) заведены сразу как стабильная схема — появление реальных правил не сломает фронт. Дифф по режимам (а не весь план).
> - Тесты: 2 кейса в `MissionEditModesTest` (валидация существующей миссии → пустой список; неизвестной → 404). Весь модуль зелёный.
- `pcp-mission-planing-service`: `POST /api/missions/{id}/validate` → корректный контракт, пустой/тривиальный список конфликтов. Реальные правила — TODO раздела 6.
- **Приёмка:** `200` с пустым diff; контракт стабилен для фронта.
@@ -252,6 +252,25 @@ class MissionController(
return ResponseEntity.noContent().build()
}
@Operation(
summary = "Проверить миссию-черновик на конфликты",
description = "Считает конфликты/наложения включений плана-черновика и возвращает дифф по " +
"режимам. MVP: валидатор-заглушка — контракт корректен, список конфликтов пуст; " +
"реальные правила (пересечения по времени, видимость цели, ЗРВ, порядок сброса, борт) — " +
"отдельной задачей."
)
@ApiResponses(
value = [
ApiResponse(responseCode = "200", description = "Валидация выполнена, конфликты в теле ответа"),
ApiResponse(responseCode = "404", description = "Миссия не найдена")
]
)
@PostMapping("/{mission_id}/validate")
fun validateMission(
@Parameter(description = "Идентификатор миссии-черновика")
@PathVariable("mission_id") id: UUID
): MissionValidationResponse = missionPlaningService.validateMission(id)
@Operation(summary = "Создать миссию")
@@ -0,0 +1,55 @@
package space.nstart.pcp.pcp_mission_planing_service.controller
import java.util.UUID
/**
* Коды правил валидации. MVP: валидатор-заглушка их **не вычисляет** — список существует как
* стабильный контракт для фронта, чтобы появление реальных правил (раздел 6 дизайн-дока) не ломало схему.
*/
object ConflictRule {
/** Пересечение по времени между включениями (съёмка↔съёмка, съёмка↔сброс, сброс↔сброс). */
const val TIME_OVERLAP = "TIME_OVERLAP"
/** Окно съёмки вне окна видимости цели (баллистика). */
const val TARGET_VISIBILITY = "TARGET_VISIBILITY"
/** Окно сброса вне зоны радиовидимости станции. */
const val COMMS_SESSION = "COMMS_SESSION"
/** Сбрасываемая съёмка не завершается строго до начала зоны сброса. */
const val DROP_ORDER = "DROP_ORDER"
/** Бортовые ограничения: длительность/скважность, энергетика, память. */
const val ONBOARD_LIMIT = "ONBOARD_LIMIT"
}
/** Уровень конфликта: [ERROR] блокирует «Применить», [WARNING] — информативный. */
object ConflictSeverity {
const val ERROR = "error"
const val WARNING = "warning"
}
/**
* Один конфликт на затронутом включении. Возвращается диффом по режимам (а не как весь план),
* чтобы фронт мог точечно подсвечивать проблемные включения.
*
* @param modeId id режима (survey или drop), к которому относится конфликт.
* @param rule код правила, см. [ConflictRule].
* @param severity уровень, см. [ConflictSeverity].
* @param message человекочитаемое описание для оператора.
* @param relatedModeId id второго участника парного правила (например, пересечения по времени), если есть.
*/
data class ModeConflict(
val modeId: Long,
val rule: String,
val severity: String,
val message: String,
val relatedModeId: Long? = null
)
/**
* Результат валидации миссии-черновика.
*
* MVP: [conflicts] всегда пуст — реальные правила ещё не считаются (TODO раздела 6 дизайн-дока).
* Контракт стабилен: фронт уже завязывается на него и не сломается, когда правила появятся.
*/
data class MissionValidationResponse(
val missionId: UUID,
val conflicts: List<ModeConflict>
)
@@ -32,6 +32,7 @@ import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.SurveyRouteBuildResp
import space.nstart.pcp.pcp_mission_planing_service.controller.CloneMode
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneRequest
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionCloneResponse
import space.nstart.pcp.pcp_mission_planing_service.controller.MissionValidationResponse
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.DropModeSurveyEntity
import space.nstart.pcp.pcp_mission_planing_service.entity.MissionEntity
@@ -910,6 +911,21 @@ class MissionPlaningService(
logger.info("Удалён сброс (drop_mode id={}), снято связей со съёмками={}", dropId, links.size)
}
/**
* Валидация миссии-черновика на конфликты/наложения включений.
*
* MVP-заглушка: проверяет существование миссии (404, если нет) и возвращает корректный контракт
* с **пустым** списком конфликтов. Реальные правила (пересечения по времени, видимость цели, ЗРВ
* станции, порядок сброса, бортовые ограничения) TODO раздела 6 дизайн-дока. Контракт
* ([MissionValidationResponse]/[ModeConflict]) стабилен, чтобы фронт не ломался при их появлении.
*/
fun validateMission(missionId: UUID): MissionValidationResponse {
if (!missionRepository.existsById(missionId)) {
throw notFound(missionId)
}
return MissionValidationResponse(missionId = missionId, conflicts = emptyList())
}
/**
* Строит контур полосы съёмки по параметрам видимости точки.
* Чистый расчёт без персистенции; переиспользуется [buildSurveyRoute] и [saveSurveyRoute].
@@ -199,6 +199,25 @@ class MissionEditModesTest {
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
// --- validate (заглушка) ---
@Test
fun `validate of an existing mission returns an empty conflict list`() {
val missionId = UUID.randomUUID()
`when`(missionRepository.existsById(missionId)).thenReturn(true)
val result = service.validateMission(missionId)
assertEquals(missionId, result.missionId)
assertTrue(result.conflicts.isEmpty(), "MVP-заглушка не считает конфликтов")
}
@Test
fun `validate of a missing mission yields 404`() {
val ex = assertThrows<ResponseStatusException> { service.validateMission(UUID.randomUUID()) }
assertEquals(HttpStatus.NOT_FOUND, ex.statusCode)
}
// --- helpers ---
private fun survey(): SurveyModeEntity {