Files
Дмитрий Соловьев c3a1a8b4a1 Add PCP architecture docs and TGU ops updates
Capture current PCP architecture notes, service-map prototypes, TGU operations UI/map work, local configuration updates, database helper scripts, and request/sample JSON artifacts.
2026-05-30 14:18:19 +03:00

1886 lines
64 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# План доработки `pcp-tgu-service` под горизонтальное масштабирование
## 0. Назначение документа
Этот документ — рабочий план для агента, который должен за один проход на усилии **high** перепроектировать `pcp-tgu-service` из single-instance сервиса с in-memory состоянием в horizontally scalable persisted scheduler выдачи планов КА.
План намеренно строгий: каждый этап имеет вход, выход, шаги, ограничения и gate перехода к следующему этапу. Агент должен двигаться по этапам последовательно и не перескакивать через gates.
После выполнения этого плана будет выполнен отдельный повторный review. Цель текущего прогона — не идеальная финальная архитектура, а устранение главных blocker'ов горизонтального масштабирования:
- production state не должен жить в памяти pod'а;
- несколько pod'ов должны безопасно выполнять polling, пересчёт, выдачу и обработку решений;
- план и попытка выдачи должны быть разными сущностями;
- BPMN должен означать одну попытку выдачи, а не долгоживущий процесс ожидания принятия;
- ручной функционал должен быть вынесен в test-only controller и не участвовать в production-flow.
---
## 1. Scope
### 1.1. Можно менять
- `services/pcp-tgu-service/**`;
- `config-repo/pcp-tgu-service.yaml`, если нужно добавить новые свойства;
- Flyway migrations внутри `services/pcp-tgu-service/src/main/resources/db/migration`;
- BPMN-файл `services/pcp-tgu-service/src/main/resources/BPMN/createSatelliteMission.bpmn`;
- тесты `services/pcp-tgu-service/src/test/**`.
### 1.2. Нельзя менять
- Helm charts;
- GitLab CI/CD;
- другие сервисы;
- общие библиотеки, если без этого можно обойтись;
- контракты других сервисов без явной необходимости;
- инфраструктурные зависимости: Redis, новые брокеры, отдельные БД, leader-election framework.
### 1.3. Не делать в этом прогоне
- не выделять отдельный сервис расчёта;
- не распределять КА между разными сервисами вручную;
- не делать Redis locks;
- не делать сложный reconciliation для `START_AMBIGUOUS`;
- не делать automatic timeout/reissue, если решение по плану вообще не пришло;
- не переписывать алгоритм `PlanCalculationService` без необходимости;
- не оптимизировать преждевременно JSONB/нормализацию окон видимости, если raw payload можно надёжно сохранить как `TEXT`.
---
## 2. Целевая production-логика
`pcp-tgu-service` должен стать сервисом, который:
1. получает список разрешённых аппаратов из НСИ;
2. для активных аппаратов получает окна видимости у баллистики;
3. полностью сохраняет raw payload окон видимости;
4. строит persisted расписание планов согласно настройкам;
5. за `planning.notification-before-start-minutes` до старта плана запускает BPMN-процесс выдачи;
6. считает один BPMN process instance одной попыткой выдачи плана;
7. хранит план и попытки выдачи как разные сущности;
8. при тех же `spacecraftId + startTime + endTime + kppId` создаёт тот же persisted plan, но новую attempt;
9. получает `ACCEPTED` / `REJECTED` через отдельный Kafka topic;
10. при `ACCEPTED` фиксирует план как базовый и пересчитывает хвост расписания;
11. при `REJECTED` создаёт возможность новой попытки того же плана, если время ещё позволяет;
12. если время ушло, переводит текущий план в `EXPIRED` и продвигает следующий доступный план;
13. если решения нет, не делает автоматическую перевыдачу.
---
## 3. Архитектурные решения, которые нельзя менять без явной причины
### 3.1. Kafka разделяется на два входа
Должно быть два разных Kafka topic и два разных consumer/use case.
#### Topic 1: изменение окон видимости
Смысл: сигнал, что для КА нужно перечитать окна у баллистики.
Consumer не должен:
- хранить окна в памяти;
- получать окна у баллистики прямо в listener transaction;
- пересчитывать планы;
- стартовать BPMN.
Consumer должен только поставить или обновить `visibility_refresh_job`.
#### Topic 2: решение по попытке выдачи плана
Смысл: результат конкретной попытки выдачи плана.
Payload должен содержать:
- `eventId`;
- `spacecraftId`;
- `planId`;
- `attemptId`;
- `decision = ACCEPTED | REJECTED`;
- `decisionTime`;
- `reason`.
`attemptId` обязателен. Без него нельзя безопасно отличить решение старой попытки от решения новой попытки того же плана.
### 3.2. План и попытка выдачи — разные сущности
Один business-plan:
```text
spacecraftId + startTime + endTime + kppId
```
должен иметь один persisted `planId`.
Повторная выдача того же business-plan должна создавать новый `attemptId`, а не новый `planId`.
### 3.3. BPMN = одна попытка выдачи
Целевой BPMN:
```text
start
-> calculateSatelliteSurveyMissions
-> calculateSatelliteDropMissions
-> sendSatellitePlan
-> end
```
BPMN больше не должен ждать accepted/rejected message. Результат попытки приходит через Kafka topic решения по плану.
### 3.4. DB — source of truth
Production-flow не должен опираться на:
- `PlanRepository` как in-memory хранилище планов;
- `SpacecraftPointsRepository` как in-memory хранилище окон видимости.
Локальная память допустима только для временных вычислений внутри одного метода или как read-only cache, от которого не зависит корректность.
### 3.5. Горизонтальное масштабирование через DB claim
Все повторяющиеся workers/schedulers могут запускаться на каждом pod. Корректность должна обеспечиваться через:
- unique constraints;
- transactional updates;
- `FOR UPDATE SKIP LOCKED` или lease-модель;
- idempotency по `eventId`;
- active attempt на уровне `spacecraft_planning_state`.
---
## 4. Текущие code anchors
Текущие классы, на которые нужно ориентироваться:
```text
services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service
```
Ключевые текущие файлы:
```text
config/PlanningProperties.kt
config/TopicsProperties.kt
controller/ExternalPointsController.kt
controller/PlanConfirmationController.kt
controller/PlanController.kt
controller/PlatformController.kt
controller/SatelliteMissionController.kt
domain/Plan.kt
domain/SpacecraftPoints.kt
dto/CreateSatelliteMissionVariables.kt
dto/ExternalRvaDto.kt
dto/SpacecraftPointsMessage.kt
entity/SpacecraftPlanningStateEntity.kt
entity/TrackedPlanEntity.kt
entity/TrackedPlanStatus.kt
integration/api/ExternalPointsClient.kt
integration/api/PlatformsClassifierClient.kt
integration/kafka/SpacecraftPointsKafkaConsumer.kt
repository/PlanRepository.kt
repository/SpacecraftPlanningStateJpaRepository.kt
repository/SpacecraftPointsRepository.kt
repository/TrackedPlanJpaRepository.kt
service/ExternalPointsUpdateScheduler.kt
service/PlanCalculationService.kt
service/PlanConfirmationService.kt
service/PlanProcessScheduler.kt
service/PlanQueryService.kt
service/PlanStatusWorker.kt
service/PlatformService.kt
service/SatelliteMissionProcessService.kt
service/SpacecraftPlanningStateService.kt
service/SpacecraftPointsService.kt
service/TrackedPlanService.kt
```
Текущие migrations:
```text
V1__create_tracked_plan.sql
V2__extend_tracked_plan.sql
V3__create_spacecraft_planning_state.sql
```
Новая migration должна быть следующей по номеру, например:
```text
V4__redesign_tgu_planning_state.sql
```
---
## 5. Целевая структура кода
Агент может выбрать небольшие вариации имён, но структура должна остаться близкой к этой.
```text
config/
PlanningProperties.kt
TopicsProperties.kt
controller/
PlanController.kt
PlatformController.kt
TestTguController.kt
.domain/
CalculatedPlan.kt
InsertionPoint.kt
ObservationWindow.kt
PlanDecision.kt
SpacecraftPoints.kt
.dto/
CreateSatelliteMissionVariables.kt
PlanDecisionMessage.kt
PlanResponse.kt
VisibilityWindowsChangedMessage.kt
.entity/
JobStatus.kt
PlanIssueAttemptEntity.kt
PlanIssueAttemptStatus.kt
PlannedPlanEntity.kt
PlannedPlanStatus.kt
ProcessedPlanDecisionEventEntity.kt
SpacecraftPlanningStateEntity.kt
SpacecraftSnapshotEntity.kt
SpacecraftVisibilitySnapshotEntity.kt
VisibilityRefreshJobEntity.kt
PlanRecalculationJobEntity.kt
.integration/api/
ExternalPointsClient.kt
PlatformsClassifierClient.kt
VisibilityPayloadParser.kt
.integration/kafka/
VisibilityWindowsChangedConsumer.kt
PlanDecisionConsumer.kt
.repository/
PlannedPlanRepository.kt
PlanIssueAttemptRepository.kt
ProcessedPlanDecisionEventRepository.kt
SpacecraftPlanningStateJpaRepository.kt
SpacecraftSnapshotRepository.kt
SpacecraftVisibilitySnapshotRepository.kt
VisibilityRefreshJobRepository.kt
PlanRecalculationJobRepository.kt
.service/
BpmnPlanIssueProcessStarter.kt
HandlePlanDecisionUseCase.kt
IssueDuePlanWorker.kt
PlanCalculationService.kt
PlanQueryService.kt
PlanRecalculationWorker.kt
RebuildPlanScheduleUseCase.kt
RefreshVisibilityWindowsUseCase.kt
SpacecraftVisibilitySnapshotService.kt
SyncSpacecraftFromNsiUseCase.kt
NsiSyncScheduler.kt
VisibilityRefreshWorker.kt
```
Если существующие классы проще переиспользовать с переименованием — можно. Но production-flow не должен сохранять старую semantic responsibility.
---
## 6. Этапы и gates
## Этап 1. Подготовить доменную модель
### Цель этапа
Отделить расчётный план от persisted plan и попытки выдачи.
### Вход этапа
- `Plan.kt` с random `UUID`;
- `TrackedPlanEntity`;
- `TrackedPlanStatus`;
- `PlanCalculationService`, который возвращает `List<Plan>`.
### Шаг 1.1. Добавить `CalculatedPlan`
**Реализация:**
Создать domain model:
```kotlin
data class CalculatedPlan(
val spacecraftId: String,
val startTime: LocalDateTime,
val endTime: LocalDateTime,
val kppId: String
)
```
**Вход шага:** текущий `Plan`.
**Выход шага:** новая модель без `planId`.
**Существующие классы:** `Plan`.
**Новая модель:** `CalculatedPlan`.
**Слой:** domain.
**Строгое правило:** не использовать `UUID.randomUUID()` в расчётном алгоритме.
### Шаг 1.2. Добавить enum'ы lifecycle
Добавить:
```text
PlannedPlanStatus:
PLANNED
ISSUING
WAITING_DECISION
ACCEPTED
REJECTED
SUPERSEDED
EXPIRED
START_AMBIGUOUS
PlanIssueAttemptStatus:
STARTING
STARTED
ACCEPTED
REJECTED
FAILED
AMBIGUOUS
PlanDecision:
ACCEPTED
REJECTED
JobStatus:
PENDING
RUNNING
DONE
FAILED
```
**Вход шага:** `TrackedPlanStatus`.
**Выход шага:** новые enum classes.
**Существующие классы:** `TrackedPlanStatus`.
**Новая модель:** да.
**Слой:** domain/entity.
**Строгое правило:** новые production-flow классы должны использовать новые статусы, а не `TrackedPlanStatus`.
### Шаг 1.3. Перевести `PlanCalculationService` на `CalculatedPlan`
**Реализация:**
- `calculatePlans(...)` возвращает `List<CalculatedPlan>`;
- `calculatePlansAfter(...)` принимает previous plan как business fields. Можно временно принимать `CalculatedPlan` или отдельный lightweight type;
- `calculateNearestPlanFrom(...)`, если сохраняется для test-only functionality, возвращает `CalculatedPlan?`;
- private `StartCandidate.toPlan(...)` переименовать в `toCalculatedPlan(...)`.
**Вход шага:** `SpacecraftPoints`.
**Выход шага:** `List<CalculatedPlan>`.
**Существующие классы:** `PlanCalculationService`, `Plan`.
**Новая модель:** `CalculatedPlan`.
**Слой:** algorithm/domain.
**Строгое правило:** алгоритм выбора окон не менять, если изменение не требуется для компиляции.
### Gate 1
К следующему этапу можно переходить только если:
- `CalculatedPlan` добавлен;
- enum'ы добавлены;
- `PlanCalculationService` больше не создаёт random `planId`;
- тесты алгоритма обновлены или временно помечены к обновлению в этапе тестов;
- production-flow ещё может не компилироваться полностью, но направление изменений должно быть очевидным и локальным.
---
## Этап 2. Добавить persistent schema
### Цель этапа
Создать новую persisted модель без destructive migration старых таблиц.
### Вход этапа
- существующие таблицы `tracked_plan`, `spacecraft_planning_state`;
- отсутствие persisted окон видимости, планов, attempts и jobs.
### Шаг 2.1. Добавить Flyway migration
Создать:
```text
services/pcp-tgu-service/src/main/resources/db/migration/V4__redesign_tgu_planning_state.sql
```
Migration должна:
1. не удалять старые таблицы;
2. создать новые таблицы;
3. расширить `spacecraft_planning_state`.
Минимальная схема:
```sql
CREATE TABLE IF NOT EXISTS spacecraft_snapshot (
spacecraft_id VARCHAR(255) PRIMARY KEY,
norad_id BIGINT NOT NULL,
nsi_id VARCHAR(255),
business_key VARCHAR(255),
name VARCHAR(1024),
status VARCHAR(255),
mission VARCHAR(1024),
valid_from TIMESTAMP,
valid_to TIMESTAMP,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS spacecraft_visibility_snapshot (
spacecraft_id VARCHAR(255) PRIMARY KEY,
norad_id BIGINT NOT NULL,
payload_json TEXT NOT NULL,
payload_hash VARCHAR(128) NOT NULL,
fetched_at TIMESTAMP NOT NULL,
visibility_from TIMESTAMP,
visibility_to TIMESTAMP,
windows_count INTEGER,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS planned_plan (
plan_id UUID PRIMARY KEY,
spacecraft_id VARCHAR(255) NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
kpp_id VARCHAR(255) NOT NULL,
status VARCHAR(64) NOT NULL,
issue_not_before TIMESTAMP,
base_plan_id UUID,
chain_version BIGINT NOT NULL DEFAULT 0,
accepted_at TIMESTAMP,
superseded_by_plan_id UUID,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
CONSTRAINT uq_planned_plan_business_key UNIQUE (spacecraft_id, start_time, end_time, kpp_id)
);
CREATE INDEX IF NOT EXISTS idx_planned_plan_due
ON planned_plan (status, issue_not_before, start_time);
CREATE INDEX IF NOT EXISTS idx_planned_plan_spacecraft_start
ON planned_plan (spacecraft_id, start_time);
CREATE TABLE IF NOT EXISTS plan_issue_attempt (
attempt_id UUID PRIMARY KEY,
plan_id UUID NOT NULL REFERENCES planned_plan(plan_id),
attempt_no INTEGER NOT NULL,
status VARCHAR(64) NOT NULL,
process_instance_key BIGINT,
decision_event_id UUID,
decision_reason TEXT,
started_at TIMESTAMP,
finished_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
CONSTRAINT uq_plan_issue_attempt_no UNIQUE (plan_id, attempt_no)
);
CREATE INDEX IF NOT EXISTS idx_plan_issue_attempt_plan
ON plan_issue_attempt (plan_id, attempt_no);
CREATE TABLE IF NOT EXISTS visibility_refresh_job (
spacecraft_id VARCHAR(255) PRIMARY KEY,
norad_id BIGINT,
status VARCHAR(64) NOT NULL,
reason VARCHAR(255),
locked_by VARCHAR(255),
locked_until TIMESTAMP,
attempts INTEGER NOT NULL DEFAULT 0,
requested_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS plan_recalculation_job (
spacecraft_id VARCHAR(255) PRIMARY KEY,
status VARCHAR(64) NOT NULL,
reason VARCHAR(255),
locked_by VARCHAR(255),
locked_until TIMESTAMP,
attempts INTEGER NOT NULL DEFAULT 0,
requested_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE IF NOT EXISTS processed_plan_decision_event (
event_id UUID PRIMARY KEY,
received_at TIMESTAMP NOT NULL
);
ALTER TABLE spacecraft_planning_state
ADD COLUMN IF NOT EXISTS active_plan_id UUID;
ALTER TABLE spacecraft_planning_state
ADD COLUMN IF NOT EXISTS active_attempt_id UUID;
ALTER TABLE spacecraft_planning_state
ADD COLUMN IF NOT EXISTS chain_version BIGINT NOT NULL DEFAULT 0;
```
**Вход шага:** migrations V1-V3.
**Выход шага:** V4 migration.
**Существующие классы:** none.
**Новая модель:** new persisted schema.
**Слой:** persistence.
**Строгое правило:** не удалять и не переименовывать старые таблицы в этом прогоне.
### Шаг 2.2. Добавить entities
Создать JPA entities:
- `SpacecraftSnapshotEntity`;
- `SpacecraftVisibilitySnapshotEntity`;
- `PlannedPlanEntity`;
- `PlanIssueAttemptEntity`;
- `VisibilityRefreshJobEntity`;
- `PlanRecalculationJobEntity`;
- `ProcessedPlanDecisionEventEntity`.
Обновить `SpacecraftPlanningStateEntity`:
- `activePlanId: UUID?`;
- `activeAttemptId: UUID?`;
- `chainVersion: Long`.
**Вход шага:** migration.
**Выход шага:** JPA entities.
**Существующие классы:** `SpacecraftPlanningStateEntity`.
**Новая модель:** listed entities.
**Слой:** persistence.
**Строгое правило:** entity classes и enum classes должны иметь комментарии, потому что это повышает читаемость модели.
### Шаг 2.3. Добавить repositories
Создать repositories:
- `SpacecraftSnapshotRepository`;
- `SpacecraftVisibilitySnapshotRepository`;
- `PlannedPlanRepository`;
- `PlanIssueAttemptRepository`;
- `VisibilityRefreshJobRepository`;
- `PlanRecalculationJobRepository`;
- `ProcessedPlanDecisionEventRepository`.
Добавить native methods для claim/lock.
Для jobs использовать паттерн:
```sql
SELECT *
FROM visibility_refresh_job
WHERE status = 'PENDING'
AND (locked_until IS NULL OR locked_until < now())
ORDER BY requested_at
LIMIT :limit
FOR UPDATE SKIP LOCKED
```
Для due plans использовать паттерн:
```sql
SELECT *
FROM planned_plan
WHERE status = 'PLANNED'
AND (issue_not_before IS NULL OR issue_not_before <= :now)
AND start_time <= :issueBefore
AND start_time > :now
ORDER BY start_time
LIMIT :limit
FOR UPDATE SKIP LOCKED
```
Для state lock добавить method в `SpacecraftPlanningStateJpaRepository`:
```sql
SELECT *
FROM spacecraft_planning_state
WHERE spacecraft_id = :spacecraftId
FOR UPDATE
```
Если state отсутствует, use case должен создать его перед lock-sensitive operations либо использовать create-and-lock pattern.
**Вход шага:** entities.
**Выход шага:** repositories.
**Существующие классы:** `SpacecraftPlanningStateJpaRepository`.
**Новая модель:** repository methods.
**Слой:** persistence.
**Строгое правило:** workers не должны делать select без claim, если результат запускает side effect или меняет state.
### Gate 2
К следующему этапу можно переходить только если:
- migration добавлена;
- entities соответствуют migration;
- repositories добавлены;
- есть claim methods для jobs и due plans;
- `SpacecraftPlanningStateEntity` расширена;
- проект может не компилироваться из-за ещё не переписанных services, но persistence-модель должна быть цельной.
---
## Этап 3. Переписать получение и хранение окон видимости
### Цель этапа
Сохранять полный raw payload баллистики и строить domain model из raw payload только для расчёта.
### Вход этапа
- `ExternalPointsClient.fetchSpacecraftPoints(...)` возвращает `SpacecraftPoints`;
- `SpacecraftPointsRepository` хранит points in-memory;
- `SpacecraftPointsService.saveAndRecalculate(...)` сразу пересчитывает планы.
### Шаг 3.1. Изменить `ExternalPointsClient`
Добавить метод:
```kotlin
fun fetchRawVisibilityPayload(
satelliteId: Long,
timeStart: LocalDateTime,
timeStop: LocalDateTime
): String
```
Требования:
- использовать тот же endpoint `/api/satellites/{satelliteId}/rva`;
- сохранить полный body как строку;
- не терять неизвестные поля;
- существующий DTO parsing перенести из client в parser.
**Вход шага:** текущий `ExternalPointsClient`.
**Выход шага:** client умеет получать raw payload.
**Существующие классы:** `ExternalPointsClient`, `ExternalRvaDto`.
**Новая модель:** none.
**Слой:** adapter.
**Строгое правило:** external client не должен сохранять payload в БД и не должен запускать пересчёт.
### Шаг 3.2. Добавить `VisibilityPayloadParser`
Создать parser:
```kotlin
@Component
class VisibilityPayloadParser(
private val objectMapper: ObjectMapper
) {
fun parse(spacecraftId: String, payloadJson: String): SpacecraftPoints
}
```
Логика парсинга должна повторить текущий `mapToDomain(...)`:
- сгруппировать RVA records по `stationId`;
- создать `InsertionPoint`;
- создать `ObservationWindow`;
- сортировать окна по `startTime`;
- сортировать insertion points по `pointId`.
**Вход шага:** raw JSON.
**Выход шага:** `SpacecraftPoints`.
**Существующие классы:** `ExternalRvaItemDto`, `SpacecraftPoints`, `InsertionPoint`, `ObservationWindow`.
**Новая модель:** `VisibilityPayloadParser`.
**Слой:** adapter/domain boundary.
**Строгое правило:** parser не должен обращаться в БД.
### Шаг 3.3. Добавить `SpacecraftVisibilitySnapshotService`
Создать service:
```text
saveSnapshot(spacecraftId, noradId, payloadJson, fetchedAt)
```
Логика:
1. вычислить `payloadHash`, например SHA-256;
2. найти текущий snapshot по `spacecraftId`;
3. если snapshot отсутствует — сохранить и поставить recalculation job;
4. если hash изменился — обновить snapshot и поставить recalculation job;
5. если hash не изменился — обновить `fetchedAt`/`updatedAt`, но не ставить recalculation job.
Optional derived metadata:
- `visibilityFrom`;
- `visibilityTo`;
- `windowsCount`.
Эти поля можно заполнить через parser. Если это усложняет реализацию, можно оставить null/0, но raw payload обязателен.
**Вход шага:** raw payload.
**Выход шага:** persisted visibility snapshot и optional recalculation job.
**Существующие классы:** `SpacecraftPointsService`.
**Новая модель:** `SpacecraftVisibilitySnapshotEntity`.
**Слой:** orchestration/persistence.
**Строгое правило:** production-flow больше не должен писать в `SpacecraftPointsRepository`.
### Gate 3
К следующему этапу можно переходить только если:
- raw payload можно получить у баллистики;
- raw payload можно сохранить полностью;
- parser строит `SpacecraftPoints` из raw payload;
- hash unchanged не создаёт лишний recalculation job;
- `SpacecraftPointsRepository` больше не нужен для нового production-flow.
---
## Этап 4. NSI sync и visibility refresh jobs
### Цель этапа
Сделать получение аппаратов и окон видимости job-based и безопасным для нескольких pod'ов.
### Вход этапа
- `PlatformService.loadAllowedPlatforms()`;
- `ExternalPointsUpdateScheduler`, который при старте/по расписанию сразу ходит в баллистику и пересчитывает планы.
### Шаг 4.1. Добавить `SyncSpacecraftFromNsiUseCase`
Логика:
1. вызвать `PlatformService.loadAllowedPlatforms()`;
2. сохранить/обновить `spacecraft_snapshot`;
3. active=true для найденных разрешённых платформ;
4. active=false для ранее известных, но больше не разрешённых/не найденных;
5. для active spacecraft поставить/обновить `visibility_refresh_job`.
`spacecraftId` на этом этапе использовать совместимо с текущей логикой: `noradId.toString()`.
**Вход шага:** список платформ из НСИ.
**Выход шага:** persisted spacecraft snapshot и refresh jobs.
**Существующие классы:** `PlatformService`, `PlatformCatalogItem`.
**Новая модель:** `SpacecraftSnapshotEntity`, `VisibilityRefreshJobEntity`.
**Слой:** orchestration/persistence.
**Строгое правило:** use case не должен ходить в баллистику и не должен считать планы.
### Шаг 4.2. Добавить `NsiSyncScheduler`
Scheduler вызывает `SyncSpacecraftFromNsiUseCase` по расписанию.
Можно переиспользовать существующий `planning.external-update-fixed-delay-ms`, чтобы не плодить лишние настройки.
Если `planning.external-update-enabled=false`, scheduler не должен выполнять sync.
**Вход шага:** настройки planning.
**Выход шага:** scheduled sync.
**Существующие классы:** `ExternalPointsUpdateScheduler`.
**Новая модель:** `NsiSyncScheduler`.
**Слой:** orchestration.
**Строгое правило:** если scheduler работает на нескольких pod, это допустимо: jobs должны coalesce'иться по `spacecraft_id`.
### Шаг 4.3. Добавить `VisibilityRefreshWorker`
Worker должен:
1. claim'ить pending refresh jobs через `FOR UPDATE SKIP LOCKED` или lease;
2. для каждого job определить `noradId`:
- из job, если есть;
- иначе из `spacecraft_snapshot`;
3. рассчитать интервал:
- `timeStart = now.truncatedTo(MINUTES)`;
- `timeStop = timeStart + externalPointsLookaheadDays`;
4. вызвать `ExternalPointsClient.fetchRawVisibilityPayload(...)`;
5. сохранить snapshot через `SpacecraftVisibilitySnapshotService`;
6. отметить job done или вернуть в pending/failed по понятной retry-логике.
Минимальная retry-логика:
- увеличить `attempts`;
- при временной ошибке вернуть в `PENDING` с `locked_until = null` или оставить `FAILED` после разумного порога;
- не блокировать остальные КА.
**Вход шага:** `visibility_refresh_job`.
**Выход шага:** `spacecraft_visibility_snapshot`, optionally `plan_recalculation_job`.
**Существующие классы:** `ExternalPointsUpdateScheduler`, `ExternalPointsClient`.
**Новая модель:** `VisibilityRefreshWorker`.
**Слой:** orchestration/adapter.
**Строгое правило:** worker может запускаться на каждом pod; один job должен обрабатываться одним pod.
### Gate 4
К следующему этапу можно переходить только если:
- список аппаратов сохраняется в БД;
- refresh jobs coalesce'ятся по `spacecraft_id`;
- worker получает raw payload и сохраняет snapshot;
- получение окон больше не запускает немедленный in-memory recalculation.
---
## Этап 5. Kafka visibility event
### Цель этапа
Оставить Kafka для события изменения окон видимости как сигнал к refresh, а не как источник in-memory state.
### Вход этапа
- `SpacecraftPointsKafkaConsumer` слушает `spacecraftPointsUpdate` и вызывает `SpacecraftPointsService.saveFromMessage(...)`.
### Шаг 5.1. Обновить `TopicsProperties`
Заменить:
```kotlin
val spacecraftPointsUpdate: String
```
на:
```kotlin
val visibilityWindowsChanged: String
val satellitePlanDecision: String
```
**Вход шага:** текущий `TopicsProperties`.
**Выход шага:** новые topic properties.
**Существующие классы:** `TopicsProperties`.
**Новая модель:** none.
**Слой:** config.
### Шаг 5.2. Добавить `VisibilityWindowsChangedMessage`
DTO:
```kotlin
data class VisibilityWindowsChangedMessage(
val eventId: UUID?,
val spacecraftId: String,
val noradId: Long?,
val changedAt: LocalDateTime?
)
```
**Вход шага:** old `SpacecraftPointsMessage`.
**Выход шага:** new lightweight event.
**Существующие классы:** `SpacecraftPointsMessage`.
**Новая модель:** `VisibilityWindowsChangedMessage`.
**Слой:** dto.
### Шаг 5.3. Заменить consumer
Создать `VisibilityWindowsChangedConsumer`:
```text
@KafkaListener(topics = topics.visibilityWindowsChanged, groupId = ...)
-> enqueue visibility_refresh_job(spacecraftId, noradId, reason='KAFKA_VISIBILITY_CHANGED')
```
Удалить или вывести из production-flow `SpacecraftPointsKafkaConsumer`.
**Вход шага:** Kafka event.
**Выход шага:** refresh job.
**Существующие классы:** `SpacecraftPointsKafkaConsumer`, `SpacecraftPointsService`.
**Новая модель:** `VisibilityWindowsChangedConsumer`.
**Слой:** adapter/orchestration.
**Строгое правило:** consumer не должен парсить окна, получать окна у баллистики, считать планы или стартовать BPMN.
### Gate 5
К следующему этапу можно переходить только если:
- old `spacecraftPointsUpdate` больше не используется в production consumer;
- visibility event only enqueues refresh job;
- topic config содержит два новых topic name.
---
## Этап 6. Persistent расчёт расписания
### Цель этапа
Строить расписание планов из persisted visibility snapshot и сохранять его в БД.
### Вход этапа
- latest `spacecraft_visibility_snapshot`;
- `PlanCalculationService` уже возвращает `CalculatedPlan`;
- `spacecraft_planning_state.current_base_plan_id`.
### Шаг 6.1. Добавить enqueue для recalculation job
В `PlanRecalculationJobRepository`/service добавить метод:
```text
enqueue(spacecraftId, reason)
```
Требования:
- coalesce по `spacecraft_id`;
- если job уже `PENDING` или `RUNNING`, обновить `requested_at`/`reason`, но не создавать дубль;
- если job `DONE`/`FAILED`, вернуть в `PENDING`.
**Вход шага:** spacecraftId.
**Выход шага:** pending recalculation job.
**Существующие классы:** none.
**Новая модель:** `PlanRecalculationJobEntity`.
**Слой:** persistence/orchestration.
### Шаг 6.2. Добавить `RebuildPlanScheduleUseCase`
Логика:
1. получить latest visibility snapshot;
2. распарсить `payloadJson` через `VisibilityPayloadParser`;
3. прочитать `spacecraft_planning_state`;
4. если `currentBasePlanId` есть:
- загрузить persisted accepted/base plan;
- рассчитать хвост после base plan;
5. если base plan отсутствует:
- рассчитать полную цепочку;
6. upsert each `CalculatedPlan` into `planned_plan` by business key;
7. для новых планов выставить:
- `status = PLANNED`;
- `issueNotBefore = null` или `startTime - notificationBeforeStartMinutes`;
- `basePlanId = currentBasePlanId`;
- `chainVersion = state.chainVersion + 1`;
8. obsolete future `PLANNED` plans, отсутствующие в новом расчёте, пометить `SUPERSEDED`;
9. не трогать планы в статусах:
- `ISSUING`;
- `WAITING_DECISION`;
- `ACCEPTED`;
- `START_AMBIGUOUS`.
**Вход шага:** spacecraftId.
**Выход шага:** persisted plan schedule.
**Существующие классы:** `SpacecraftPointsService`, `PlanCalculationService`, `SpacecraftPlanningStateService`.
**Новая модель:** `PlannedPlanEntity`, `PlanRecalculationJobEntity`.
**Слой:** orchestration/domain/persistence.
**Строгое правило:** upsert должен сохранять стабильный `planId` для одного business key.
### Шаг 6.3. Добавить `PlanRecalculationWorker`
Worker:
1. claim'ит recalculation jobs;
2. вызывает `RebuildPlanScheduleUseCase`;
3. завершает job;
4. при ошибке увеличивает attempts и не блокирует остальные jobs.
**Вход шага:** `plan_recalculation_job`.
**Выход шага:** rebuilt persisted schedule.
**Существующие классы:** `SpacecraftPointsService`.
**Новая модель:** `PlanRecalculationWorker`.
**Слой:** orchestration.
### Gate 6
К следующему этапу можно переходить только если:
- расписание планов сохраняется в `planned_plan`;
- repeated rebuild не создаёт дубли для того же business key;
- old in-memory `PlanRepository` не участвует в production rebuild;
- obsolete future plans получают `SUPERSEDED`, но active/accepted планы не ломаются.
---
## Этап 7. Выдача планов через BPMN
### Цель этапа
Выдавать due plans из БД через attempts и безопасный claim.
### Вход этапа
- persisted `planned_plan`;
- `PlanProcessScheduler`, который сканирует in-memory `PlanRepository`;
- `SatelliteMissionProcessService`, который смешивает manual start, расчёт и Camunda call.
### Шаг 7.1. Добавить `BpmnPlanIssueProcessStarter`
Новый adapter service:
```text
startProcess(plan, attempt): Long processInstanceKey
```
Он должен:
- получить `PlannedPlanEntity` и `PlanIssueAttemptEntity`;
- заполнить `CreateSatelliteMissionVariables`;
- вызвать Zeebe/Camunda create instance;
- вернуть `processInstanceKey`.
BPMN variables:
```text
spacecraftId
satellitePlanId
satellitePlanAttemptId
planStartTime
planEndTime
kppId
```
**Вход шага:** plan + attempt.
**Выход шага:** processInstanceKey.
**Существующие классы:** `SatelliteMissionProcessService`, `CreateSatelliteMissionVariables`.
**Новая модель:** `BpmnPlanIssueProcessStarter`.
**Слой:** external adapter.
**Строгое правило:** starter не должен искать nearest plan, читать visibility snapshot, создавать plan, принимать решения о retry.
### Шаг 7.2. Добавить `IssueDuePlanWorker`
Worker должен:
1. найти due `PLANNED` plans через repository claim;
2. для каждого plan в отдельной transaction:
- lock/create `spacecraft_planning_state`;
- если `activeAttemptId != null`, пропустить plan;
- определить next `attemptNo` для `planId`;
- создать `PlanIssueAttemptEntity(status=STARTING)`;
- поставить `plan.status = ISSUING`;
- заполнить `state.activePlanId` и `state.activeAttemptId`;
- commit;
3. после commit вызвать `BpmnPlanIssueProcessStarter.startProcess(...)` вне DB transaction;
4. после успешного call в новой transaction:
- сохранить `processInstanceKey`;
- `attempt.status = STARTED`;
- `attempt.startedAt = now`;
- `plan.status = WAITING_DECISION`;
5. если Camunda call бросил exception:
- `attempt.status = AMBIGUOUS`;
- `plan.status = START_AMBIGUOUS`;
- active ids оставить или перевести в состояние, явно блокирующее автоматический повтор;
- не делать автоматический retry.
Due condition:
```text
status = PLANNED
AND (issueNotBefore IS NULL OR issueNotBefore <= now)
AND startTime <= now + notificationBeforeStartMinutes
AND startTime > now
```
**Вход шага:** due persisted plan.
**Выход шага:** attempt + BPMN process.
**Существующие классы:** `PlanProcessScheduler`, `SatelliteMissionProcessService`.
**Новая модель:** `IssueDuePlanWorker`, `PlanIssueAttemptEntity`.
**Слой:** orchestration.
**Строгое правило:** DB transaction не должна держаться во время Zeebe/Camunda call.
### Шаг 7.3. Убрать production usage старого scheduler/process service
- `PlanProcessScheduler` не должен быть production scheduler;
- `SatelliteMissionProcessService` не должен быть production use case для ручного запуска;
- если классы оставлены временно, они должны использоваться только из test-only controller или быть удалены.
**Вход шага:** old flow.
**Выход шага:** issue flow only through `IssueDuePlanWorker`.
**Существующие классы:** `PlanProcessScheduler`, `SatelliteMissionProcessService`.
**Новая модель:** none.
**Слой:** cleanup.
### Gate 7
К следующему этапу можно переходить только если:
- due plans читаются из БД;
- по одному КА одновременно не создаётся несколько active attempts;
- повторная выдача same plan создаёт new attemptNo/attemptId;
- Camunda start выполняется вне DB transaction;
- ambiguous start не ретраится автоматически;
- `PlanProcessScheduler` не участвует в production-flow.
---
## Этап 8. BPMN вариант A
### Цель этапа
Сделать BPMN одной попыткой выдачи, без ожидания принятия.
### Вход этапа
Текущий `createSatelliteMission.bpmn` содержит:
- расчёт survey missions;
- расчёт drop missions;
- отправку satellite plan;
- ожидание `Message_SatellitePlanAccepted`;
- `updatePlanStatus`;
- `updateSlotsStatus`.
### Шаг 8.1. Упростить BPMN
Целевой flow:
```text
start
-> calculateSatelliteSurveyMissions
-> calculateSatelliteDropMissions
-> sendSatellitePlan
-> end
```
Удалить из production BPMN:
- message catch event ожидания accepted;
- `updatePlanStatus` service task;
- `updateSlotsStatus`, если он был строго после accepted.
Если `updateSlotsStatus` критичен, не пытаться встроить его обратно без понимания контракта. Оставить как follow-up в финальном отчёте.
**Вход шага:** BPMN file.
**Выход шага:** short-lived BPMN per attempt.
**Существующие файлы:** `createSatelliteMission.bpmn`.
**Слой:** BPMN/orchestration.
### Шаг 8.2. Обновить process variables
`CreateSatelliteMissionVariables` должен включать:
```text
spacecraftId
satellitePlanId
satellitePlanAttemptId
planStartTime
planEndTime
kppId
```
**Вход шага:** old variables.
**Выход шага:** attempt-aware variables.
**Существующие классы:** `CreateSatelliteMissionVariables`.
**Слой:** dto.
### Gate 8
К следующему этапу можно переходить только если:
- BPMN больше не ждёт accepted/rejected;
- `PlanStatusWorker` больше не требуется production BPMN;
- process variables содержат `satellitePlanAttemptId`.
---
## Этап 9. Kafka decision flow
### Цель этапа
Обрабатывать принятие/непринятие попытки выдачи через отдельный Kafka topic.
### Вход этапа
- separate topic `satellitePlanDecision`;
- persisted plans/attempts;
- `spacecraft_planning_state` с active ids.
### Шаг 9.1. Добавить `PlanDecisionMessage`
DTO:
```kotlin
data class PlanDecisionMessage(
val eventId: UUID,
val spacecraftId: String,
val planId: UUID,
val attemptId: UUID,
val decision: PlanDecision,
val decisionTime: LocalDateTime,
val reason: String?
)
```
**Вход шага:** Kafka payload.
**Выход шага:** typed DTO.
**Существующие классы:** none or old confirmation DTO.
**Новая модель:** `PlanDecisionMessage`.
**Слой:** dto.
### Шаг 9.2. Добавить `PlanDecisionConsumer`
Consumer:
```text
@KafkaListener(topics = topics.satellitePlanDecision, groupId = ...)
-> HandlePlanDecisionUseCase.handle(message)
```
**Вход шага:** Kafka event.
**Выход шага:** delegated use case.
**Существующие классы:** `PlanStatusWorker`, `PlanConfirmationService`.
**Новая модель:** `PlanDecisionConsumer`.
**Слой:** adapter.
### Шаг 9.3. Добавить `HandlePlanDecisionUseCase`
Общая transaction логика:
1. insert `processed_plan_decision_event(eventId)`;
2. если duplicate key — вернуть success/idempotent no-op;
3. lock attempt by `attemptId`;
4. проверить, что attempt belongs to `planId`;
5. lock plan;
6. lock/create planning state;
7. применить decision.
#### ACCEPTED
```text
attempt.status = ACCEPTED
attempt.finishedAt = decisionTime
attempt.decisionEventId = eventId
plan.status = ACCEPTED
plan.acceptedAt = decisionTime
state.currentBasePlanId = planId
state.activePlanId = null
state.activeAttemptId = null
state.chainVersion = state.chainVersion + 1
enqueue recalculation_job(spacecraftId, reason='PLAN_ACCEPTED')
```
#### REJECTED before cutoff
Condition:
```text
now < plan.startTime - planning.minReissueLeadMinutes
```
Action:
```text
attempt.status = REJECTED
attempt.finishedAt = decisionTime
attempt.decisionEventId = eventId
attempt.decisionReason = reason
plan.status = PLANNED
plan.issueNotBefore = now
state.activePlanId = null
state.activeAttemptId = null
```
`IssueDuePlanWorker` позже создаст новую attempt for same plan.
#### REJECTED after cutoff
Condition:
```text
now >= plan.startTime - planning.minReissueLeadMinutes
```
Action:
```text
attempt.status = REJECTED
attempt.finishedAt = decisionTime
attempt.decisionEventId = eventId
attempt.decisionReason = reason
plan.status = EXPIRED
state.activePlanId = null
state.activeAttemptId = null
```
Then:
1. найти следующий `PLANNED` plan for same spacecraft with `startTime > now`, order by `startTime`;
2. если найден: `nextPlan.issueNotBefore = now`;
3. если не найден: enqueue recalculation job.
**Вход шага:** `PlanDecisionMessage`.
**Выход шага:** updated plan/attempt/planning state.
**Существующие классы:** `PlanConfirmationService`, `TrackedPlanService`, `SpacecraftPlanningStateService`.
**Новая модель:** `ProcessedPlanDecisionEventEntity`.
**Слой:** orchestration/domain.
**Строгое правило:** не запускать новую attempt прямо из decision use case. Use case только меняет state; запуск делает `IssueDuePlanWorker`.
### Gate 9
К следующему этапу можно переходить только если:
- duplicate `eventId` idempotently ignored;
- accepted updates base plan;
- rejected before cutoff reopens same plan;
- rejected after cutoff expires current plan and promotes next;
- no decision means no automatic retry.
---
## Этап 10. Controllers
### Цель этапа
Оставить production API только read-only, ручные операции перенести в явно test-only controller.
### Вход этапа
- `SatelliteMissionController` содержит ручной запуск;
- `PlanConfirmationController` содержит ручное подтверждение;
- `ExternalPointsController` может использоваться как manual/debug.
### Шаг 10.1. Обновить production `PlanController` / `PlanQueryService`
Production endpoints:
```text
GET /api/plans
GET /api/plans/{spacecraftId}
GET /api/platforms
```
`PlanQueryService` должен читать:
- `planned_plan`;
- optionally latest attempt per plan;
- not `PlanRepository`.
Response можно оставить совместимым с `PlanResponse`, добавив минимум полей, если нужно:
- `planId`;
- `spacecraftId`;
- `startTime`;
- `endTime`;
- `kppId`;
- `status`.
**Вход шага:** persisted plans.
**Выход шага:** read-only query API.
**Существующие классы:** `PlanController`, `PlanQueryService`, `PlanResponse`.
**Новая модель:** maybe response fields updated.
**Слой:** controller/query.
### Шаг 10.2. Добавить `TestTguController`
Создать controller:
```kotlin
@ConditionalOnProperty(
prefix = "tgu.test-controller",
name = ["enabled"],
havingValue = "true"
)
@RestController
@RequestMapping("/test/tgu")
class TestTguController(...)
```
Default property must be false.
Перенести туда manual/debug операции:
```text
POST /test/tgu/spacecraft/{spacecraftId}/refresh-visibility
POST /test/tgu/spacecraft/{spacecraftId}/rebuild-plans
POST /test/tgu/plans/{planId}/issue
POST /test/tgu/plan-decisions
GET /test/tgu/external-points/{noradId}
```
Эти endpoints должны вызывать новые use cases, а не старый in-memory flow.
**Вход шага:** old manual controllers.
**Выход шага:** guarded test-only controller.
**Существующие классы:** `SatelliteMissionController`, `PlanConfirmationController`, `ExternalPointsController`.
**Новая модель:** `TestTguController`.
**Слой:** controller.
**Строгое правило:** production-flow не должен зависеть от test controller.
### Шаг 10.3. Убрать/отключить старые manual production endpoints
- `SatelliteMissionController` удалить или сделать inactive;
- `PlanConfirmationController.POST` удалить из production-flow или перенести в test controller;
- `ExternalPointsController` перенести в test controller, если он debug-only.
**Вход шага:** old controllers.
**Выход шага:** no production manual write endpoints.
**Слой:** cleanup/controller.
### Gate 10
К следующему этапу можно переходить только если:
- production write/manual endpoints removed or guarded;
- test controller disabled by default;
- plan query reads DB only.
---
## Этап 11. Config
### Цель этапа
Добавить свойства для новых topic'ов, reissue cutoff, worker lock и test controller.
### Шаг 11.1. Обновить `PlanningProperties`
Добавить:
```kotlin
val minReissueLeadMinutes: Long
val workerLockTtlSeconds: Long
```
Сохранить существующие свойства, если их используют новые workers:
- `notificationBeforeStartMinutes`;
- `targetPlanDurationMinutes`;
- `targetPlanDurationDeltaMinutes`;
- `externalUpdateEnabled`;
- `externalPointsLookaheadDays`;
- `externalUpdateFixedDelayMs`;
- `notificationCheckFixedDelayMs`.
`inMemoryHistoryDays` можно оставить временно, но production query не должен от него зависеть.
### Шаг 11.2. Обновить config files
В `services/pcp-tgu-service/src/main/resources/application.yml` и при необходимости `config-repo/pcp-tgu-service.yaml` добавить:
```yaml
topics:
visibility-windows-changed: pcp.tgu.visibility-windows-changed.v1
satellite-plan-decision: pcp.tgu.satellite-plan-decision.v1
planning:
min-reissue-lead-minutes: 5
worker-lock-ttl-seconds: 300
tgu:
test-controller:
enabled: false
```
Если в проекте используется kebab-case binding, имена должны соответствовать Kotlin properties.
### Gate 11
К следующему этапу можно переходить только если:
- приложение стартует с новыми properties;
- старый `spacecraft-points-update` topic не нужен production-flow;
- test controller disabled by default.
---
## Этап 12. Cleanup старого production-flow
### Цель этапа
Удалить или изолировать старые single-instance элементы.
### Шаг 12.1. Убрать production usage in-memory repositories
`PlanRepository` и `SpacecraftPointsRepository` не должны участвовать в production-flow.
Допустимые варианты:
1. удалить классы;
2. оставить только для старых тестов, но production services не inject'ят их;
3. перенести в test package, если нужно.
Строго проверить, что новые production classes не зависят от них.
### Шаг 12.2. Убрать production usage `PlanStatusWorker` / `PlanConfirmationService`
Если BPMN больше не ждёт accepted, `PlanStatusWorker` не должен быть активным production worker.
Допустимые варианты:
1. удалить;
2. выключить;
3. оставить только для test/legacy, если без этого быстрее пройти build.
Но production accepted/rejected должен идти через `PlanDecisionConsumer`.
### Шаг 12.3. Убрать direct recalculation из старого `SpacecraftPointsService`
Если `SpacecraftPointsService` остаётся, он не должен быть центральным production use case.
Новая цепочка:
```text
visibility changed -> refresh job -> raw snapshot -> recalculation job -> rebuild schedule
```
### Gate 12
К следующему этапу можно переходить только если:
- old in-memory repositories are not source of truth;
- old plan status BPMN worker not used in production;
- old direct `saveAndRecalculate` path not used by production Kafka/scheduler.
---
## Этап 13. Тесты
### Цель этапа
Покрыть новую state model и защитить ключевые scale-out инварианты.
### Шаг 13.1. Обновить unit tests алгоритма
Покрыть:
- `PlanCalculationService` возвращает `CalculatedPlan`;
- `CalculatedPlan` не содержит random `planId`;
- алгоритм выбора окон не изменился относительно старых expectations.
### Шаг 13.2. Покрыть visibility flow
Тесты:
- visibility changed event only enqueues refresh job;
- refresh worker stores full raw payload;
- same payload hash does not enqueue recalculation;
- changed payload hash enqueues recalculation.
### Шаг 13.3. Покрыть rebuild schedule
Тесты:
- upsert by business key keeps stable `planId`;
- repeated rebuild does not duplicate plans;
- obsolete future `PLANNED` plans become `SUPERSEDED`;
- `WAITING_DECISION`, `ISSUING`, `ACCEPTED`, `START_AMBIGUOUS` are not superseded by normal rebuild.
### Шаг 13.4. Покрыть issue worker
Тесты:
- due plan creates attempt;
- second call with active attempt does not create another active attempt for same spacecraft;
- repeated issue of same plan creates new `attemptId` and increments `attemptNo`;
- Camunda exception sets `AMBIGUOUS`/`START_AMBIGUOUS` and no auto retry happens.
### Шаг 13.5. Покрыть decision flow
Тесты:
- duplicate `eventId` ignored;
- `ACCEPTED` sets attempt accepted, plan accepted, current base plan, clears active ids, enqueues recalculation;
- `REJECTED` before cutoff sets same plan `PLANNED` and `issueNotBefore = now`;
- `REJECTED` after cutoff sets current plan `EXPIRED` and promotes next `PLANNED` plan;
- `REJECTED` after cutoff with no next plan enqueues recalculation;
- decision with mismatched `planId`/`attemptId` fails safely and does not corrupt state.
### Шаг 13.6. Покрыть query/test controller basics
Тесты:
- `PlanQueryService` reads `planned_plan`, not `PlanRepository`;
- test controller is disabled by default when property absent/false;
- test controller is enabled only when `tgu.test-controller.enabled=true`.
### Gate 13
К финальной проверке можно переходить только если:
- старые тесты либо обновлены, либо удалены как неактуальные;
- новые тесты покрывают acceptance criteria;
- нет тестов, которые закрепляют старый in-memory production-flow.
---
## Этап 14. Проверки и финальный отчёт агента
### Проверки
Агент обязан выполнить из корня репозитория:
```bash
./gradlew :services:pcp-tgu-service:test
./gradlew :services:pcp-tgu-service:build
```
Если это проходит и время позволяет:
```bash
./gradlew build
```
Если какая-то проверка не проходит по причине, не связанной с `pcp-tgu-service`, агент должен явно указать это в финальном отчёте и привести error summary.
### Финальный отчёт агента должен содержать
1. Что изменено по этапам.
2. Список ключевых новых классов.
3. Список удалённых/изолированных старых production-flow классов.
4. Новые таблицы и migration name.
5. Как теперь работает flow:
- NSI sync;
- visibility refresh;
- schedule rebuild;
- issue due plan;
- accepted/rejected decision.
6. Какие проверки запущены и их результат.
7. Что осталось follow-up.
---
## 7. Acceptance criteria
Задача считается выполненной для первого прогона, если выполнены все критерии ниже.
### State model
1. `pcp-tgu-service` больше не хранит production state планов в in-memory `PlanRepository`.
2. `pcp-tgu-service` больше не хранит production state окон видимости в in-memory `SpacecraftPointsRepository`.
3. Окна видимости сохраняются полностью как raw payload.
4. Один business-plan имеет стабильный `planId` через unique business key.
5. Повторная выдача того же business-plan создаёт новый `attemptId`, а не новый `planId`.
### Kafka
6. Kafka topic изменения окон только ставит refresh job.
7. Kafka topic решения плана обрабатывает `ACCEPTED` / `REJECTED` по `attemptId`.
8. Decision events дедуплицируются по `eventId`.
### Planning
9. Список аппаратов берётся из НСИ и сохраняется в `spacecraft_snapshot`.
10. Для active spacecraft создаются refresh jobs.
11. Расписание планов строится из persisted visibility snapshot.
12. Расписание планов сохраняется в `planned_plan`.
13. Rebuild не дублирует планы с тем же business key.
### Issuing
14. BPMN process = одна попытка выдачи плана.
15. BPMN больше не ждёт accepted/rejected.
16. Due plans выдаются через DB claim.
17. По одному КА не создаётся несколько active attempts одновременно.
18. Camunda/Zeebe call выполняется вне DB transaction.
19. Ambiguous start не ретраится автоматически.
### Decisions
20. `ACCEPTED` обновляет attempt, plan, current base plan и ставит recalculation job.
21. `REJECTED` before cutoff открывает тот же plan для новой попытки.
22. `REJECTED` after cutoff переводит текущий plan в `EXPIRED` и продвигает следующий plan.
23. Если решения нет, автоматической перевыдачи нет.
### API/config/tests
24. Production write/manual endpoints убраны или guarded.
25. Ручной функционал находится в `TestTguController` под `/test/tgu`.
26. `TestTguController` disabled by default.
27. Helm и CI/CD не изменены.
28. `./gradlew :services:pcp-tgu-service:test` проходит.
29. `./gradlew :services:pcp-tgu-service:build` проходит.
---
## 8. Follow-up после первого прогона
Не реализовывать в этом плане, но указать в финальном отчёте, если актуально:
1. Reconciliation для `START_AMBIGUOUS` через поиск процесса в Camunda/Zeebe по business key/attempt id.
2. Kafka retry/DLQ/error-handler hardening.
3. Observability: metrics, logs, alerts для stuck `WAITING_DECISION`, `START_AMBIGUOUS`, job failures.
4. JSONB вместо `TEXT payload_json`, если потребуется query inside payload.
5. Нормализация окон видимости в отдельные таблицы, если появятся SQL-запросы по окнам.
6. Timeout policy для отсутствующего decision event.
7. Outbox/event publication, если сервис позже начнёт сам публиковать доменные события.
8. Удаление legacy tables `tracked_plan`, если миграция данных больше не нужна.
9. Отдельные integration tests с PostgreSQL/Testcontainers для claim semantics.
---
## 9. Стиль реализации
Агент должен соблюдать стиль:
- простое явное Kotlin/Spring решение;
- не вводить лишние интерфейсы, фабрики, стратегии и adapters без необходимости;
- не делать архитектуру “на будущее”;
- comments оставлять у data classes, enum classes, JPA entities и у неочевидных бизнес-правил;
- не удалять комментарии у data classes/entities/enums только потому, что они выглядят очевидными;
- транзакционные границы делать явными;
- external side effects не выполнять внутри долгой DB transaction;
- если нужно выбрать между идеальным redesign и компилируемым минимально достаточным refactor — выбрать минимально достаточный refactor, сохранив acceptance criteria.
---
## 10. Короткая карта нового runtime flow
```text
NSI scheduler
-> SyncSpacecraftFromNsiUseCase
-> spacecraft_snapshot
-> visibility_refresh_job
Kafka visibility-windows-changed
-> VisibilityWindowsChangedConsumer
-> visibility_refresh_job
VisibilityRefreshWorker
-> claim visibility_refresh_job
-> ExternalPointsClient.fetchRawVisibilityPayload
-> spacecraft_visibility_snapshot
-> if payload changed: plan_recalculation_job
PlanRecalculationWorker
-> claim plan_recalculation_job
-> VisibilityPayloadParser
-> PlanCalculationService
-> upsert planned_plan
-> supersede obsolete future plans
IssueDuePlanWorker
-> claim due planned_plan
-> create plan_issue_attempt
-> BpmnPlanIssueProcessStarter
-> plan WAITING_DECISION
Kafka satellite-plan-decision
-> PlanDecisionConsumer
-> HandlePlanDecisionUseCase
-> ACCEPTED: base plan + recalculation
-> REJECTED before cutoff: same plan PLANNED again
-> REJECTED after cutoff: current EXPIRED + promote next
```