# План доработки `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
```
