# План доработки `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`. ### Шаг 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`; - `calculatePlansAfter(...)` принимает previous plan как business fields. Можно временно принимать `CalculatedPlan` или отдельный lightweight type; - `calculateNearestPlanFrom(...)`, если сохраняется для test-only functionality, возвращает `CalculatedPlan?`; - private `StartCandidate.toPlan(...)` переименовать в `toCalculatedPlan(...)`. **Вход шага:** `SpacecraftPoints`. **Выход шага:** `List`. **Существующие классы:** `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 ```