Files
dc-observatio/services/pcp-tgu-service/pcptguplan.md
T
Дмитрий Соловьев 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

64 KiB
Raw Blame History

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

spacecraftId + startTime + endTime + kppId

должен иметь один persisted planId.

Повторная выдача того же business-plan должна создавать новый attemptId, а не новый planId.

3.3. BPMN = одна попытка выдачи

Целевой BPMN:

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

Текущие классы, на которые нужно ориентироваться:

services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service

Ключевые текущие файлы:

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:

V1__create_tracked_plan.sql
V2__extend_tracked_plan.sql
V3__create_spacecraft_planning_state.sql

Новая migration должна быть следующей по номеру, например:

V4__redesign_tgu_planning_state.sql

5. Целевая структура кода

Агент может выбрать небольшие вариации имён, но структура должна остаться близкой к этой.

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:

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

Добавить:

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

Создать:

services/pcp-tgu-service/src/main/resources/db/migration/V4__redesign_tgu_planning_state.sql

Migration должна:

  1. не удалять старые таблицы;
  2. создать новые таблицы;
  3. расширить spacecraft_planning_state.

Минимальная схема:

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 использовать паттерн:

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 использовать паттерн:

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:

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

Добавить метод:

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:

@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:

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

Заменить:

val spacecraftPointsUpdate: String

на:

val visibilityWindowsChanged: String
val satellitePlanDecision: String

Вход шага: текущий TopicsProperties.

Выход шага: новые topic properties.

Существующие классы: TopicsProperties.

Новая модель: none.

Слой: config.

Шаг 5.2. Добавить VisibilityWindowsChangedMessage

DTO:

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:

@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 добавить метод:

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:

startProcess(plan, attempt): Long processInstanceKey

Он должен:

  • получить PlannedPlanEntity и PlanIssueAttemptEntity;
  • заполнить CreateSatelliteMissionVariables;
  • вызвать Zeebe/Camunda create instance;
  • вернуть processInstanceKey.

BPMN variables:

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:

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:

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 должен включать:

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:

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:

@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

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:

now < plan.startTime - planning.minReissueLeadMinutes

Action:

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:

now >= plan.startTime - planning.minReissueLeadMinutes

Action:

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:

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:

@ConditionalOnProperty(
    prefix = "tgu.test-controller",
    name = ["enabled"],
    havingValue = "true"
)
@RestController
@RequestMapping("/test/tgu")
class TestTguController(...)

Default property must be false.

Перенести туда manual/debug операции:

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

Добавить:

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 добавить:

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.

Новая цепочка:

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. Проверки и финальный отчёт агента

Проверки

Агент обязан выполнить из корня репозитория:

./gradlew :services:pcp-tgu-service:test
./gradlew :services:pcp-tgu-service:build

Если это проходит и время позволяет:

./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

  1. Kafka topic изменения окон только ставит refresh job.
  2. Kafka topic решения плана обрабатывает ACCEPTED / REJECTED по attemptId.
  3. Decision events дедуплицируются по eventId.

Planning

  1. Список аппаратов берётся из НСИ и сохраняется в spacecraft_snapshot.
  2. Для active spacecraft создаются refresh jobs.
  3. Расписание планов строится из persisted visibility snapshot.
  4. Расписание планов сохраняется в planned_plan.
  5. Rebuild не дублирует планы с тем же business key.

Issuing

  1. BPMN process = одна попытка выдачи плана.
  2. BPMN больше не ждёт accepted/rejected.
  3. Due plans выдаются через DB claim.
  4. По одному КА не создаётся несколько active attempts одновременно.
  5. Camunda/Zeebe call выполняется вне DB transaction.
  6. Ambiguous start не ретраится автоматически.

Decisions

  1. ACCEPTED обновляет attempt, plan, current base plan и ставит recalculation job.
  2. REJECTED before cutoff открывает тот же plan для новой попытки.
  3. REJECTED after cutoff переводит текущий plan в EXPIRED и продвигает следующий plan.
  4. Если решения нет, автоматической перевыдачи нет.

API/config/tests

  1. Production write/manual endpoints убраны или guarded.
  2. Ручной функционал находится в TestTguController под /test/tgu.
  3. TestTguController disabled by default.
  4. Helm и CI/CD не изменены.
  5. ./gradlew :services:pcp-tgu-service:test проходит.
  6. ./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

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