Запрет вложенных планов: строго монотонная цепочка в pcp-tgu-service

Цепочка планов теперь строит соседние планы со строго возрастающими
стартом и концом, поэтому ни один план не может лежать целиком внутри
другого. Нахлёст «на одну ЗРВ» считается по времени, а не по плоскому
индексу кандидата — это убирает вложенность при нескольких станциях,
видящих один пролёт одновременно (кейс Emissio).

Подчистка устаревших PLANNED-планов при rebuild переведена на условие
end_time > now, чтобы гасить и уже стартовавшие вложенные планы.
This commit is contained in:
Дмитрий Соловьев
2026-06-02 11:04:38 +03:00
parent edd24d93ba
commit 96f698112b
5 changed files with 179 additions and 49 deletions
@@ -24,11 +24,11 @@ interface PlannedPlanRepository : JpaRepository<PlannedPlanEntity, UUID> {
/** Возвращает все планы в порядке старта. */ /** Возвращает все планы в порядке старта. */
fun findAllByOrderByStartTime(): List<PlannedPlanEntity> fun findAllByOrderByStartTime(): List<PlannedPlanEntity>
/** Возвращает будущие planned-планы КА. */ /** Возвращает planned-планы КА, ещё не завершившиеся по времени (для supersede по интервалу). */
fun findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( fun findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(
spacecraftId: String, spacecraftId: String,
status: PlannedPlanStatus, status: PlannedPlanStatus,
startTime: LocalDateTime endTime: LocalDateTime
): List<PlannedPlanEntity> ): List<PlannedPlanEntity>
/** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */ /** Возвращает ближайший future planned plan для продвижения после позднего REJECTED. */
@@ -9,32 +9,41 @@ import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
import java.time.Duration import java.time.Duration
import java.time.LocalDateTime import java.time.LocalDateTime
/**
* Строит цепочку планов по окнам видимости КА.
*
* Главный инвариант цепочки: у соседних планов строго возрастают и старт, и конец
* (`start[i] < start[i+1]` и `end[i] < end[i+1]`). Отсюда математически следует, что
* ни один план не может лежать целиком внутри другого. Перекрытие сохраняется: следующий
* план стартует на ЗРВ, попадающей внутрь предыдущего плана.
*
* Окна разных станций не сливаются — старт плана привязан к конкретной ЗРВ один к одному.
*/
@Service @Service
class PlanCalculationService( class PlanCalculationService(
private val planningProperties: PlanningProperties private val planningProperties: PlanningProperties
) { ) {
/** Количество ЗРВ, на которое следующий план должен заходить на предыдущий, если это возможно. */
private val overlapShiftByWindows: Int = 1
/** Строит полную цепочку планов для КА по всем доступным окнам видимости. */ /** Строит полную цепочку планов для КА по всем доступным окнам видимости. */
fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> { fun calculatePlans(spacecraftPoints: SpacecraftPoints): List<CalculatedPlan> {
/** Все доступные кандидаты начала/окончания плана для указанного КА. */ /** Все доступные кандидаты начала/окончания плана для указанного КА. */
val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints) val candidates: List<StartCandidate> = buildCandidates(spacecraftPoints)
return calculatePlansFromCandidates( return buildChain(
spacecraftId = spacecraftPoints.spacecraftId, spacecraftId = spacecraftPoints.spacecraftId,
candidates = candidates, candidates = candidates,
initialStartIndex = 0 fromStartIndex = 0,
initialLowerEndBound = null
) )
} }
/** /**
* Строит часть цепочки после уже известного плана. * Строит часть цепочки после уже известного (принятого) плана.
* *
* Если предыдущий план удалось сопоставить с текущими окнами наблюдения, * Если предыдущий план удалось сопоставить с текущими окнами наблюдения,
* следующий план начинается с нахлестом на одну ЗРВ. * следующий план начинается с нахлестом на одну ЗРВ. Если сопоставление не удалось,
* Если сопоставление не удалось, используется безопасный fallback без нахлеста. * используется безопасный fallback без нахлеста. В обоих случаях конец первого нового
* плана обязан быть строго позже конца принятого плана — это исключает вложенность с базой.
*/ */
fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> { fun calculatePlansAfter(spacecraftPoints: SpacecraftPoints, previousPlan: CalculatedPlan): List<CalculatedPlan> {
/** Все доступные кандидаты начала/окончания плана для указанного КА. */ /** Все доступные кандидаты начала/окончания плана для указанного КА. */
@@ -53,11 +62,12 @@ class PlanCalculationService(
?: -1 ?: -1
/** Стартовый индекс для пересчёта следующего хвоста цепочки. */ /** Стартовый индекс для пересчёта следующего хвоста цепочки. */
val initialStartIndex: Int = if ( val fromStartIndex: Int = if (
previousStartIndex >= 0 && previousStartIndex >= 0 &&
previousEndIndex > previousStartIndex previousEndIndex > previousStartIndex
) { ) {
resolveNextStartIndex( resolveNextStartIndex(
candidates = candidates,
currentStartIndex = previousStartIndex, currentStartIndex = previousStartIndex,
endIndex = previousEndIndex endIndex = previousEndIndex
) )
@@ -65,14 +75,15 @@ class PlanCalculationService(
candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime } candidates.indexOfFirst { candidate -> candidate.startTime >= previousPlan.endTime }
} }
if (initialStartIndex == -1) { if (fromStartIndex !in candidates.indices) {
return emptyList() return emptyList()
} }
return calculatePlansFromCandidates( return buildChain(
spacecraftId = spacecraftPoints.spacecraftId, spacecraftId = spacecraftPoints.spacecraftId,
candidates = candidates, candidates = candidates,
initialStartIndex = initialStartIndex fromStartIndex = fromStartIndex,
initialLowerEndBound = previousPlan.endTime
) )
} }
@@ -96,7 +107,8 @@ class PlanCalculationService(
/** Индекс лучшего кандидата окончания для ближайшей закладки. */ /** Индекс лучшего кандидата окончания для ближайшей закладки. */
val endIndex: Int = findEndCandidateIndexOrNull( val endIndex: Int = findEndCandidateIndexOrNull(
candidates = candidates, candidates = candidates,
startIndex = nearestStartIndex startIndex = nearestStartIndex,
lowerEndBound = null
) ?: return null ) ?: return null
return candidates[nearestStartIndex].toCalculatedPlan( return candidates[nearestStartIndex].toCalculatedPlan(
@@ -105,37 +117,49 @@ class PlanCalculationService(
) )
} }
/** Преобразует отсортированных кандидатов в последовательную цепочку планов. */ /**
private fun calculatePlansFromCandidates( * Строит последовательную цепочку планов, начиная с `fromStartIndex`.
*
* Поддерживает инвариант строгого роста старта и конца: следующий план обязан
* стартовать строго позже и заканчиваться строго позже предыдущего, поэтому ни один
* план не оказывается внутри другого.
*/
private fun buildChain(
spacecraftId: String, spacecraftId: String,
candidates: List<StartCandidate>, candidates: List<StartCandidate>,
initialStartIndex: Int fromStartIndex: Int,
initialLowerEndBound: LocalDateTime?
): List<CalculatedPlan> { ): List<CalculatedPlan> {
if (candidates.isEmpty() || initialStartIndex !in candidates.indices || initialStartIndex >= candidates.lastIndex) { if (candidates.isEmpty() || fromStartIndex !in candidates.indices || fromStartIndex >= candidates.lastIndex) {
return emptyList() return emptyList()
} }
/** Результирующая цепочка планов. */ /** Результирующая цепочка планов. */
val resultPlans: MutableList<CalculatedPlan> = mutableListOf() val resultPlans: MutableList<CalculatedPlan> = mutableListOf()
/** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */ /** Индекс кандидата, с которого сейчас пытаемся построить очередной план. */
var currentStartIndex: Int = initialStartIndex var currentStartIndex: Int = fromStartIndex
/** Конец последнего добавленного плана — нижняя граница для конца следующего. */
var lowerEndBound: LocalDateTime? = initialLowerEndBound
while (currentStartIndex < candidates.lastIndex) { while (currentStartIndex < candidates.lastIndex) {
/** Текущий кандидат старта плана. */ /** Текущий кандидат старта плана. */
val currentCandidate: StartCandidate = candidates[currentStartIndex] val currentCandidate: StartCandidate = candidates[currentStartIndex]
/** Индекс найденного кандидата окончания плана. */ /** Индекс найденного кандидата окончания плана со строго растущим концом. */
val endIndex: Int = findEndCandidateIndexOrNull( val endIndex: Int = findEndCandidateIndexOrNull(
candidates = candidates, candidates = candidates,
startIndex = currentStartIndex startIndex = currentStartIndex,
lowerEndBound = lowerEndBound
) ?: break ) ?: break
resultPlans += currentCandidate.toCalculatedPlan( resultPlans += currentCandidate.toCalculatedPlan(
spacecraftId = spacecraftId, spacecraftId = spacecraftId,
endTime = candidates[endIndex].endTime endTime = candidates[endIndex].endTime
) )
lowerEndBound = candidates[endIndex].endTime
/** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */ /** Индекс старта следующего плана с нахлестом на одну ЗРВ, если это безопасно. */
val nextStartIndex: Int = resolveNextStartIndex( val nextStartIndex: Int = resolveNextStartIndex(
candidates = candidates,
currentStartIndex = currentStartIndex, currentStartIndex = currentStartIndex,
endIndex = endIndex endIndex = endIndex
) )
@@ -162,7 +186,7 @@ class PlanCalculationService(
) )
} }
} }
.sortedBy { candidate -> candidate.startTime } .sortedWith(compareBy({ candidate -> candidate.startTime }, { candidate -> candidate.endTime }))
/** Возвращает целевую длительность плана из настроек. */ /** Возвращает целевую длительность плана из настроек. */
private fun targetDuration(): Duration = private fun targetDuration(): Duration =
@@ -173,31 +197,62 @@ class PlanCalculationService(
Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes) Duration.ofMinutes(planningProperties.targetPlanDurationDeltaMinutes)
/** /**
* Возвращает безопасный индекс старта следующего плана. * Возвращает индекс старта следующего плана с нахлестом на одну ЗРВ.
* *
* Если длина текущего плана позволяет, следующий план стартует на одну ЗРВ раньше конца текущего. * Нахлест считается по времени, а не по плоскому индексу: берётся самая поздняя ЗРВ,
* Если такой нахлест невозможен, сервис просто двигается вперед на следующий кандидат, * которая начинается строго раньше конечной ЗРВ текущего плана и строго позже его старта.
* Это даёт ровно одно окно перекрытия внутри текущего плана и устойчиво к тому, что одну и
* ту же ЗРВ видят несколько станций одновременно.
*
* Если такой ЗРВ нет, сервис двигается на ближайший более поздний старт (стык без нахлеста),
* чтобы не зациклиться и не повторить тот же самый план. * чтобы не зациклиться и не повторить тот же самый план.
*/ */
private fun resolveNextStartIndex(currentStartIndex: Int, endIndex: Int): Int = private fun resolveNextStartIndex(
maxOf(currentStartIndex + 1, endIndex - overlapShiftByWindows) candidates: List<StartCandidate>,
currentStartIndex: Int,
endIndex: Int
): Int {
/** Время старта текущего плана. */
val currentStartTime: LocalDateTime = candidates[currentStartIndex].startTime
/** Время старта конечной ЗРВ текущего плана. */
val endStartTime: LocalDateTime = candidates[endIndex].startTime
/** Самая поздняя ЗРВ строго внутри текущего плана — даёт одно окно нахлеста. */
val overlappingIndex: Int = candidates.indices.lastOrNull { index ->
candidates[index].startTime > currentStartTime &&
candidates[index].startTime < endStartTime
} ?: -1
if (overlappingIndex > currentStartIndex) {
return overlappingIndex
}
/** Ближайший более поздний старт, если нахлест невозможен. */
return candidates.indexOfFirst { candidate -> candidate.startTime > currentStartTime }
}
/** /**
* Находит лучший конечный кандидат для плана, начинающегося с `startIndex`. * Находит лучший конечный кандидат для плана, начинающегося с `startIndex`.
* *
* Сначала сервис старается попасть в целевое окно `target ± delta`. * Рассматриваются только концы строго позже старта плана и строго позже конца предыдущего
* Если это невозможно, он всё равно обязан построить план и берёт * плана (`lowerEndBound`) — это гарантирует строгий рост конца цепочки и отсутствие вложенности.
* любой доступный конец, наиболее близкий к целевой длительности. * Сначала сервис старается попасть в целевое окно `target ± delta`; если это невозможно,
* берётся любой допустимый конец, наиболее близкий к целевой длительности.
*/ */
private fun findEndCandidateIndexOrNull( private fun findEndCandidateIndexOrNull(
candidates: List<StartCandidate>, candidates: List<StartCandidate>,
startIndex: Int startIndex: Int,
lowerEndBound: LocalDateTime?
): Int? { ): Int? {
/** Кандидат, с которого начинается рассматриваемый план. */ /** Кандидат, с которого начинается рассматриваемый план. */
val startCandidate: StartCandidate = candidates[startIndex] val startCandidate: StartCandidate = candidates[startIndex]
/** Все доступные кандидаты окончания после выбранного старта. */ /** Допустимые кандидаты окончания: позже старта, позже предыдущего конца. */
val allEndCandidates: List<IndexedValue<StartCandidate>> = candidates.withIndex() val allEndCandidates: List<IndexedValue<StartCandidate>> = candidates.withIndex()
.filter { indexedCandidate -> indexedCandidate.index > startIndex } .filter { indexedCandidate ->
indexedCandidate.index > startIndex &&
indexedCandidate.value.endTime > startCandidate.startTime &&
(lowerEndBound == null || indexedCandidate.value.endTime > lowerEndBound)
}
if (allEndCandidates.isEmpty()) { if (allEndCandidates.isEmpty()) {
return null return null
@@ -239,8 +294,8 @@ class PlanCalculationService(
/** /**
* Выбирает лучший конец по минимальному отклонению от целевой длительности. * Выбирает лучший конец по минимальному отклонению от целевой длительности.
* *
* При равной близости предпочтение отдаётся более длинному плану, * При равной близости предпочтение отдаётся более длинному плану, а затем более раннему концу,
* чтобы не занижать результат без необходимости. * чтобы выбор был детерминированным.
*/ */
private fun selectPreferredEndCandidate( private fun selectPreferredEndCandidate(
candidates: List<IndexedValue<StartCandidate>>, candidates: List<IndexedValue<StartCandidate>>,
@@ -257,6 +312,8 @@ class PlanCalculationService(
) )
}.thenByDescending { candidate -> }.thenByDescending { candidate ->
Duration.between(startTime, candidate.value.endTime) Duration.between(startTime, candidate.value.endTime)
}.thenBy { candidate ->
candidate.value.endTime
} }
) )
} }
@@ -87,10 +87,10 @@ class RebuildPlanScheduleUseCase(
calculatedBusinessKeys: Set<String>, calculatedBusinessKeys: Set<String>,
now: LocalDateTime now: LocalDateTime
) { ) {
plannedPlanRepository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime( plannedPlanRepository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(
spacecraftId = spacecraftId, spacecraftId = spacecraftId,
status = PlannedPlanStatus.PLANNED, status = PlannedPlanStatus.PLANNED,
startTime = now endTime = now
) )
.filter { plan -> plan.businessKey() !in calculatedBusinessKeys } .filter { plan -> plan.businessKey() !in calculatedBusinessKeys }
.forEach { plan -> .forEach { plan ->
@@ -1,9 +1,11 @@
package space.nstart.pcp_tgu_service.service package space.nstart.pcp_tgu_service.service
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.config.PlanningProperties
import space.nstart.pcp_tgu_service.domain.CalculatedPlan
import space.nstart.pcp_tgu_service.domain.InsertionPoint import space.nstart.pcp_tgu_service.domain.InsertionPoint
import space.nstart.pcp_tgu_service.domain.ObservationWindow import space.nstart.pcp_tgu_service.domain.ObservationWindow
import space.nstart.pcp_tgu_service.domain.SpacecraftPoints import space.nstart.pcp_tgu_service.domain.SpacecraftPoints
@@ -29,16 +31,16 @@ class PlanCalculationServiceTest {
) )
) )
assertEquals(4, plans.size) assertChainInvariant(plans)
assertEquals(3, plans.size)
assertEquals(start, plans[0].startTime) assertEquals(start, plans[0].startTime)
assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
assertEquals(start.plusHours(16), plans[1].startTime) assertEquals(start.plusHours(16), plans[1].startTime)
assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plans[1].endTime) assertEquals(start.plusHours(40).plusMinutes(10), plans[1].endTime)
assertEquals("KPP-3", plans[1].kppId) assertEquals("KPP-3", plans[1].kppId)
assertEquals(start.plusHours(32), plans[2].startTime) assertEquals(start.plusHours(32), plans[2].startTime)
assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime) assertEquals(start.plusDays(2).plusMinutes(10), plans[2].endTime)
assertEquals(start.plusHours(40), plans[3].startTime) assertEquals("KPP-6", plans[2].kppId)
assertEquals(start.plusDays(2).plusMinutes(10), plans[3].endTime)
} }
@Test @Test
@@ -59,14 +61,15 @@ class PlanCalculationServiceTest {
val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first()) val plansAfterFirst = service.calculatePlansAfter(spacecraftPoints, fullPlans.first())
assertEquals(3, plansAfterFirst.size) assertChainInvariant(plansAfterFirst)
assertEquals(2, plansAfterFirst.size)
assertEquals(start.plusHours(16), plansAfterFirst[0].startTime) assertEquals(start.plusHours(16), plansAfterFirst[0].startTime)
assertEquals(start.plusDays(1).plusHours(16).plusMinutes(10), plansAfterFirst[0].endTime) assertEquals(start.plusHours(40).plusMinutes(10), plansAfterFirst[0].endTime)
assertEquals("KPP-3", plansAfterFirst[0].kppId) assertEquals("KPP-3", plansAfterFirst[0].kppId)
assertEquals(start.plusHours(32), plansAfterFirst[1].startTime) assertEquals(start.plusHours(32), plansAfterFirst[1].startTime)
assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime) assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[1].endTime)
assertEquals(start.plusHours(40), plansAfterFirst[2].startTime) // Хвост не должен заканчиваться раньше принятого базового плана.
assertEquals(start.plusDays(2).plusMinutes(10), plansAfterFirst[2].endTime) assertTrue(plansAfterFirst.all { plan -> plan.endTime > fullPlans.first().endTime })
} }
@Test @Test
@@ -96,6 +99,7 @@ class PlanCalculationServiceTest {
) )
) )
assertChainInvariant(plans)
assertEquals(2, plans.size) assertEquals(2, plans.size)
assertEquals(start, plans[0].startTime) assertEquals(start, plans[0].startTime)
assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime) assertEquals(start.plusDays(1).plusMinutes(10), plans[0].endTime)
@@ -135,6 +139,7 @@ class PlanCalculationServiceTest {
) )
) )
assertChainInvariant(plans)
assertEquals(2, plans.size) assertEquals(2, plans.size)
assertEquals(start, plans[0].startTime) assertEquals(start, plans[0].startTime)
assertEquals(start.plusMinutes(55), plans[1].startTime) assertEquals(start.plusMinutes(55), plans[1].startTime)
@@ -153,6 +158,7 @@ class PlanCalculationServiceTest {
) )
) )
assertChainInvariant(plans)
assertEquals(2, plans.size) assertEquals(2, plans.size)
assertEquals(start, plans[0].startTime) assertEquals(start, plans[0].startTime)
assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime) assertEquals(start.plusHours(1).plusMinutes(40), plans[0].endTime)
@@ -174,6 +180,7 @@ class PlanCalculationServiceTest {
) )
) )
assertChainInvariant(plans)
assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime) assertEquals(start.plusHours(1).plusMinutes(40), plans.first().endTime)
} }
@@ -192,12 +199,65 @@ class PlanCalculationServiceTest {
val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray())) val plans = service.calculatePlans(spacecraftPoints(*simultaneousWindows.toTypedArray()))
assertChainInvariant(plans)
assertTrue(plans.isNotEmpty()) assertTrue(plans.isNotEmpty())
assertTrue(plans.size <= simultaneousWindows.size) assertTrue(plans.size <= simultaneousWindows.size)
assertEquals(start, plans.first().startTime) assertEquals(start, plans.first().startTime)
assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime) assertEquals(start.plusDays(1).plusMinutes(10), plans.first().endTime)
} }
@Test
fun `calculatePlans never nests plans when several stations see the same passes (Emissio case)`() {
// Прод-конфигурация: цель 3 часа, допуск 1 час.
val service = createService(targetMinutes = 180L, deltaMinutes = 60L)
val start = LocalDateTime.of(2026, 3, 10, 10, 0)
// Пролёты примерно раз в час, каждый виден тремя станциями почти одновременно
// (это и есть корень вложенности у Emissio): одинаковые/близкие старты, дублирующиеся ЗРВ.
val stations = listOf("ST-A", "ST-B", "ST-C")
val windows = buildList {
for (passIndex in 0..8) {
stations.forEachIndexed { stationOffset, stationId ->
add(
window(
pointId = "$stationId-$passIndex",
startTime = start.plusHours(passIndex.toLong()).plusMinutes(stationOffset.toLong())
)
)
}
}
}
val plans = service.calculatePlans(spacecraftPoints(*windows.toTypedArray()))
assertChainInvariant(plans)
assertTrue(plans.size >= 2, "expected a multi-plan chain, got ${plans.size}")
}
/**
* Проверяет главный инвариант цепочки: старт и конец строго возрастают, и ни один план
* не лежит целиком внутри другого.
*/
private fun assertChainInvariant(plans: List<CalculatedPlan>) {
for (index in 1 until plans.size) {
assertTrue(
plans[index].startTime > plans[index - 1].startTime,
"start must strictly increase at index $index: $plans"
)
assertTrue(
plans[index].endTime > plans[index - 1].endTime,
"end must strictly increase at index $index: $plans"
)
}
for (outer in plans.indices) {
for (inner in plans.indices) {
if (outer == inner) continue
val contains = plans[outer].startTime <= plans[inner].startTime &&
plans[inner].endTime <= plans[outer].endTime
assertFalse(contains, "plan $inner must not be nested in plan $outer: $plans")
}
}
}
private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService = private fun createService(targetMinutes: Long, deltaMinutes: Long): PlanCalculationService =
PlanCalculationService( PlanCalculationService(
PlanningProperties( PlanningProperties(
@@ -27,6 +27,17 @@ class RebuildPlanScheduleUseCaseTest {
fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() { fun `rebuild keeps stable planId by business key and supersedes obsolete planned only`() {
val spacecraftId = "25544" val spacecraftId = "25544"
val planStore = linkedMapOf<UUID, PlannedPlanEntity>() val planStore = linkedMapOf<UUID, PlannedPlanEntity>()
// Устаревший PLANNED-план, который уже стартовал, но ещё не завершился —
// должен быть погашен по новому условию end_time > now (а не start_time > now).
val stalePlanId = UUID.randomUUID()
planStore[stalePlanId] = PlannedPlanEntity(
planId = stalePlanId,
spacecraftId = spacecraftId,
startTime = LocalDateTime.now().minusHours(1),
endTime = LocalDateTime.now().plusHours(1),
kppId = "KPP-STALE",
status = PlannedPlanStatus.PLANNED
)
val plannedRepository = plannedRepository(planStore) val plannedRepository = plannedRepository(planStore)
val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId) val state = SpacecraftPlanningStateEntity(spacecraftId = spacecraftId)
val stateRepository = stateRepository(state) val stateRepository = stateRepository(state)
@@ -58,6 +69,8 @@ class RebuildPlanScheduleUseCaseTest {
service.rebuild(spacecraftId) service.rebuild(spacecraftId)
val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId val firstPlanId = planStore.values.first { plan -> plan.kppId == "KPP-1" }.planId
// Устаревший активный план без места в новой цепочке погашен уже на первом rebuild.
assertEquals(PlannedPlanStatus.SUPERSEDED, planStore.getValue(stalePlanId).status)
`when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan)) `when`(calculationService.calculatePlans(anyNonNull())).thenReturn(listOf(firstPlan))
service.rebuild(spacecraftId) service.rebuild(spacecraftId)
@@ -140,13 +153,13 @@ class RebuildPlanScheduleUseCaseTest {
store[plan.planId] = plan store[plan.planId] = plan
plan plan
} }
`when`(repository.findAllBySpacecraftIdAndStatusAndStartTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull())) `when`(repository.findAllBySpacecraftIdAndStatusAndEndTimeAfterOrderByStartTime(anyNonNull(), anyNonNull(), anyNonNull()))
.thenAnswer { invocation -> .thenAnswer { invocation ->
store.values store.values
.filter { plan -> .filter { plan ->
plan.spacecraftId == invocation.arguments[0] && plan.spacecraftId == invocation.arguments[0] &&
plan.status == invocation.arguments[1] && plan.status == invocation.arguments[1] &&
plan.startTime > invocation.arguments[2] as LocalDateTime plan.endTime > invocation.arguments[2] as LocalDateTime
} }
.sortedBy { plan -> plan.startTime } .sortedBy { plan -> plan.startTime }
} }