From cbbafebf79bf75c10d5b54fb68b37c14ef220e50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Tue, 26 May 2026 11:07:37 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20pc?= =?UTF-8?q?p-tgu=20=D0=BD=D0=B0=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=9D?= =?UTF-8?q?=D0=A1=D0=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config-repo/application-docker-local.yaml | 5 +- config-repo/pcp-tgu-service.yaml | 5 +- services/pcp-tgu-service/platforms.json | 2 +- .../config/ClassifierProperties.kt | 5 +- .../controller/PlatformController.kt | 21 +- .../domain/PlatformCatalogItem.kt | 15 + .../pcp_tgu_service/dto/PlatformsDto.kt | 256 ++---------------- .../integration/api/ExternalPointsClient.kt | 27 +- .../api/PlatformsClassifierClient.kt | 10 +- .../service/PlatformService.kt | 55 +++- .../service/SatelliteMissionProcessService.kt | 4 +- .../api/ExternalPointsClientTest.kt | 110 ++++++++ .../api/PlatformsClassifierClientTest.kt | 89 ++++++ .../service/PlatformServiceTest.kt | 94 +++++++ .../SatelliteMissionProcessServiceTest.kt | 62 +++-- 15 files changed, 462 insertions(+), 298 deletions(-) create mode 100644 services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/PlatformCatalogItem.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClientTest.kt create mode 100644 services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlatformServiceTest.kt diff --git a/config-repo/application-docker-local.yaml b/config-repo/application-docker-local.yaml index 0ffcc24..0991144 100644 --- a/config-repo/application-docker-local.yaml +++ b/config-repo/application-docker-local.yaml @@ -65,7 +65,10 @@ planning: external-update-enabled: false classifier: - platforms-url: http://pcp-satellite-catalog-service:${pcp.ports.satellite-catalog} + platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records + allowed-platform-statuses: + - OPERATIONAL + - STANDBY tle: polling: diff --git a/config-repo/pcp-tgu-service.yaml b/config-repo/pcp-tgu-service.yaml index ea06150..912548f 100644 --- a/config-repo/pcp-tgu-service.yaml +++ b/config-repo/pcp-tgu-service.yaml @@ -38,7 +38,10 @@ topics: spacecraft-points-update: pcp.tgu.kpp-update.v1 classifier: - platforms-url: http://nsi-classifier-api.k8s.265.nstart.local/classifiers/platforms + platforms-url: https://ordinis.nstart.cloud/api/v1/spacecraft/records + allowed-platform-statuses: + - OPERATIONAL + - STANDBY --- spring: diff --git a/services/pcp-tgu-service/platforms.json b/services/pcp-tgu-service/platforms.json index 80efc4b..c40d331 100644 --- a/services/pcp-tgu-service/platforms.json +++ b/services/pcp-tgu-service/platforms.json @@ -1 +1 @@ -{"uuid":"17fe3173-9f64-4afe-856d-febdbce7774c","apiName":"platforms","guiName":"Космические аппараты","status":"ACTIVE","hierarchy":false,"validationRule":null,"fields":[{"uuid":"18775460-42ae-4b90-a444-d6d1e2d516c9","apiName":"name","guiName":"Название","status":"TEST","type":"TEXT","fkClassifierApiName":null,"fkFieldApiName":null},{"uuid":"b3e7e51a-f143-4b55-90ea-c018db6f5d1f","apiName":"umn","guiName":"УМН","status":"ACTIVE","type":"TEXT","fkClassifierApiName":null,"fkFieldApiName":null},{"uuid":"d783790b-772b-4637-b731-538a03c7444a","apiName":"platform_type_name","guiName":"Тип КА - название","status":"ACTIVE","type":"FK","fkClassifierApiName":"platform_types","fkFieldApiName":"name"},{"uuid":"315d7a11-e693-4026-958d-99a10eddcb4d","apiName":"platform_type_code","guiName":"Тип КА - код","status":"ACTIVE","type":"FK","fkClassifierApiName":"platform_types","fkFieldApiName":"code"},{"uuid":"a92431ba-02e8-4540-9cea-7240a79910dc","apiName":"description","guiName":"Описание","status":"DRAFT","type":"TEXT","fkClassifierApiName":null,"fkFieldApiName":null},{"uuid":"c71aacf6-9705-48b3-875d-c3db797663ed","apiName":"norad_id","guiName":"NORAD ID","status":"DRAFT","type":"BIGINT","fkClassifierApiName":null,"fkFieldApiName":null}],"content":[{"uuid":"1461d773-5008-465a-99ee-3f90aa3d2ae4","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"AI2T","norad_id":12345,"umn":"1","name":"Аист-2Т №1","description":null,"platform_type_name":"Аист-2Т"}},{"uuid":"9eb34926-abe7-4004-a36d-4cbab75207fd","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"AI2T","umn":"3","name":"Аист-2Т №3","description":null,"platform_type_name":"Аист-2Т"}},{"uuid":"86a6c49c-b15f-46e8-b454-d84f14424764","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"805","name":"БКА1","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"9def8050-acd3-4bbf-a27f-2f55c4ef2c12","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"OR","umn":"199","name":"Обзор-Р2","description":null,"platform_type_name":"Обзор-Р"}},{"uuid":"8d9c9b5b-bd29-46bb-90c1-d442b47ac5e4","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RDK","umn":"40","name":"Ресурс-ДК1","description":null,"platform_type_name":"Ресурс-ДК"}},{"uuid":"c3ed7943-bfb6-4e59-9618-554479bae65b","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"45","name":"Ресурс-П5","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"f3601a85-dc8c-4340-adbb-4f1d64f41794","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RPM","umn":"141","name":"Ресурс-ПМ1","description":null,"platform_type_name":"Ресурс-ПМ"}},{"uuid":"3e45ab01-eddb-40a5-82b5-bcec0e74a319","parentItemUuid":null,"status":"ARCHIVE","initiation":null,"expiration":null,"data":{"platform_type_code":"CNDR","umn":"69","name":"Кондор-ФКА №2","description":null,"platform_type_name":"Кондор-ФКА"}},{"uuid":"fa359247-e06e-43c2-a0ab-28055983ca66","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"315","name":"Канопус-В4","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"703adc6b-50a2-44ff-84a3-259dbecfc764","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"42","name":"Ресурс-П2","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"d0eb88b4-62b0-49f1-89e6-534766169306","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"36","name":"Метеор-М2-3","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"5341d9df-a407-4438-9758-c4b062e839c3","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"33","name":"Метеор-М2","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"5a5cd4f9-8377-4659-ad74-a464c3fd41fa","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"32","name":"Метеор-М1","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"6088ec48-0688-472c-9655-69b2f91b1677","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"34","name":"Метеор-М2-1","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"7bd0db12-6e9b-45fa-9c1a-f8b266e21d58","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"313","name":"Канопус-В6","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"9d693053-9fa5-4f51-8c9c-e9b758d56f36","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"35","name":"Метеор-М2-2","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"2024817b-cad8-4148-a2f8-fd7f1ba32bac","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"314","name":"Канопус-В3","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"1211c6c3-5144-4d6e-aff6-bc7561f46713","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"44","name":"Ресурс-П4","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"65e4c723-d406-4ece-9e72-53440a5b402c","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"41","name":"Ресурс-П1","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"429d4031-5c00-4f65-a5a8-07855cb07070","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"OR","umn":"121","name":"Обзор-Р1","description":null,"platform_type_name":"Обзор-Р"}},{"uuid":"a9ae7b76-6f7b-4c6b-b96b-ae9eceb4441e","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"316","name":"Канопус-В-ИК","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"a4660c7c-b004-44b1-a202-bdedf6d93085","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"MM","umn":"37","name":"Метеор-М2-4","description":null,"platform_type_name":"Метеор-М"}},{"uuid":"ec3d9cef-606d-4d2e-8a85-4d81f54fc986","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"806","name":"Канопус-В1","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"ee8ec1c5-15a8-4ac5-97c4-6bc5a73cbde3","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"RP","umn":"43","name":"Ресурс-П3","description":null,"platform_type_name":"Ресурс-П"}},{"uuid":"1216b167-c983-41a4-802b-94921913f162","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"CNDR","norad_id":56756,"umn":"68","name":"Кондор-ФКА №1","description":"Тестовый аппарат для стенда сквозного прогона","platform_type_name":"Кондор-ФКА"}},{"uuid":"fb104d60-9a6f-4347-8805-cde63952fc48","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"KV","umn":"312","name":"Канопус-В5","description":null,"platform_type_name":"Канопус-В"}},{"uuid":"707767c8-b68a-4ffd-baca-a51fe631d326","parentItemUuid":null,"status":"DRAFT","initiation":null,"expiration":null,"data":{"platform_type_code":"CNDR","norad_id":62138,"umn":"69","name":"Кондор-ФКА №2","description":"Тестовый аппарат для стенда сквозного прогона","platform_type_name":"Кондор-ФКА"}}],"page":{"size":1000,"number":0,"totalElements":27,"totalPages":1}} \ No newline at end of file +[{"id":"4ae3e206-03ee-410e-aae5-96eb69435557","businessKey":"2013-030A","data":{"name":"Resurs-P No.1","orbit":{"type":"SSO","inclination_deg":97.27},"status":"LOST","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":39186,"operator":"Roscosmos / RSC Progress","decay_date":"2021-12-19","designator":"2013-030A","launch_date":"2013-06-25","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-07T21:20:51.710841Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"7307c2f2-c92c-44fa-b864-10ffad2fe88c","businessKey":"2014-085A","data":{"name":"Resurs-P No.2","orbit":{"type":"SSO","inclination_deg":97.27},"status":"LOST","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":40360,"operator":"Roscosmos / RSC Progress","decay_date":"2022-04-13","designator":"2014-085A","launch_date":"2014-12-26","satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-07T21:20:51.710841Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"17080530-8d5b-47bd-ab9c-f7151d492578","businessKey":"2026-Emissio1","data":{"name":"Emissio 1","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":495,"norad_id":2,"operator":"НСТАРТ","designator":"2026-001E","spectrum_bands":["S_BAND"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T13:59:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"2bc048fc-7788-46fd-b368-37ee1b8b1a7a","businessKey":"2026-Reflexio1","data":{"name":"Reflexio 1","orbit":{"type":"SSO","inclination_deg":97.5},"status":"OPERATIONAL","country":"RU","mass_kg":495,"norad_id":30,"operator":"НСТАРТ","designator":"2026-001R","spectrum_bands":["S_BAND"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T13:59:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"933bbfd1-774b-440e-aa83-256936129e0a","businessKey":"2016-016A","data":{"name":"Resurs-P No.3","orbit":{"type":"SSO","inclination_deg":97.28},"status":"STANDBY","country":"RU","mass_kg":5920,"mission":"RESURS_P","norad_id":41386,"operator":"Roscosmos / RSC Progress","designator":"2016-016A","launch_date":"2016-03-13","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"da0bed3a-5557-452d-a235-53ca26080bca","businessKey":"2024-046A","data":{"name":"Resurs-P No.4","orbit":{"type":"SSO","inclination_deg":97.28},"status":"STANDBY","country":"RU","mass_kg":5920,"mission":"RESURS_P","operator":"Roscosmos / RSC Progress","designator":"2024-046A","launch_date":"2024-03-31","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"5293be80-4107-4bed-a8f1-4aeb8d833e03","businessKey":"1999-068A","data":{"name":"Terra (EOS AM-1)","orbit":{"type":"SSO","eccentricity":1.0E-4,"inclination_deg":98.2,"semimajor_axis_km":7083},"status":"STANDBY","country":"US","mass_kg":4864,"mission":"TERRA_AQUA","norad_id":25994,"operator":"NASA","designator":"1999-068A","launch_date":"1999-12-18","spectrum_bands":["VIS","NIR","SWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"1435cdcc-0b81-4761-9b4e-a4e991160156","businessKey":"2021-016A","data":{"name":"Arktika-M No.1","orbit":{"type":"HEO"},"status":"STANDBY","country":"RU","mass_kg":2200,"mission":"ARKTIKA_M","operator":"Roshydromet / Lavochkin Association","designator":"2021-016A","launch_date":"2021-02-28","spectrum_bands":["VIS","NIR","MWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:05:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"906b8317-72c7-436e-a582-d4e8df774430","businessKey":"2015-074A","data":{"name":"Elektro-L No.2","orbit":{"type":"GEO"},"status":"STANDBY","country":"RU","mass_kg":1740,"mission":"ELEKTRO_L","operator":"Roshydromet / Lavochkin Association","designator":"2015-074A","launch_date":"2015-12-11","spectrum_bands":["VIS","NIR","MWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"2cf4b26e-f44c-43eb-9155-9e412df8d0ba","businessKey":"2019-038A","data":{"name":"Meteor-M No.2-2","orbit":{"type":"SSO","inclination_deg":98.83},"status":"STANDBY","country":"RU","mass_kg":2900,"mission":"METEOR_M","operator":"Roshydromet / VNIIEM","designator":"2019-038A","launch_date":"2019-07-05","satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"ea2e466c-3c8a-4e2d-8c0e-d0634e7cda53","businessKey":"2014-037A","data":{"name":"Meteor-M No.2","orbit":{"type":"SSO","inclination_deg":98.86},"status":"STANDBY","country":"RU","mass_kg":2900,"mission":"METEOR_M","operator":"Roshydromet / VNIIEM","designator":"2014-037A","launch_date":"2014-07-08","spectrum_bands":["VIS","NIR","SWIR","TIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"99c1d6d2-0e0d-4cb5-a7a8-7e8f934e22c2","businessKey":"2017-076A","data":{"name":"Kanopus-V No.5 (Grand)","orbit":{"type":"SSO","inclination_deg":97.45},"status":"STANDBY","country":"RU","mass_kg":473,"mission":"KANOPUS_V","operator":"Roscosmos / VNIIEM","designator":"2017-076A","launch_date":"2017-12-01","satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"5381755f-2397-4863-b4f3-55301e73b27a","businessKey":"2012-039A","data":{"name":"Kanopus-V No.1","orbit":{"type":"SSO","inclination_deg":97.43},"status":"STANDBY","country":"RU","mass_kg":473,"mission":"KANOPUS_V","norad_id":38707,"operator":"Roscosmos / VNIIEM","designator":"2012-039A","launch_date":"2012-07-22","spectrum_bands":["VIS","NIR"],"satellite_type_code":"OPT_HIRES_VIS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"f0fc5aad-5b4a-49b0-9ee6-0ab79327a7e6","businessKey":"2024-149E","data":{"name":"Kondor-FKA No.2","orbit":{"type":"SSO","inclination_deg":97.5},"status":"STANDBY","country":"RU","mass_kg":1050,"mission":"KONDOR_FKA","operator":"Roscosmos","designator":"2024-149E","launch_date":"2024-08-13","spectrum_bands":["S_BAND"],"satellite_type_code":"SAR_X_HIRES"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:06:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"54fbf999-b1c0-4868-a51c-eb7191f1e867","businessKey":"2014-016A","data":{"name":"Sentinel-1A","orbit":{"type":"SSO","eccentricity":1.0E-4,"inclination_deg":98.18,"semimajor_axis_km":7064},"status":"STANDBY","country":"FR","mass_kg":2300,"mission":"SENTINEL_1","norad_id":39634,"operator":"ESA / Copernicus","designator":"2014-016A","launch_date":"2014-04-03","spectrum_bands":["C_BAND"],"satellite_type_code":"SAR_X_HIRES"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:07:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}},{"id":"d5cf2788-cec5-43a1-9bda-ab3e8ec5c4aa","businessKey":"2015-028A","data":{"name":"Sentinel-2A","orbit":{"type":"SSO","inclination_deg":98.62,"semimajor_axis_km":7167},"status":"STANDBY","country":"FR","mass_kg":1140,"mission":"SENTINEL_2","norad_id":40697,"operator":"ESA / Copernicus","designator":"2015-028A","launch_date":"2015-06-23","spectrum_bands":["VIS","NIR","SWIR"],"satellite_type_code":"OPT_MIDRES_MS"},"dataScope":"PUBLIC","validFrom":"2026-05-21T15:07:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US","defaultLocale":"ru-RU","asOf":"2026-05-25T14:37:45.200356844Z"}}] \ No newline at end of file diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt index 4a54e2b..5a734ef 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/config/ClassifierProperties.kt @@ -9,5 +9,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties data class ClassifierProperties( /** URL классификатора космических аппаратов. */ - val platformsUrl: String + val platformsUrl: String, + + /** Статусы КА из НСИ, с которыми сервису разрешено работать. */ + val allowedPlatformStatuses: Set ) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt index fb86c2f..0d17c5d 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/controller/PlatformController.kt @@ -1,7 +1,8 @@ package space.nstart.pcp_tgu_service.controller /** Содержит методы API только на чтение для платформ из внешнего классификатора. */ -import space.nstart.pcp_tgu_service.dto.ContentItemDTO +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem +import space.nstart.pcp_tgu_service.dto.PlatformResponse import space.nstart.pcp_tgu_service.service.PlatformService import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag @@ -25,14 +26,24 @@ class PlatformController( /** * Возвращает список космических аппаратов из внешнего классификатора. */ - /** Возвращает платформы с необязательной фильтрацией по типу и статусу. */ @GetMapping @Operation(summary = "Получить список космических аппаратов") fun getPlatforms( - @RequestParam(required = false) platformTypeCode: String?, @RequestParam(required = false) status: String? - ): Mono> = + ): Mono> = Mono.fromCallable { - platformService.loadPlatforms(platformTypeCode, status) + platformService.loadPlatforms(status).map { platform -> platform.toResponse() } }.subscribeOn(Schedulers.boundedElastic()) + + private fun PlatformCatalogItem.toResponse(): PlatformResponse = + PlatformResponse( + id = nsiId, + businessKey = businessKey, + name = name, + status = status, + noradId = noradId, + mission = mission, + validFrom = validFrom, + validTo = validTo + ) } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/PlatformCatalogItem.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/PlatformCatalogItem.kt new file mode 100644 index 0000000..6fd6dc5 --- /dev/null +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/domain/PlatformCatalogItem.kt @@ -0,0 +1,15 @@ +package space.nstart.pcp_tgu_service.domain + +import java.time.Instant + +/** Платформа КА из НСИ в минимальном виде, нужном pcp-tgu-service. */ +data class PlatformCatalogItem( + val nsiId: String, + val businessKey: String?, + val name: String?, + val status: String?, + val noradId: Long?, + val mission: String?, + val validFrom: Instant?, + val validTo: Instant? +) diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlatformsDto.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlatformsDto.kt index 201818b..3610697 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlatformsDto.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/dto/PlatformsDto.kt @@ -2,249 +2,35 @@ package space.nstart.pcp_tgu_service.dto import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.ObjectMapper +import java.time.Instant -/** - * Корневой DTO JSON-ответа. - * Соответствует всему объекту JSON. - */ +/** Платформа из root-array ответа НСИ. */ @JsonIgnoreProperties(ignoreUnknown = true) -data class PlatformsResponseDTO( - - /** - * UUID справочника. - */ - val uuid: String?, - - /** - * API имя сущности. - */ - val apiName: String?, - - /** - * GUI имя сущности. - */ - val guiName: String?, - - /** - * Статус справочника. - */ - val status: String?, - - /** - * Признак иерархии. - */ - val hierarchy: Boolean?, - - /** - * Правило валидации. - */ - val validationRule: String?, - - /** - * Список описаний полей справочника. - */ - val fields: List?, - - /** - * Основной список элементов. - */ - val content: List?, - - /** - * Информация о пагинации. - */ - val page: PageDTO? +data class NsiPlatformDto( + val id: String?, + val businessKey: String?, + val data: NsiPlatformDataDto?, + val validFrom: Instant?, + val validTo: Instant? ) -/** - * DTO описания одного поля. - */ +/** Согласованные поля объекта data платформы НСИ. */ @JsonIgnoreProperties(ignoreUnknown = true) -data class FieldDTO( - - /** - * UUID поля. - */ - val uuid: String?, - - /** - * API имя поля. - */ - val apiName: String?, - - /** - * GUI имя поля. - */ - val guiName: String?, - - /** - * Статус поля. - */ +data class NsiPlatformDataDto( + val name: String?, val status: String?, - - /** - * Тип поля. - */ - val type: String?, - - /** - * API имя связанного классификатора. - */ - val fkClassifierApiName: String?, - - /** - * API имя связанного поля. - */ - val fkFieldApiName: String? -) - -/** - * DTO одного элемента массива content. - */ -@JsonIgnoreProperties(ignoreUnknown = true) -data class ContentItemDTO( - - /** - * UUID записи. - */ - val uuid: String?, - - /** - * UUID родительского элемента. - */ - val parentItemUuid: String?, - - /** - * Статус записи. - */ - val status: String?, - - /** - * Дата начала действия. - */ - val initiation: String?, - - /** - * Дата окончания действия. - */ - val expiration: String?, - - /** - * Вложенный объект данных. - */ - val data: ContentDataDTO? -) - -/** - * DTO вложенного объекта data внутри content. - */ -@JsonIgnoreProperties(ignoreUnknown = true) -data class ContentDataDTO( - - /** - * Код типа платформы. - */ - @param:JsonProperty("platform_type_code") - val platformTypeCode: String?, - - /** - * NORAD ID. - */ @param:JsonProperty("norad_id") val noradId: Long?, + val mission: String? +) - /** - * Уникальный номер изделия (УМН). - */ - val umn: String?, - - /** - * Название аппарата. - */ +data class PlatformResponse( + val id: String, + val businessKey: String?, val name: String?, - - /** - * Описание аппарата. - */ - val description: String?, - - /** - * Название типа платформы. - */ - @param:JsonProperty("platform_type_name") - val platformTypeName: String? + val status: String?, + val noradId: Long?, + val mission: String?, + val validFrom: Instant?, + val validTo: Instant? ) - -/** - * DTO блока page (пагинация). - */ -@JsonIgnoreProperties(ignoreUnknown = true) -data class PageDTO( - - /** - * Размер страницы. - */ - val size: Int?, - - /** - * Номер страницы. - */ - val number: Int?, - - /** - * Общее количество элементов. - */ - val totalElements: Long?, - - /** - * Общее количество страниц. - */ - val totalPages: Int? -) - -/** - * Класс парсера JSON. - */ -class PlatformsJsonParser( - - /** - * ObjectMapper Jackson. - * Предоставляется Spring Boot автоматически. - */ - private val objectMapper: ObjectMapper -) { - - /** - * Парсит весь JSON в корневой DTO. - * - * @param json JSON строка - * @return PlatformsResponseDTO - */ - fun parse(json: String): PlatformsResponseDTO { - - /** - * Десериализация JSON -> Kotlin объект. - */ - return objectMapper.readValue(json, PlatformsResponseDTO::class.java) - } - - /** - * Парсит JSON и возвращает только массив content. - * - * @param json JSON строка - * @return список элементов content - */ - fun parseContent(json: String): List { - - /** - * Парсим корневой объект. - */ - val response = objectMapper.readValue(json, PlatformsResponseDTO::class.java) - - /** - * Если content отсутствует — возвращаем пустой список. - */ - return response.content.orEmpty() - } -} diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt index c16cf93..9a32255 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClient.kt @@ -5,8 +5,8 @@ import space.nstart.pcp_tgu_service.config.ExternalApiProperties import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.domain.InsertionPoint import space.nstart.pcp_tgu_service.domain.ObservationWindow +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.dto.ContentItemDTO import space.nstart.pcp_tgu_service.dto.ExternalRvaItemDto import space.nstart.pcp_tgu_service.service.PlatformService import org.slf4j.LoggerFactory @@ -39,16 +39,15 @@ class ExternalPointsClient( /** * Загружает окна видимости по всем КА из классификатора, - * у которых заполнен NORAD ID. + * которые разрешены для работы TGU. */ - /** Загружает окна видимости для всех КА с заполненным NORAD-идентификатором в классификаторе. */ fun fetchAllSpacecraftPoints(): List { val timeStart = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES) val timeStop = timeStart.plusDays(planningProperties.externalPointsLookaheadDays) - return platformService.loadPlatforms() + return platformService.loadAllowedPlatforms() .mapNotNull { platform -> - val noradId = platform.data?.noradId ?: return@mapNotNull null + val noradId = requireNotNull(platform.noradId) fetchSpacecraftPointsSafely( platform = platform, satelliteId = noradId, @@ -60,7 +59,7 @@ class ExternalPointsClient( /** Оборачивает загрузку по одному КА и подавляет транспортные ошибки только для него. */ private fun fetchSpacecraftPointsSafely( - platform: ContentItemDTO, + platform: PlatformCatalogItem, satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime @@ -138,19 +137,19 @@ class ExternalPointsClient( } private fun logExternalApiError( - platform: ContentItemDTO, + platform: PlatformCatalogItem, satelliteId: Long, timeStart: LocalDateTime, timeStop: LocalDateTime, ex: WebClientException ) { - val platformName = platform.data?.name - if (ex is WebClientResponseException) { log.warn( - "Skipping spacecraft due to external API response error. name={}, noradId={}, timeStart={}, timeStop={}, status={}, responseBody={}", - platformName, + "Skipping spacecraft due to external API response error. name={}, noradId={}, nsiId={}, businessKey={}, timeStart={}, timeStop={}, status={}, responseBody={}", + platform.name, satelliteId, + platform.nsiId, + platform.businessKey, timeStart, timeStop, ex.statusCode.value(), @@ -160,9 +159,11 @@ class ExternalPointsClient( } log.warn( - "Skipping spacecraft due to external API transport error. name={}, noradId={}, timeStart={}, timeStop={}, message={}", - platformName, + "Skipping spacecraft due to external API transport error. name={}, noradId={}, nsiId={}, businessKey={}, timeStart={}, timeStop={}, message={}", + platform.name, satelliteId, + platform.nsiId, + platform.businessKey, timeStart, timeStop, ex.message diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClient.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClient.kt index 8981fb9..8377293 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClient.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClient.kt @@ -2,7 +2,7 @@ package space.nstart.pcp_tgu_service.integration.api /** Вызывает сервис классификатора и возвращает сырые метаданные платформ. */ import space.nstart.pcp_tgu_service.config.ClassifierProperties -import space.nstart.pcp_tgu_service.dto.PlatformsResponseDTO +import space.nstart.pcp_tgu_service.dto.NsiPlatformDto import org.springframework.stereotype.Component import org.springframework.web.reactive.function.client.WebClient @@ -19,13 +19,13 @@ class PlatformsClassifierClient( .build() /** - * Загружает полный ответ классификатора платформ. + * Загружает список платформ из классификатора НСИ. */ - /** Загружает полный ответ классификатора вместе с содержимым платформ. */ - fun fetchPlatforms(): PlatformsResponseDTO = + fun fetchPlatforms(): List = webClient.get() .retrieve() - .bodyToMono(PlatformsResponseDTO::class.java) + .bodyToMono(Array::class.java) + .map { platforms -> platforms.toList() } .block() ?: throw IllegalStateException("Classifier response body is empty: ${classifierProperties.platformsUrl}") } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt index 2e29fb8..8ce9a99 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/PlatformService.kt @@ -1,29 +1,56 @@ package space.nstart.pcp_tgu_service.service /** Загружает метаданные платформ КА и применяет простую in-memory фильтрацию. */ -import space.nstart.pcp_tgu_service.dto.ContentItemDTO +import space.nstart.pcp_tgu_service.config.ClassifierProperties +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem +import space.nstart.pcp_tgu_service.dto.NsiPlatformDto import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import java.time.Instant @Service class PlatformService( - private val platformsClassifierClient: PlatformsClassifierClient + private val platformsClassifierClient: PlatformsClassifierClient, + private val classifierProperties: ClassifierProperties ) { - /** Возвращает платформы из классификатора с необязательной фильтрацией по коду типа и статусу. */ - fun loadPlatforms( - platformTypeCode: String? = null, - status: String? = null - ): List { + private val log = LoggerFactory.getLogger(javaClass) - val platforms = platformsClassifierClient.fetchPlatforms().content.orEmpty() + /** Возвращает платформы из классификатора с необязательной фильтрацией по статусу НСИ. */ + fun loadPlatforms(status: String? = null): List { + val platforms = platformsClassifierClient.fetchPlatforms() + .mapNotNull { platform -> platform.toCatalogItem() } - return platforms.filter { platform -> - val matchesPlatformTypeCode = platformTypeCode.isNullOrBlank() || - platform.data?.platformTypeCode == platformTypeCode - val matchesStatus = status.isNullOrBlank() || - platform.status == status + return platforms.filter { platform -> status.isNullOrBlank() || platform.status == status } + } - matchesPlatformTypeCode && matchesStatus + /** Возвращает платформы, с которыми TGU разрешено работать сейчас. */ + fun loadAllowedPlatforms(now: Instant = Instant.now()): List = + loadPlatforms().filter { platform -> + platform.noradId != null && + platform.status in classifierProperties.allowedPlatformStatuses && + platform.validFrom != null && + platform.validTo != null && + !platform.validFrom.isAfter(now) && + now.isBefore(platform.validTo) } + + private fun NsiPlatformDto.toCatalogItem(): PlatformCatalogItem? { + val nsiId = id + if (nsiId == null) { + log.warn("Skipping NSI platform without id. businessKey={}, noradId={}", businessKey, data?.noradId) + return null + } + + return PlatformCatalogItem( + nsiId = nsiId, + businessKey = businessKey, + name = data?.name, + status = data?.status, + noradId = data?.noradId, + mission = data?.mission, + validFrom = validFrom, + validTo = validTo + ) } } diff --git a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt index add14a9..88ade1f 100644 --- a/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt +++ b/services/pcp-tgu-service/src/main/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessService.kt @@ -151,8 +151,8 @@ class SatelliteMissionProcessService( /** Проверяет, что КА присутствует во внешнем классификаторе платформ. */ private fun ensurePlatformExists(noradId: Long) { /** Признак того, что платформа с указанным NORAD-идентификатором существует во внешнем классификаторе. */ - val platformExists: Boolean = platformService.loadPlatforms() - .any { platform -> platform.data?.noradId == noradId } + val platformExists: Boolean = platformService.loadAllowedPlatforms() + .any { platform -> platform.noradId == noradId } if (!platformExists) { throw ResponseStatusException( diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt new file mode 100644 index 0000000..034b8f0 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/ExternalPointsClientTest.kt @@ -0,0 +1,110 @@ +package space.nstart.pcp_tgu_service.integration.api + +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import space.nstart.pcp_tgu_service.config.ClassifierProperties +import space.nstart.pcp_tgu_service.config.ExternalApiProperties +import space.nstart.pcp_tgu_service.config.PlanningProperties +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem +import space.nstart.pcp_tgu_service.service.PlatformService +import java.net.InetSocketAddress +import java.time.Instant +import java.util.Collections + +class ExternalPointsClientTest { + + @Test + fun `fetchAllSpacecraftPoints uses allowed platforms and keeps noradId as spacecraftId`() { + val requestedPaths = Collections.synchronizedList(mutableListOf()) + + withServer(requestedPaths) { baseUrl -> + val client = ExternalPointsClient( + externalApiProperties = ExternalApiProperties(baseUrl = baseUrl), + platformService = FixedPlatformService( + listOf( + platform(nsiId = "allowed-1", noradId = 25544L), + platform(nsiId = "allowed-2", noradId = 41386L) + ) + ), + planningProperties = planningProperties() + ) + + val points = client.fetchAllSpacecraftPoints() + + assertEquals(1, points.size) + assertEquals("25544", points.single().spacecraftId) + assertEquals(listOf("7"), points.single().points.map { it.pointId }) + assertEquals(1, points.single().points.single().observationWindows.size) + assertEquals( + listOf( + "/api/satellites/25544/rva", + "/api/satellites/41386/rva" + ), + requestedPaths.map { path -> path.substringBefore("?") } + ) + } + } + + private class FixedPlatformService( + private val allowedPlatforms: List + ) : PlatformService( + mock(PlatformsClassifierClient::class.java), + ClassifierProperties( + platformsUrl = "http://localhost", + allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") + ) + ) { + override fun loadPlatforms(status: String?): List = + error("ExternalPointsClient must use loadAllowedPlatforms") + + override fun loadAllowedPlatforms(now: Instant): List = allowedPlatforms + } + + private fun withServer(requestedPaths: MutableList, block: (String) -> Unit) { + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/") { exchange -> + requestedPaths.add(exchange.requestURI.toString()) + val responseBody = when (exchange.requestURI.path) { + "/api/satellites/25544/rva" -> """[{"noradId":25544,"stationId":7,"revolution":1,"onStart":{"time":"2026-05-26T10:00:00Z","elevation":1.0},"onMaximum":{"time":"2026-05-26T10:05:00Z","elevation":45.0},"onStop":{"time":"2026-05-26T10:10:00Z","elevation":1.0}}]""" + else -> """{"error":"failed"}""" + } + val status = if (exchange.requestURI.path == "/api/satellites/25544/rva") 200 else 500 + val responseBytes = responseBody.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(status, responseBytes.size.toLong()) + exchange.responseBody.use { body -> body.write(responseBytes) } + } + server.start() + try { + block("http://localhost:${server.address.port}") + } finally { + server.stop(0) + } + } + + private fun platform(nsiId: String, noradId: Long): PlatformCatalogItem = + PlatformCatalogItem( + nsiId = nsiId, + businessKey = "business-$nsiId", + name = "Satellite $nsiId", + status = "OPERATIONAL", + noradId = noradId, + mission = "MISSION", + validFrom = Instant.parse("2026-01-01T00:00:00Z"), + validTo = Instant.parse("9999-12-31T23:59:59Z") + ) + + private fun planningProperties(): PlanningProperties = + PlanningProperties( + notificationBeforeStartMinutes = 30, + targetPlanDurationMinutes = 24 * 60L, + targetPlanDurationDeltaMinutes = 60L, + externalUpdateEnabled = false, + externalPointsLookaheadDays = 3, + inMemoryHistoryDays = 14, + externalUpdateFixedDelayMs = 0, + notificationCheckFixedDelayMs = 30_000 + ) +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClientTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClientTest.kt new file mode 100644 index 0000000..a688285 --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/integration/api/PlatformsClassifierClientTest.kt @@ -0,0 +1,89 @@ +package space.nstart.pcp_tgu_service.integration.api + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test +import space.nstart.pcp_tgu_service.config.ClassifierProperties +import space.nstart.pcp_tgu_service.dto.NsiPlatformDto +import java.net.InetSocketAddress +import java.nio.file.Files +import java.nio.file.Path + +class PlatformsClassifierClientTest { + + @Test + fun `sample platforms json is deserialized as root array and ignores unknown fields`() { + val objectMapper = jacksonObjectMapper().findAndRegisterModules() + val json = Files.readString(Path.of("platforms.json")) + + val platforms = objectMapper.readValue(json, Array::class.java).toList() + + val operationalPlatform = platforms.first { platform -> platform.data?.status == "OPERATIONAL" } + assertNotNull(operationalPlatform.id) + assertNotNull(operationalPlatform.businessKey) + assertNotNull(operationalPlatform.data?.name) + assertEquals("OPERATIONAL", operationalPlatform.data?.status) + assertNotNull(operationalPlatform.data?.noradId) + assertNotNull(operationalPlatform.validFrom) + assertNotNull(operationalPlatform.validTo) + } + + @Test + fun `fetchPlatforms reads root array response`() { + withServer("""[{"id":"nsi-id","businessKey":"business-key","data":{"name":"Satellite","status":"OPERATIONAL","norad_id":25544,"mission":"MISSION","orbit":{"type":"SSO"}},"validFrom":"2026-01-01T00:00:00Z","validTo":"9999-12-31T23:59:59Z","_meta":{"locale":"en-US"}}]""") { baseUrl -> + val client = PlatformsClassifierClient( + ClassifierProperties( + platformsUrl = baseUrl, + allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") + ) + ) + + val platforms = client.fetchPlatforms() + + assertEquals(1, platforms.size) + assertEquals("nsi-id", platforms.single().id) + assertEquals("business-key", platforms.single().businessKey) + assertEquals("Satellite", platforms.single().data?.name) + assertEquals("OPERATIONAL", platforms.single().data?.status) + assertEquals(25544L, platforms.single().data?.noradId) + assertEquals("MISSION", platforms.single().data?.mission) + } + } + + @Test + fun `fetchPlatforms throws clear error on empty body`() { + withServer("") { baseUrl -> + val client = PlatformsClassifierClient( + ClassifierProperties( + platformsUrl = baseUrl, + allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") + ) + ) + + val exception = assertThrows(IllegalStateException::class.java) { + client.fetchPlatforms() + } + + assertEquals("Classifier response body is empty: $baseUrl", exception.message) + } + } + + private fun withServer(responseBody: String, block: (String) -> Unit) { + val server = HttpServer.create(InetSocketAddress(0), 0) + server.createContext("/") { exchange -> + val responseBytes = responseBody.toByteArray() + exchange.responseHeaders.add("Content-Type", "application/json") + exchange.sendResponseHeaders(200, responseBytes.size.toLong()) + exchange.responseBody.use { body -> body.write(responseBytes) } + } + server.start() + try { + block("http://localhost:${server.address.port}") + } finally { + server.stop(0) + } + } +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlatformServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlatformServiceTest.kt new file mode 100644 index 0000000..676b5ae --- /dev/null +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/PlatformServiceTest.kt @@ -0,0 +1,94 @@ +package space.nstart.pcp_tgu_service.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import space.nstart.pcp_tgu_service.config.ClassifierProperties +import space.nstart.pcp_tgu_service.dto.NsiPlatformDataDto +import space.nstart.pcp_tgu_service.dto.NsiPlatformDto +import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient +import java.time.Instant + +class PlatformServiceTest { + + @Test + fun `loadPlatforms maps NSI DTO and filters by status`() { + val client = mock(PlatformsClassifierClient::class.java) + `when`(client.fetchPlatforms()).thenReturn( + listOf( + platformDto(id = "operational-id", status = "OPERATIONAL", noradId = 25544L), + platformDto(id = "standby-id", status = "STANDBY", noradId = 41386L), + platformDto(id = "lost-id", status = "LOST", noradId = 39186L), + platformDto(id = null, status = "OPERATIONAL", noradId = 2L) + ) + ) + val service = service(client) + + val allPlatforms = service.loadPlatforms() + val platforms = service.loadPlatforms(status = "STANDBY") + + assertEquals(listOf("operational-id", "standby-id", "lost-id"), allPlatforms.map { it.nsiId }) + assertEquals(1, platforms.size) + assertEquals("standby-id", platforms.single().nsiId) + assertEquals("business-standby-id", platforms.single().businessKey) + assertEquals("Satellite standby-id", platforms.single().name) + assertEquals("STANDBY", platforms.single().status) + assertEquals(41386L, platforms.single().noradId) + assertEquals("MISSION", platforms.single().mission) + assertEquals(Instant.parse("2026-01-01T00:00:00Z"), platforms.single().validFrom) + assertEquals(Instant.parse("2026-12-31T00:00:00Z"), platforms.single().validTo) + } + + @Test + fun `loadAllowedPlatforms uses configured statuses noradId and validity interval`() { + val now = Instant.parse("2026-05-26T12:00:00Z") + val client = mock(PlatformsClassifierClient::class.java) + `when`(client.fetchPlatforms()).thenReturn( + listOf( + platformDto(id = "operational-id", status = "OPERATIONAL", noradId = 1L), + platformDto(id = "standby-id", status = "STANDBY", noradId = 2L), + platformDto(id = "lost-id", status = "LOST", noradId = 3L), + platformDto(id = "without-norad-id", status = "OPERATIONAL", noradId = null), + platformDto(id = "future-id", status = "OPERATIONAL", noradId = 4L, validFrom = "2026-06-01T00:00:00Z"), + platformDto(id = "expired-id", status = "OPERATIONAL", noradId = 5L, validTo = "2026-05-26T12:00:00Z"), + platformDto(id = "without-valid-from", status = "OPERATIONAL", noradId = 6L, validFrom = null), + platformDto(id = "without-valid-to", status = "OPERATIONAL", noradId = 7L, validTo = null) + ) + ) + val service = service(client) + + val allowedPlatforms = service.loadAllowedPlatforms(now) + + assertEquals(listOf("operational-id", "standby-id"), allowedPlatforms.map { it.nsiId }) + } + + private fun service(client: PlatformsClassifierClient): PlatformService = + PlatformService( + platformsClassifierClient = client, + classifierProperties = ClassifierProperties( + platformsUrl = "http://localhost", + allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") + ) + ) + + private fun platformDto( + id: String?, + status: String, + noradId: Long?, + validFrom: String? = "2026-01-01T00:00:00Z", + validTo: String? = "2026-12-31T00:00:00Z" + ): NsiPlatformDto = + NsiPlatformDto( + id = id, + businessKey = id?.let { "business-$it" }, + data = NsiPlatformDataDto( + name = id?.let { "Satellite $it" }, + status = status, + noradId = noradId, + mission = "MISSION" + ), + validFrom = validFrom?.let(Instant::parse), + validTo = validTo?.let(Instant::parse) + ) +} diff --git a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt index 425a6f0..e25fa60 100644 --- a/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt +++ b/services/pcp-tgu-service/src/test/kotlin/space/nstart/pcp_tgu_service/service/SatelliteMissionProcessServiceTest.kt @@ -13,11 +13,11 @@ import org.mockito.Mockito.verify import org.mockito.Mockito.`when` import org.springframework.http.HttpStatus import org.springframework.web.server.ResponseStatusException +import space.nstart.pcp_tgu_service.config.ClassifierProperties import space.nstart.pcp_tgu_service.config.PlanningProperties import space.nstart.pcp_tgu_service.domain.Plan +import space.nstart.pcp_tgu_service.domain.PlatformCatalogItem import space.nstart.pcp_tgu_service.domain.SpacecraftPoints -import space.nstart.pcp_tgu_service.dto.ContentDataDTO -import space.nstart.pcp_tgu_service.dto.ContentItemDTO import space.nstart.pcp_tgu_service.dto.CreateSatelliteMissionVariables import space.nstart.pcp_tgu_service.dto.PointMessage import space.nstart.pcp_tgu_service.dto.SpacecraftPointsMessage @@ -28,6 +28,7 @@ import space.nstart.pcp_tgu_service.integration.api.PlatformsClassifierClient import space.nstart.pcp_tgu_service.repository.PlanRepository import space.nstart.pcp_tgu_service.repository.SpacecraftPointsRepository import space.nstart.pcp_tgu_service.repository.TrackedPlanJpaRepository +import java.time.Instant import java.time.LocalDateTime import java.util.UUID @@ -199,10 +200,36 @@ class SatelliteMissionProcessServiceTest { verify(planQueryService).reuseExistingPlan(waitingPlan) } + @Test + fun `startCreateSatelliteMissionManually returns not found when platform is not allowed`() { + val service = SatelliteMissionProcessService( + camundaClient = mock(CamundaClient::class.java), + platformService = FixedPlatformService(emptyList()), + spacecraftPointsService = FixedSpacecraftPointsService( + SpacecraftPointsMessage("25544", emptyList()) + ), + planCalculationService = mock(PlanCalculationService::class.java), + planQueryService = mock(PlanQueryService::class.java), + trackedPlanService = FixedTrackedPlanService() + ) + + val exception = assertThrows(ResponseStatusException::class.java) { + service.startCreateSatelliteMissionManually(25544L) + } + + assertEquals(HttpStatus.NOT_FOUND, exception.statusCode) + } + private class FixedPlatformService( - private val platforms: List - ) : PlatformService(mock(PlatformsClassifierClient::class.java)) { - override fun loadPlatforms(platformTypeCode: String?, status: String?): List = platforms + private val platforms: List + ) : PlatformService( + mock(PlatformsClassifierClient::class.java), + ClassifierProperties( + platformsUrl = "http://localhost", + allowedPlatformStatuses = setOf("OPERATIONAL", "STANDBY") + ) + ) { + override fun loadAllowedPlatforms(now: Instant): List = platforms } private class FixedSpacecraftPointsService( @@ -284,21 +311,16 @@ class SatelliteMissionProcessServiceTest { } } - private fun platform(noradId: Long): ContentItemDTO = - ContentItemDTO( - uuid = UUID.randomUUID().toString(), - parentItemUuid = null, - status = "ACTIVE", - initiation = null, - expiration = null, - data = ContentDataDTO( - platformTypeCode = null, - noradId = noradId, - umn = null, - name = "satellite", - description = null, - platformTypeName = null - ) + private fun platform(noradId: Long): PlatformCatalogItem = + PlatformCatalogItem( + nsiId = UUID.randomUUID().toString(), + businessKey = "business-key-$noradId", + name = "satellite", + status = "OPERATIONAL", + noradId = noradId, + mission = "MISSION", + validFrom = Instant.parse("2026-01-01T00:00:00Z"), + validTo = Instant.parse("9999-12-31T23:59:59Z") ) @Suppress("UNCHECKED_CAST")