diff --git a/editoast/openapi.yaml b/editoast/openapi.yaml index 7d7dcacb861..b4e170d184f 100644 --- a/editoast/openapi.yaml +++ b/editoast/openapi.yaml @@ -3290,6 +3290,53 @@ paths: type: array items: $ref: '#/components/schemas/Conflict' + /timetable/{id}/path_steps/local_track_names: + post: + tags: + - timetable + summary: |- + Retrieve list of local track names for each path step of each train schedule + that match the given operational point references list + parameters: + - name: id + in: path + description: A timetable ID + required: true + schema: + type: integer + format: int64 + - name: infra_id + in: query + required: true + schema: + type: integer + format: int64 + requestBody: + description: The list of operational point references to match + content: + application/json: + schema: + type: object + required: + - operational_point_references + properties: + operational_point_references: + type: array + items: + $ref: '#/components/schemas/OperationalPointReference' + required: true + responses: + '200': + description: For each operational point in the input list, returns the set of local track names found in the timetable's train schedules path steps + content: + application/json: + schema: + type: array + items: + type: array + items: + type: string + uniqueItems: true /timetable/{id}/requirements: get: tags: diff --git a/editoast/src/views/infra/mod.rs b/editoast/src/views/infra/mod.rs index d59ddcf1d12..8b0bcf48f93 100644 --- a/editoast/src/views/infra/mod.rs +++ b/editoast/src/views/infra/mod.rs @@ -662,7 +662,7 @@ pub(in crate::views) async fn unlock( #[derive(Deserialize, ToSchema)] #[cfg_attr(test, derive(Serialize))] pub(in crate::views) struct MatchOperationalPointsForm { - operational_point_references: Vec, + pub operational_point_references: Vec, } #[derive(Serialize, ToSchema)] diff --git a/editoast/src/views/mod.rs b/editoast/src/views/mod.rs index c8ad7ff5b68..2e678321bef 100644 --- a/editoast/src/views/mod.rs +++ b/editoast/src/views/mod.rs @@ -250,6 +250,9 @@ fn service_router() -> router::DocumentedRouter { .nests("/train_schedules", |path| { path.route("/", get!(timetable::get_train_schedules)) }) + .nests("/path_steps", |path| { + path.route("/local_track_names", post!(timetable::get_local_track_names)) + }) .nests("/round_trips", |path| { path.route("/train_schedules", get!(round_trips::list_train_schedules)) }) diff --git a/editoast/src/views/timetable.rs b/editoast/src/views/timetable.rs index b026b077e52..3c5a065c448 100644 --- a/editoast/src/views/timetable.rs +++ b/editoast/src/views/timetable.rs @@ -36,6 +36,7 @@ use core_client::simulation::PhysicsConsist; use database::DbConnection; use database::DbConnectionPoolV2; use editoast_derive::EditoastError; +use editoast_models::TrainScheduleException; use editoast_models::prelude::*; use editoast_models::timetable::Timetable; use editoast_models::timetable::TimetableWithTrains; @@ -45,6 +46,9 @@ use schemas::rolling_stock::RollingResistance; use schemas::rolling_stock::RollingStock; use schemas::rolling_stock::TowedRollingStock; use schemas::rolling_stock::{EtcsBrakeParams, LoadingGaugeType}; +use schemas::train_schedule::OperationalPointPartReference; +use schemas::train_schedule::OperationalPointReference; +use schemas::train_schedule::PathItemLocation; use schemas::train_schedule::TrainScheduleLike; use serde::Deserialize; use serde::Serialize; @@ -63,6 +67,8 @@ use super::path::pathfinding::PathfindingResult; use crate::AppState; use crate::error::Result; use crate::views::AuthenticationExt; +use crate::views::infra::MatchOperationalPointsForm; +use crate::views::path::operational_point_cache::OperationalPointCache; use crate::views::timetable::simulation::SimulationResponseSuccess; use editoast_models::Infra; use editoast_models::TrainScheduleSet; @@ -609,6 +615,140 @@ fn build_trains_requirements( ) } +/// Retrieve list of local track names for each path step of each train schedule +/// that match the given operational point references list +#[editoast_derive::route] +#[utoipa::path( + post, path = "", + tags = ["timetable"], + params(TimetableIdParam, InfraIdQueryParam), + request_body( + content = inline(MatchOperationalPointsForm), + description = "The list of operational point references to match", + ), + responses( + (status = 200, description = "For each operational point in the input list, returns the set of local track names found in the timetable's train schedules path steps", body = inline(Vec>)), + ), +)] +pub(in crate::views) async fn get_local_track_names( + State(AppState { db_pool, .. }): State, + Extension(auth): AuthenticationExt, + Path(TimetableIdParam { id: timetable_id }): Path, + Query(InfraIdQueryParam { infra_id }): Query, + Json(MatchOperationalPointsForm { + operational_point_references, + }): Json, +) -> Result>>> { + // Check user privilege on infra + auth.check_authorization(async |authorizer| { + authorizer + .authorize_infra(&authz::Infra(infra_id), authz::InfraPrivilege::CanRead) + .await + }) + .await?; + + let conn = &mut db_pool.get().await?; + + Timetable::exists_or_fail(conn, timetable_id, || TimetableError::NotFound { + timetable_id, + }) + .await?; + + let train_schedule_set_ids = + Timetable::get_train_schedule_set_ids_from_timetable(timetable_id, conn).await?; + + let train_schedules = editoast_models::TrainSchedule::list( + conn, + SelectionSettings::new().filter(move || { + editoast_models::TrainSchedule::TRAIN_SCHEDULE_SET_ID + .eq_any(train_schedule_set_ids.clone()) + }), + ) + .await?; + + let train_ids = train_schedules.iter().map(|ts| ts.id).collect::>(); + + let mut exceptions = TrainScheduleException::retrieve_exceptions_by_train_schedules( + conn, + timetable_id, + &train_ids, + ) + .await? + .into_iter() + .map_into::() + .into_group_map_by(|e| e.train_schedule_id); + + // Collect all occurrences from all trains + let train_occurrences = train_schedules.iter().flat_map(|train| { + train + .iter_occurrences(&exceptions.remove(&train.id).unwrap_or_default()) + .collect::>() + }); + + // Collect local_track_names and deduplicated path locations from train occurrences. + let mut local_track_names: HashMap> = HashMap::new(); + let mut seen_op_refs: HashSet = HashSet::new(); + let mut unique_locations: Vec = Vec::new(); + + train_occurrences + .flat_map(|(_, occurrence)| occurrence.path.into_iter()) + .filter_map(|path_item| match path_item.location { + PathItemLocation::OperationalPointPartReference(op_ref) => Some(op_ref), + _ => None, + }) + .for_each(|op_ref| { + if let Some(name) = &op_ref.local_track_name { + local_track_names + .entry(op_ref.operational_point.clone()) + .or_default() + .insert(name.0.clone()); + } + if seen_op_refs.insert(op_ref.operational_point.clone()) { + unique_locations.push(PathItemLocation::OperationalPointPartReference(op_ref)); + } + }); + // Also include the requested op_refs so the cache resolves them even if no train uses them. + for op_ref in &operational_point_references { + if seen_op_refs.insert(op_ref.clone()) { + unique_locations.push(PathItemLocation::OperationalPointPartReference( + OperationalPointPartReference { + operational_point: op_ref.clone(), + local_track_name: None, + }, + )); + } + } + + let path_items: Vec<&PathItemLocation> = unique_locations.iter().collect(); + let op_cache = + OperationalPointCache::load_path_items(db_pool.get().await?, infra_id, &path_items).await?; + + // Resolve op_ref to op id, merging sets when multiple refs point to the same OP. + let op_id_to_local_track_names: HashMap> = local_track_names + .iter() + .filter_map(|(op_ref, names)| { + let op = op_cache.get_reference(op_ref.clone())?; + Some((op.id.0.clone(), names)) + }) + .fold(HashMap::new(), |mut map, (id, names)| { + map.entry(id).or_default().extend(names.iter().cloned()); + map + }); + + let result: Vec> = operational_point_references + .iter() + .map(|op_ref| match op_cache.get_reference(op_ref.clone()) { + Some(op) => op_id_to_local_track_names + .get(&op.id.0) + .cloned() + .unwrap_or_default(), + None => local_track_names.get(op_ref).cloned().unwrap_or_default(), + }) + .collect(); + + Ok(Json(result)) +} + #[derive(Serialize, ToSchema)] #[cfg_attr(test, derive(Deserialize))] pub(in crate::views) struct TrainRequirementsPage { @@ -940,10 +1080,15 @@ mod tests { use pretty_assertions::assert_eq; use schemas::fixtures::simple_rolling_stock; use schemas::fixtures::towed_rolling_stock; + use schemas::rolling_stock::RollingResistance; + use schemas::train_schedule::PathItem; + use schemas::train_schedule::ScheduleItem; use super::*; use crate::error::InternalError; + use crate::fixtures::create_simple_paced_train; + use crate::fixtures::create_small_infra; use crate::fixtures::create_timetable; use crate::fixtures::create_timetable_with_train_schedule_set; use crate::fixtures::create_train_schedule_exception; @@ -1491,4 +1636,96 @@ mod tests { .json_into(); assert_eq!(train_schedule_sets, vec![train_schedule_set]); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn test_get_local_track_names_simple_train_schedule() { + let app = TestAppBuilder::default_app(); + let db_pool = app.db_pool(); + + let small_infra = create_small_infra(&mut db_pool.get_ok()).await; + + let (timetable, train_schedule_set) = + create_timetable_with_train_schedule_set(&mut db_pool.get().await.unwrap()).await; + let _train_schedule = + create_simple_paced_train(&mut db_pool.get().await.unwrap(), train_schedule_set.id) + .await; + let mut train_schedule_base_2 = simple_paced_train_base(); + train_schedule_base_2.train_occurrence.path = vec![ + PathItem { + id: "Mid_West_station".into(), + location: PathItemLocation::OperationalPointPartReference( + OperationalPointPartReference { + operational_point: OperationalPointReference::Id { + operational_point: "Mid_West_station".into(), + }, + local_track_name: Some("West_1".into()), + }, + ), + }, + PathItem { + id: "Mid_East_station".into(), + location: PathItemLocation::OperationalPointPartReference( + OperationalPointPartReference { + operational_point: OperationalPointReference::Id { + operational_point: "Mid_East_station".into(), + }, + local_track_name: Some("East_1".into()), + }, + ), + }, + ]; + train_schedule_base_2.train_occurrence.schedule = vec![ScheduleItem::new_with_stop( + "Mid_West_station", + Duration::seconds(0), + )]; + + let _train_schedule_2 = + Changeset::::from(train_schedule_base_2) + .train_schedule_set_id(train_schedule_set.id) + .create(&mut db_pool.get().await.unwrap()) + .await + .expect("Failed to create paced train"); + + let form = MatchOperationalPointsForm { + operational_point_references: vec![ + // MWS is the trigram of Mid_West_station — tests cross-reference resolution + OperationalPointReference::Trigram { + trigram: "MWS".into(), + secondary_code: Some("BV".into()), + }, + OperationalPointReference::Uic { + uic: 8711, + secondary_code: Some("BV".into()), + }, + OperationalPointReference::Id { + operational_point: "Mid_East_station".into(), + }, + ], + }; + + let request = app + .post( + format!( + "/timetable/{}/path_steps/local_track_names?infra_id={}", + timetable.id, small_infra.id + ) + .as_str(), + ) + .json(&form); + + let response: Vec> = app + .fetch(request) + .await + .assert_status(StatusCode::OK) + .json_into(); + + assert_eq!( + response, + vec![ + HashSet::from(["West_1".to_string()]), + HashSet::new(), + HashSet::from(["East_1".to_string()]), + ] + ); + } } diff --git a/front/public/locales/en/operational-studies.json b/front/public/locales/en/operational-studies.json index dd17bc0cbe6..725c48a9dac 100644 --- a/front/public/locales/en/operational-studies.json +++ b/front/public/locales/en/operational-studies.json @@ -426,6 +426,7 @@ "moveLocationOnMap": "Move location on the map", "movePointOnMap": "Move this waypoint using the map", "next": "Next", + "noComputation": "there will be no computation for this train", "opId": "Operational point identifier", "opName": "Operational point name", "opType": "Type", diff --git a/front/public/locales/fr/operational-studies.json b/front/public/locales/fr/operational-studies.json index f63f47f1d3b..4c086fe57ba 100644 --- a/front/public/locales/fr/operational-studies.json +++ b/front/public/locales/fr/operational-studies.json @@ -426,6 +426,7 @@ "moveLocationOnMap": "Déplacer le point remarquable sur la carte", "movePointOnMap": "Déplacez ce point en utilisant la carte", "next": "Suivant", + "noComputation": "il n'y aura pas de calcul pour ce train", "opId": "Identifiant de point", "opName": "Nom du point remarquable", "opType": "Type", diff --git a/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/ItineraryModal.tsx b/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/ItineraryModal.tsx index 54258d06365..c7f1ed2bcf8 100644 --- a/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/ItineraryModal.tsx +++ b/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/ItineraryModal.tsx @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import useCategoryColors from 'applications/operationalStudies/hooks/useCategoryColors'; +import useLocalTrackNames from 'applications/operationalStudies/hooks/useLocalTrackNames'; import { useManageTrainScheduleContext } from 'applications/operationalStudies/hooks/useManageTrainScheduleContext'; import { useOperationalPointSearch } from 'applications/operationalStudies/hooks/useOperationalPointSearch'; import { useScenarioContext } from 'applications/operationalStudies/hooks/useScenarioContext'; @@ -18,6 +19,7 @@ import type { PathItemLocation, TrainCategory, } from 'common/api/osrdEditoastApi'; +import { osrdEditoastApi } from 'common/api/osrdEditoastApi'; import Banner from 'common/Banner'; import { computeBBoxViewport } from 'common/Map/WarpedMap/core/helpers'; import { useInfraID } from 'common/osrdContext'; @@ -35,7 +37,7 @@ import { getPathSteps, getRollingStockName, } from 'reducers/osrdconf/operationalStudiesConf/selectors'; -import type { PathStep, PathStepMetadata, PathStepV2 } from 'reducers/osrdconf/types'; +import type { PathStep, PathStepMetadata, PathStepV2, TrainScheduleToEditData } from 'reducers/osrdconf/types'; import { useAppDispatch } from 'store'; import { addElementAtIndex } from 'utils/array'; import { Duration } from 'utils/duration'; @@ -61,6 +63,7 @@ type ItineraryModalProps = { itineraryModalIsOpen: boolean; setItineraryModalIsOpen: (isOpen: boolean) => void; displayTrainScheduleManagement: string; + trainScheduleToEditData?: TrainScheduleToEditData; }; export type ItineraryModalFormState = { @@ -75,13 +78,14 @@ const ItineraryModal = ({ itineraryModalIsOpen, setItineraryModalIsOpen, displayTrainScheduleManagement, + trainScheduleToEditData, }: ItineraryModalProps) => { const { t } = useTranslation('operational-studies', { keyPrefix: 'manageTrainSchedule.itineraryModal', }); const storePathSteps = useSelector(getPathSteps); const category = useSelector(getCategory); - const { workerStatus } = useScenarioContext(); + const { workerStatus, timetableId } = useScenarioContext(); const rollingStockId = useSelector(getOperationalStudiesRollingStockID); const rollingStockName = useSelector(getRollingStockName); const name = useSelector(getName); @@ -150,9 +154,59 @@ const ItineraryModal = ({ const { launchPathfinding } = useManageTrainScheduleContext(); const { pathStepsMetadataById } = usePathStepsMetadata(pathSteps, pendingStepIdRef); + + // Build the trainPath to pass to useLocalTrackNames (same format as TrainSchedule['path']) + const trainPath = useMemo( + () => + pathSteps.reduce<{ id: string; location: PathItemLocation }[]>((acc, step) => { + if (step.location) { + acc.push({ id: step.id, location: step.location }); + } + return acc; + }, []), + [pathSteps] + ); + + const localTrackNamesByStepId = useLocalTrackNames(infraId!, timetableId, trainPath); const { launchPathfindingV2, pathProperties, pathfindingError } = usePathfindingV2(); const { convertFeatureClickToLocation } = useMapTrackSelection(infraId); + /** + * When a custom track name is added (one that doesn't exist in the OP parts), + * immediately update the train schedule's path via API to persist the local_track_name. + */ + const updateTrainSchedulePathTrackName = useCallback( + async (stepId: string, trackName: string) => { + if (!trainScheduleToEditData) return; + + const { trainScheduleId } = trainScheduleToEditData; + + // GET the current train schedule fresh from the DB to avoid overwriting anything + const { id: _, train_schedule_set_id: __, ...currentData } = await dispatch( + osrdEditoastApi.endpoints.getTrainSchedulesById.initiate( + { id: trainScheduleId }, + { subscribe: false } + ) + ).unwrap(); + + // Only update the local_track_name on the matching path step + await dispatch( + osrdEditoastApi.endpoints.putTrainSchedulesById.initiate({ + id: trainScheduleId, + trainSchedule: { + ...currentData, + path: currentData.path.map((item) => + item.id === stepId && item.location.type !== 'track_offset' + ? { ...item, location: { ...item.location, local_track_name: trackName || undefined } } + : item + ), + }, + }) + ).unwrap(); + }, + [dispatch, trainScheduleToEditData] + ); + const applyOperationalPointToStep = ( stepId: string, suggestion: OperationalPointSuggestion, @@ -546,12 +600,12 @@ const ItineraryModal = ({ {rollingStockMessage && } {hasInvalidPathStepDisplay && (
- +
)} {!hasInvalidPathStepDisplay && pathfindingError && (
- +
)} {submitAttempted && @@ -625,6 +679,7 @@ const ItineraryModal = ({ pathStep={pathStep} setPathSteps={setPathSteps} pathStepMetadata={pathStepMetadata} + extraTrackNames={localTrackNamesByStepId.get(pathStep.id)} index={i + 1} categoryColors={categoryColors} hidePathfindingLine={ @@ -678,6 +733,9 @@ const ItineraryModal = ({ }) ); }} + onAddCustomTrackName={(trackName) => { + updateTrainSchedulePathTrackName(pathStep.id, trackName); + }} onOpBlur={() => { // If the user focuses out on an input with a valid op, we display the last valid op of this input (or empty) const valueOnFocus = focusValueRef.current[pathStep.id]; diff --git a/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/PathStepItem.tsx b/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/PathStepItem.tsx index 49e6b4bd597..80434ee9d48 100644 --- a/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/PathStepItem.tsx +++ b/front/src/applications/operationalStudies/views/Scenario/components/ManageTrainSchedule/Itinerary/PathStepItem.tsx @@ -1,6 +1,6 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; -import { ComboBox, Select, SegmentedControl } from '@osrd-project/ui-core'; +import { ComboBox, SegmentedControl } from '@osrd-project/ui-core'; import { AddedLocation, AddLocation, @@ -33,11 +33,13 @@ type PathStepProps = { pathStep: PathStepV2; setPathSteps?: React.Dispatch>; pathStepMetadata: PathStepMetadata | undefined; + extraTrackNames?: string[]; index: number; hidePathfindingLine: boolean; categoryColors: CategoryColors; onOpInputChange: (value: string) => void; onTrackNameChange: (trackName: string) => void; + onAddCustomTrackName?: (trackName: string) => void; onOpFocus: () => void; onOpBlur: () => void; inputValue: string | undefined; @@ -61,11 +63,13 @@ const PathStepItem = ({ pathStep, setPathSteps, pathStepMetadata, + extraTrackNames, index, hidePathfindingLine, categoryColors, onOpInputChange, onTrackNameChange, + onAddCustomTrackName, onOpFocus, onOpBlur, inputValue, @@ -110,11 +114,8 @@ const PathStepItem = ({ return (message += t('requestedPoint')); } - const trackInfo = - location && location.local_track_name ? `, ${t('track')} ${location.local_track_name}` : ''; - if (location?.operational_point.type === 'id') { - return (message += t('opId') + trackInfo); + return (message += t('opId')); } const secondaryCodeInfo = @@ -130,7 +131,7 @@ const PathStepItem = ({ message += t('uic') + ' ' + location.operational_point.uic; } - return message + secondaryCodeInfo + trackInfo; + return message + secondaryCodeInfo; }; const selectedSecondaryCodeOption = useMemo(() => { @@ -146,11 +147,10 @@ const PathStepItem = ({ const selectedSecondaryCode = selectedSecondaryCodeOption.id; if (!selectedSecondaryCode || !isOpRefMetadata(pathStepMetadata)) return []; - const sortedSuggestions = (pathStepMetadata?.parts || []) - .map((part, i) => ({ - label: part.trackName, - id: `${part.trackId}-${i}`, - })) + // Track name suggestions come from the new endpoint (local track names from all trains in the timetable) + const suggestions = (extraTrackNames ?? []) + .filter((name) => !!name) + .map((name) => ({ label: name, id: `track-${name}` })) // Sort with numbers first in ascending order, then alphabetically .sort((a, b) => { const isANumber = !isNaN(Number(a.label)); @@ -166,20 +166,32 @@ const PathStepItem = ({ return a.label.localeCompare(b.label); } }); - return [{ label: '', id: '' }, ...sortedSuggestions]; - }, [pathStepMetadata, selectedSecondaryCodeOption.id]); + return [{ label: '', id: '' }, ...suggestions]; + }, [pathStepMetadata, selectedSecondaryCodeOption.id, extraTrackNames]); + + const [filteredTrackSuggestions, setFilteredTrackSuggestions] = useState(trackNameSuggestions); + + useEffect(() => { + setFilteredTrackSuggestions(trackNameSuggestions); + }, [trackNameSuggestions]); const selectedTrackNameOption = useMemo(() => { + // When OP is invalid but has a local_track_name, show it + if (pathStepMetadata?.isInvalid && pathStepMetadata.localTrackName) { + return { label: pathStepMetadata.localTrackName, id: pathStepMetadata.localTrackName }; + } + // No track should be selected if the path step is invalid or has no secondary code // or is a step added by map click - if (!isOpRefMetadata(pathStepMetadata) || !pathStepMetadata.trackName) { return EMPTY_OPTION; } return ( - trackNameSuggestions.find((track) => track.label === pathStepMetadata.trackName) || - EMPTY_OPTION + trackNameSuggestions.find((track) => track.label === pathStepMetadata.trackName) || { + label: pathStepMetadata.trackName, + id: pathStepMetadata.trackName, + } ); }, [pathStepMetadata, trackNameSuggestions]); @@ -293,6 +305,7 @@ const PathStepItem = ({ className={cx('path-step-wrapper', { 'is-placeholder': isTrailingPlaceHolder, 'map-selection-active': isMapSelectionMode, + 'is-invalid': isInvalidAndIsEditing, })} >
-