From 1d438d782fbadbdf64f077694e66e8e583fb1bde Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 3 Aug 2026 11:26:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E6=9C=AA=E8=A8=AD=E5=AE=9A=E3=81=AE?= =?UTF-8?q?=E9=89=84=E9=81=93=E8=B7=AF=E7=B7=9A=E3=81=AB=E5=90=84=E5=81=9C?= =?UTF-8?q?=E7=A8=AE=E5=88=A5=E3=82=92=E8=A3=9C=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + stationapi/src/import.rs | 258 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 67127edb..c309f7ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ This guide explains how automation agents and human contributors should work wit - **Lines** – `GetLineById`, `GetLinesByIdList`, `GetLinesByName`. Results include company data and computed line symbols based on repository helpers. - **Routes** – `GetRoutes`, `GetRoutesMinimal`. The minimal variant returns `RouteMinimalResponse` with deduplicated `LineMinimal` data; paging tokens are currently empty (pagination not implemented). - **Train types** – `GetTrainTypesByStationId`, `GetRouteTypes`. Train types aggregate by line group and include related lines plus optional train type metadata. Rail variants use `TrainTypeKind::{Default, Branch, Rapid, Express, LimitedExpress, HighSpeedRapid, CommuterRapid}` (0-6); bus variants use `BusRoute` (7), which represents a `(route_id, shape_id)` operation pattern (e.g. 循環 / 短ターン / 支線) generated automatically from the configured GTFS bus feeds (Toei Bus, Seibu Bus, Keio Bus) and the converted Tokyu Bus JSON. +- **Default rail train types** – After the canonical CSV import, startup fills every active rail line containing at least one station with no `station_station_types` row with a deterministic, complete all-stop group. The generated rows exist only in PostgreSQL; canonical CSV files remain unchanged. `type_cd=100` represents 「普通」 and `type_cd=101` represents 「各駅停車」. An existing 100/101 assignment on the line takes precedence; otherwise startup selects the label per line through `LOCAL_SERVICE_RAIL_LINE_IDS` in `src/import.rs`. Generated `line_group_cd` values use `1,000,000,000 + line_cd`; startup fails on a collision. Bus lines are excluded and continue to use their GTFS-derived `BusRoute` groups. - **GTFS bus integration** – At startup, `src/import.rs::integrate_gtfs_to_stations()` ingests GTFS feeds into `gtfs_*` tables and then projects them onto the shared `stations` / `lines` / `types` / `station_station_types` tables. Every configured GTFS feed is imported, including Seibu Bus and Keio Bus (both downloaded from ODPT with `ODPT_ACCESS_TOKEN`). Tokyu Bus ordinary-route `BusroutePattern`, `BusstopPole`, and `BusTimetable` JSON are converted into the same `gtfs_*` representation; pattern IDs become `shape_id` values so route variants remain queryable as bus TrainTypes. The Tokyu-operated Ota, Shinagawa, and Meguro community buses use their official GTFS feeds and matching JSON routes are excluded to prevent duplicates. `ODPT_ACCESS_TOKEN` is required for authenticated sources. Stops whose Tokyu JSON records omit coordinates remain available to name and route queries but not coordinate searches. `transport_type` (0: rail, 1: bus) on both `stations` and `lines` keeps rail and bus records queryable side by side. GTFS IDs are namespaced per feed before import to avoid cross-operator collisions. `line_cd` (100,000,000+), `station_cd` / `station_g_cd` (200,000,000+), and bus `type_cd` / `line_group_cd` (100,000,000+) are all deterministic fnv1a hashes that stay clear of the rail data ranges. Disable the entire bus pipeline with `DISABLE_BUS_FEATURE=true`. - **Bus stop translations (readings & English)** – GTFS-JP `translations.txt` layouts differ per feed, so `load_gtfs_translations` resolves columns by header name (Seibu ships 6 columns without `record_sub_id`; Keio and the Tokyu community feeds ship 7) and indexes each `stop_name` translation under both keys it may use: `record_id` (== the stop_id, Seibu — with the "-NN" pole suffix also mapped to the parent stop_id) and `field_value` (== the Japanese stop_name, Keio / Tokyu community, where `record_id` is left empty). `import_gtfs_stops` then looks a stop's translation up by stop_id first, then by name. Keying only by `record_id` (the previous behavior) silently dropped every field_value-keyed feed, leaving `station_name_k` filled with the kanji stop_name and `station_name_r` empty. Readings arriving as half-width katakana (`ニシハチオウジ`, Keio / Tokyu community) are folded to full-width via `romaji::to_fullwidth_katakana()` before storage. - **Bus English-name fallback** – When a feed provides no English (`en`) translation for a stop — e.g. Tokyu Bus ordinary-route JSON, which carries only `dc:title` and `odpt:kana` — `src/domain/romaji.rs::romaji_display_name()` derives a modified-Hepburn romanization (with macrons for long vowels, matching the curated rail style: Tōkyō / Kyōto / Shin-Ōsaka) from the kana reading, and `import.rs` fills `stop_name_r` with it. The fallback never overwrites a real `en` value, and a reading with no convertible kana stays `NULL` rather than emitting a partial transcription. Because `stop_name_r` is the single upstream source that fans out into the `stations` projection, `search_by_name`, and the romanized bus route/headsign names, this supplements every English-facing surface at once. When projecting into `stations`, `station_name_rn` is filled with the plain-ASCII spelling via `romaji::strip_macrons()` (Tōkyō → Tokyo), mirroring the rail dataset's `_r` (macron) / `_rn` (macron-free) column pair. diff --git a/stationapi/src/import.rs b/stationapi/src/import.rs index 7a313527..429cca21 100644 --- a/stationapi/src/import.rs +++ b/stationapi/src/import.rs @@ -93,6 +93,29 @@ const PERFORMANCE_INDEXES: &[(&str, &str)] = &[ ), ]; +const DEFAULT_RAIL_TYPE_CD: i32 = 100; +const LOCAL_RAIL_TYPE_CD: i32 = 101; +const VIRTUAL_RAIL_LINE_GROUP_BASE: i32 = 1_000_000_000; + +/// Lines whose operator-facing local-service label is 「各駅停車」 rather than +/// 「普通」. Every other uncovered rail line uses `DEFAULT_RAIL_TYPE_CD`. +/// +/// This is intentionally line-based instead of company-based: operators such as +/// JR East use both labels depending on the line. Keep this list in sync when a +/// newly-added line uses 「各駅停車」 as its Japanese service name. +const LOCAL_SERVICE_RAIL_LINE_IDS: &[i32] = &[ + 11309, // JR East Sagami Line + 11318, // JR East Hachiko Line + 11345, // Disney Resort Line + 99101, // Sapporo Subway Tozai Line + 99102, // Sapporo Subway Namboku Line + 99103, // Sapporo Subway Toho Line + 99301, // Toei Oedo Line + 99305, // Tokyo Sakura Tram + 99342, // Nippori-Toneri Liner + 99649, // Rokko Liner +]; + /// Create required extensions and tables before running data imports. /// Must be called before `import_csv` and `import_gtfs` can run in parallel. pub async fn create_schema() -> Result<(), Box> { @@ -251,6 +274,8 @@ pub async fn import_csv() -> Result<(), Box> { .await?; } + generate_virtual_local_rail_services(&mut conn).await?; + sqlx::query("ANALYZE;").execute(&mut conn).await?; info!("CSV import completed successfully."); @@ -258,6 +283,127 @@ pub async fn import_csv() -> Result<(), Box> { Ok(()) } +/// Fill rail lines containing at least one station with no train type with one +/// deterministic, complete local service. The canonical CSV files remain +/// untouched; generated rows live only in the database rebuilt at startup. +/// +/// `station_station_types.id` determines stop order in repository queries, so +/// the INSERT is ordered by line and station order. Bus lines are excluded both +/// at the line and station level and continue to be managed by GTFS integration. +async fn generate_virtual_local_rail_services( + conn: &mut PgConnection, +) -> Result<(), Box> { + let missing_type_ids: Vec = sqlx::query_scalar( + "SELECT required.type_cd + FROM unnest($1::int[]) AS required(type_cd) + WHERE NOT EXISTS ( + SELECT 1 FROM types AS t WHERE t.type_cd = required.type_cd + ) + ORDER BY required.type_cd", + ) + .bind(vec![DEFAULT_RAIL_TYPE_CD, LOCAL_RAIL_TYPE_CD]) + .fetch_all(&mut *conn) + .await?; + if !missing_type_ids.is_empty() { + return Err(format!( + "cannot generate virtual rail services: missing types.type_cd values {missing_type_ids:?}" + ) + .into()); + } + + let colliding_group_ids: Vec = sqlx::query_scalar( + "WITH lines_needing_local_service AS ( + SELECT l.line_cd + FROM lines AS l + WHERE l.e_status = 0 + AND l.transport_type = 0 + AND EXISTS ( + SELECT 1 FROM stations AS s + WHERE s.line_cd = l.line_cd + AND s.e_status = 0 + AND s.transport_type = 0 + AND NOT EXISTS ( + SELECT 1 FROM station_station_types AS sst + WHERE sst.station_cd = s.station_cd + ) + ) + ) + SELECT DISTINCT sst.line_group_cd + FROM lines_needing_local_service AS ul + JOIN station_station_types AS sst + ON sst.line_group_cd = $1 + ul.line_cd + ORDER BY sst.line_group_cd", + ) + .bind(VIRTUAL_RAIL_LINE_GROUP_BASE) + .fetch_all(&mut *conn) + .await?; + if !colliding_group_ids.is_empty() { + return Err(format!( + "cannot generate virtual rail services: line_group_cd collision for {colliding_group_ids:?}" + ) + .into()); + } + + let result = sqlx::query( + "WITH lines_needing_local_service AS MATERIALIZED ( + SELECT + l.line_cd, + COALESCE( + ( + SELECT sst.type_cd + FROM stations AS typed_station + JOIN station_station_types AS sst + ON sst.station_cd = typed_station.station_cd + WHERE typed_station.line_cd = l.line_cd + AND typed_station.e_status = 0 + AND typed_station.transport_type = 0 + AND sst.type_cd IN ($2, $3) + GROUP BY sst.type_cd + ORDER BY COUNT(*) DESC, sst.type_cd + LIMIT 1 + ), + CASE + WHEN l.line_cd = ANY($1::int[]) THEN $3 + ELSE $2 + END + ) AS type_cd, + $4 + l.line_cd AS line_group_cd + FROM lines AS l + WHERE l.e_status = 0 + AND l.transport_type = 0 + AND EXISTS ( + SELECT 1 FROM stations AS s + WHERE s.line_cd = l.line_cd + AND s.e_status = 0 + AND s.transport_type = 0 + AND NOT EXISTS ( + SELECT 1 FROM station_station_types AS sst + WHERE sst.station_cd = s.station_cd + ) + ) + ) + INSERT INTO station_station_types (station_cd, type_cd, line_group_cd, pass) + SELECT s.station_cd, ul.type_cd, ul.line_group_cd, 0 + FROM lines_needing_local_service AS ul + JOIN stations AS s ON s.line_cd = ul.line_cd + WHERE s.e_status = 0 + AND s.transport_type = 0 + ORDER BY ul.line_cd, s.e_sort, s.station_cd", + ) + .bind(LOCAL_SERVICE_RAIL_LINE_IDS) + .bind(DEFAULT_RAIL_TYPE_CD) + .bind(LOCAL_RAIL_TYPE_CD) + .bind(VIRTUAL_RAIL_LINE_GROUP_BASE) + .execute(&mut *conn) + .await?; + + info!( + "Generated {} virtual local-service station rows for rail lines with untyped stations.", + result.rows_affected() + ); + Ok(()) +} + /// Represents a translation entry from translations.txt #[derive(Debug, Clone, Default)] struct Translation { @@ -4383,6 +4529,118 @@ mod tests { assert_eq!(parse_gtfs_time("00:00:01"), Some("00:00:01".to_string())); } + #[tokio::test] + #[cfg_attr(not(feature = "integration-tests"), ignore)] + async fn test_generate_virtual_local_rail_services_covers_partial_lines_and_excludes_bus() { + use sqlx::{Executor, Row}; + + let mut conn = stop_route_mapping_fixtures::open_conn().await; + let schema = stop_route_mapping_fixtures::unique_schema_name(); + conn.execute(format!("CREATE SCHEMA \"{schema}\"").as_str()) + .await + .expect("create schema"); + conn.execute(format!("SET search_path TO \"{schema}\"").as_str()) + .await + .expect("set search_path"); + conn.execute( + r#" + CREATE TABLE types (type_cd INTEGER PRIMARY KEY); + CREATE TABLE lines ( + line_cd INTEGER PRIMARY KEY, + e_status INTEGER NOT NULL, + transport_type INTEGER NOT NULL + ); + CREATE TABLE stations ( + station_cd INTEGER PRIMARY KEY, + line_cd INTEGER NOT NULL, + e_status INTEGER NOT NULL, + transport_type INTEGER NOT NULL, + e_sort INTEGER NOT NULL + ); + CREATE TABLE station_station_types ( + id SERIAL PRIMARY KEY, + station_cd INTEGER NOT NULL, + type_cd INTEGER NOT NULL, + line_group_cd INTEGER NOT NULL, + pass INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO types VALUES (100), (101), (555); + INSERT INTO lines VALUES + (11309, 0, 0), + (500, 0, 0), + (600, 0, 0), + (700, 0, 1); + INSERT INTO stations VALUES + (1, 11309, 0, 0, 1), + (2, 11309, 0, 0, 2), + (3, 11309, 0, 0, 3), + (4, 500, 0, 0, 1), + (5, 500, 0, 0, 2), + (6, 600, 0, 0, 1), + (7, 600, 0, 0, 2), + (8, 700, 0, 1, 1); + INSERT INTO station_station_types + (station_cd, type_cd, line_group_cd, pass) + VALUES + (1, 555, 50, 0), + (4, 100, 60, 0), + (6, 100, 70, 0), + (7, 100, 70, 0); + "#, + ) + .await + .expect("create virtual rail service fixtures"); + + generate_virtual_local_rail_services(&mut conn) + .await + .expect("generate virtual rail services"); + generate_virtual_local_rail_services(&mut conn) + .await + .expect("second generation is idempotent"); + + let generated = sqlx::query( + "SELECT station_cd, type_cd, line_group_cd, pass + FROM station_station_types + WHERE line_group_cd >= $1 + ORDER BY line_group_cd, id", + ) + .bind(VIRTUAL_RAIL_LINE_GROUP_BASE) + .fetch_all(&mut conn) + .await + .expect("read generated rows"); + let actual: Vec<(i32, i32, i32, i32)> = generated + .iter() + .map(|row| { + ( + row.get("station_cd"), + row.get("type_cd"), + row.get("line_group_cd"), + row.get("pass"), + ) + }) + .collect(); + + assert_eq!( + actual, + vec![ + (4, 100, VIRTUAL_RAIL_LINE_GROUP_BASE + 500, 0), + (5, 100, VIRTUAL_RAIL_LINE_GROUP_BASE + 500, 0), + (1, 101, VIRTUAL_RAIL_LINE_GROUP_BASE + 11309, 0), + (2, 101, VIRTUAL_RAIL_LINE_GROUP_BASE + 11309, 0), + (3, 101, VIRTUAL_RAIL_LINE_GROUP_BASE + 11309, 0), + ] + ); + + let bus_generated: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM station_station_types WHERE station_cd = 8") + .fetch_one(&mut conn) + .await + .expect("count bus rows"); + assert_eq!(bus_generated, 0); + + stop_route_mapping_fixtures::drop_schema(&mut conn, &schema).await; + } + // ============================================================================ // build_stop_route_mapping regression tests // From 50066db56b572018dd46600aeb3b3a242f8c5045 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 3 Aug 2026 11:31:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?Rust=201.97=E3=81=AEClippy=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stationapi/src/use_case/interactor/query.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 7836f833..5c79052e 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -1206,7 +1206,7 @@ where let params = EstimationParams::default(); let mut result: Vec = Vec::new(); - for (_line_group_cd, group_stops) in route_row_tree_map.iter() { + for group_stops in route_row_tree_map.values() { // 先頭駅が末尾にも重複格納された「閉じた」環状データ(ポートライナー等)は、 // そのままだとラップ時に閉じ駅が二重になるため重複終端を除いてから // 環状判定・弧選択する。 From b0c63cc8b20bb3a306c029ebde4b15340ad53c70 Mon Sep 17 00:00:00 2001 From: Tsubasa SEKIGUCHI Date: Mon, 3 Aug 2026 12:04:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E5=88=97=E8=BB=8A=E7=A8=AE=E5=88=A5?= =?UTF-8?q?=E3=81=AA=E3=81=97=E3=81=AE=E7=B5=8C=E8=B7=AF=E5=88=86=E5=B2=90?= =?UTF-8?q?=E3=82=92=E5=89=8A=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/infrastructure/station_repository.rs | 194 +++--------------- stationapi/src/use_case/interactor/query.rs | 90 +++----- 2 files changed, 60 insertions(+), 224 deletions(-) diff --git a/stationapi/src/infrastructure/station_repository.rs b/stationapi/src/infrastructure/station_repository.rs index 99868af0..44147cfd 100644 --- a/stationapi/src/infrastructure/station_repository.rs +++ b/stationapi/src/infrastructure/station_repository.rs @@ -12,11 +12,6 @@ use crate::{ proto::StopCondition, }; -#[derive(sqlx::FromRow)] -struct TrainTypesCountRow { - train_types_count: Option, -} - #[derive(sqlx::FromRow, Clone)] struct StationRow { pub station_cd: i32, @@ -322,25 +317,13 @@ impl StationRepository for MyStationRepository { direction_id: Option, ) -> Result, DomainError> { let mut conn = self.pool.acquire().await?; - match station_id { - Some(station_id) => { - InternalStationRepository::get_by_line_id_and_station_id( - line_id, - station_id, - direction_id, - &mut conn, - ) - .await - } - None => { - InternalStationRepository::get_by_line_id_without_train_types( - line_id, - direction_id, - &mut conn, - ) - .await - } - } + InternalStationRepository::get_by_line_id_with_train_type( + line_id, + station_id, + direction_id, + &mut conn, + ) + .await } async fn get_by_line_id_vec(&self, line_ids: &[u32]) -> Result, DomainError> { let mut conn = self.pool.acquire().await?; @@ -481,28 +464,6 @@ impl StationRepository for MyStationRepository { struct InternalStationRepository {} impl InternalStationRepository { - async fn fetch_has_local_train_types_by_station_id( - id: u32, - conn: &mut PgConnection, - ) -> Result { - let row: TrainTypesCountRow = sqlx::query_as!( - TrainTypesCountRow, - "SELECT COUNT(sst.line_group_cd)::integer AS train_types_count - FROM station_station_types AS sst - JOIN types AS t ON t.type_cd = sst.type_cd - WHERE sst.station_cd = $1 - AND ( - t.kind IN (0, 1) - OR t.priority > 0 - )", - id as i32, - ) - .fetch_one(conn) - .await?; - - Ok(row.train_types_count.unwrap_or(0) > 0) - } - async fn find_by_id(id: u32, conn: &mut PgConnection) -> Result, DomainError> { let rows: Option = sqlx::query_as!( StationRow, @@ -707,99 +668,6 @@ impl InternalStationRepository { Ok(stations) } - async fn get_by_line_id_without_train_types( - line_id: u32, - direction_id: Option, - conn: &mut PgConnection, - ) -> Result, DomainError> { - // When direction_id = 1 (上り) or 2 (下り), reverse the order - let order_clause = if matches!(direction_id, Some(1) | Some(2)) { - "ORDER BY s.e_sort DESC, s.station_cd DESC" - } else { - "ORDER BY s.e_sort ASC, s.station_cd ASC" - }; - - let query_str = format!( - r#"SELECT - s.station_cd, - s.station_g_cd, - s.station_name, - s.station_name_k, - s.station_name_r, - s.station_name_rn, - s.station_name_zh, - s.station_name_ko, - s.station_number1, - s.station_number2, - s.station_number3, - s.station_number4, - s.three_letter_code, - s.line_cd, - s.pref_cd, - s.post, - s.address, - s.lon, - s.lat, - s.open_ymd, - s.close_ymd, - s.e_status, - s.e_sort, - l.company_cd, - COALESCE(NULLIF(COALESCE(a.line_name, l.line_name), ''), NULL) AS line_name, - COALESCE(NULLIF(COALESCE(a.line_name_k, l.line_name_k), ''), NULL) AS line_name_k, - COALESCE(NULLIF(COALESCE(a.line_name_h, l.line_name_h), ''), NULL) AS line_name_h, - COALESCE(NULLIF(COALESCE(a.line_name_r, l.line_name_r), ''), NULL) AS line_name_r, - COALESCE(NULLIF(COALESCE(a.line_name_zh, l.line_name_zh), ''), NULL) AS line_name_zh, - COALESCE(NULLIF(COALESCE(a.line_name_ko, l.line_name_ko), ''), NULL) AS line_name_ko, - COALESCE(NULLIF(COALESCE(a.line_color_c, l.line_color_c), ''), NULL) AS line_color_c, - l.line_type, - l.line_symbol1, - l.line_symbol2, - l.line_symbol3, - l.line_symbol4, - l.line_symbol1_color, - l.line_symbol2_color, - l.line_symbol3_color, - l.line_symbol4_color, - l.line_symbol1_shape, - l.line_symbol2_shape, - l.line_symbol3_shape, - l.line_symbol4_shape, - COALESCE(l.average_distance, 0.0)::DOUBLE PRECISION AS average_distance, - NULL::int AS type_id, - NULL::int AS sst_id, - NULL::int AS type_cd, - NULL::int AS line_group_cd, - NULL::int AS pass, - NULL::text AS type_name, - NULL::text AS type_name_k, - NULL::text AS type_name_r, - NULL::text AS type_name_zh, - NULL::text AS type_name_ko, - NULL::text AS color, - NULL::int AS direction, - NULL::int AS kind, - s.transport_type - FROM stations AS s - JOIN lines AS l ON l.line_cd = s.line_cd - LEFT JOIN line_aliases AS la ON la.station_cd = s.station_cd - LEFT JOIN aliases AS a ON a.id = la.alias_cd - WHERE l.line_cd = $1 - AND s.e_status = 0 - AND l.e_status = 0 - {order_clause}"# - ); - - let rows = sqlx::query_as::<_, StationRow>(&query_str) - .bind(line_id as i32) - .fetch_all(conn) - .await?; - - let stations: Vec = rows.into_iter().map(|row| row.into()).collect(); - - Ok(stations) - } - async fn get_by_line_id_vec( line_ids: &[u32], conn: &mut PgConnection, @@ -1002,31 +870,27 @@ impl InternalStationRepository { Ok(stations) } - async fn get_by_line_id_and_station_id( + async fn get_by_line_id_with_train_type( line_id: u32, - station_id: u32, + station_id: Option, direction_id: Option, conn: &mut PgConnection, ) -> Result, DomainError> { - let stations: Vec = match Self::fetch_has_local_train_types_by_station_id( - station_id, conn, - ) - .await? - { - true => { - // When direction_id = 1 (上り) or 2 (下り), reverse the order - let order_clause = if matches!(direction_id, Some(1) | Some(2)) { - "ORDER BY sst.id DESC" - } else { - "ORDER BY sst.id ASC" - }; - - let query_str = format!( - r#"WITH target_line_group AS ( + // When direction_id = 1 (上り) or 2 (下り), reverse the order + let order_clause = if matches!(direction_id, Some(1) | Some(2)) { + "ORDER BY sst.id DESC" + } else { + "ORDER BY sst.id ASC" + }; + + let query_str = format!( + r#"WITH target_line_group AS ( SELECT sst_inner.line_group_cd FROM station_station_types AS sst_inner LEFT JOIN types AS t_inner ON sst_inner.type_cd = t_inner.type_cd - WHERE sst_inner.station_cd = $1 + JOIN stations AS seed_station ON seed_station.station_cd = sst_inner.station_cd + WHERE seed_station.line_cd = $1 + AND ($2::int IS NULL OR sst_inner.station_cd = $2) AND ( (t_inner.priority > 0 AND sst_inner.pass <> 1 AND sst_inner.type_cd = t_inner.type_cd) OR (NOT (t_inner.priority > 0 AND sst_inner.pass <> 1) AND t_inner.kind IN (0,1)) @@ -1102,16 +966,14 @@ impl InternalStationRepository { WHERE s.e_status = 0 AND l.e_status = 0 {order_clause}"# - ); + ); - let rows = sqlx::query_as::<_, StationRow>(&query_str) - .bind(station_id as i32) - .fetch_all(conn) - .await?; - rows.into_iter().map(|row| row.into()).collect() - } - false => Self::get_by_line_id_without_train_types(line_id, direction_id, conn).await?, - }; + let rows = sqlx::query_as::<_, StationRow>(&query_str) + .bind(line_id as i32) + .bind(station_id.map(|id| id as i32)) + .fetch_all(conn) + .await?; + let stations = rows.into_iter().map(|row| row.into()).collect(); Ok(stations) } diff --git a/stationapi/src/use_case/interactor/query.rs b/stationapi/src/use_case/interactor/query.rs index 5c79052e..f639cc07 100644 --- a/stationapi/src/use_case/interactor/query.rs +++ b/stationapi/src/use_case/interactor/query.rs @@ -1037,31 +1037,13 @@ where to_station_id: u32, line_group_id: Option, ) -> Result, UseCaseError> { - // line_group_id 未指定は種別なし(各駅停車)の単一路線走行。 - // from駅の所属路線の駅列をそのまま経路として扱う。 - let stations = match line_group_id { - Some(line_group_id) => { - self.get_stations_by_line_group_id(line_group_id, TransportTypeFilter::RailAndBus) - .await? - } - None => { - let from_station = self - .station_repository - .find_by_id(from_station_id) - .await? - .ok_or_else(|| UseCaseError::NotFound { - entity_type: "station", - entity_id: from_station_id.to_string(), - })?; - self.get_stations_by_line_id( - from_station.line_cd as u32, - None, - None, - TransportTypeFilter::RailAndBus, - ) - .await? - } - }; + let line_group_id = line_group_id.ok_or_else(|| UseCaseError::NotFound { + entity_type: "line group", + entity_id: "unspecified".to_string(), + })?; + let stations = self + .get_stations_by_line_group_id(line_group_id, TransportTypeFilter::RailAndBus) + .await?; let from_idx = stations .iter() @@ -1609,17 +1591,22 @@ where } fn build_route_tree_map<'a>(&self, stops: &'a [Station]) -> BTreeMap> { - stops.iter().fold( - BTreeMap::new(), - |mut acc: BTreeMap>, value| { - if let Some(line_group_cd) = value.line_group_cd { - acc.entry(line_group_cd).or_default().push(value); - } else { - acc.entry(value.line_cd).or_default().push(value); - }; - acc - }, - ) + stops + .iter() + .map(|stop| { + ( + stop.line_group_cd + .expect("route stop must belong to a train type group"), + stop, + ) + }) + .fold( + BTreeMap::new(), + |mut acc: BTreeMap>, (line_group_cd, stop)| { + acc.entry(line_group_cd).or_default().push(stop); + acc + }, + ) } fn build_station_from_row( @@ -2701,7 +2688,7 @@ mod tests { data.iter() .enumerate() .map(|(i, &(cd, lat, lon))| { - let mut s = create_test_station(cd, 9930100, 99301, None); + let mut s = create_test_station(cd, 9930100, 99301, Some(9930100)); s.lat = lat; s.lon = lon; s.e_sort = 9930101 + i as i32; @@ -2780,7 +2767,7 @@ mod tests { data.iter() .enumerate() .map(|(i, &(cd, lat, lon))| { - let mut s = create_test_station(cd, cd, 28008, None); + let mut s = create_test_station(cd, cd, 28008, Some(2800800)); s.lat = lat; s.lon = lon; s.e_sort = 2800801 + i as i32; @@ -2799,7 +2786,8 @@ mod tests { /// 修正前は種別倍率(×1.15)が掛かり駅間別較正も外れて約15分に縮んでいた。 #[tokio::test] async fn test_estimate_route_arrival_times_through_express_all_stops_matches_local() { - let local = build_interactor(hanzomon_stops(None), vec![], vec![], vec![]); + let default_kind = Some(proto::TrainTypeKind::Default as i32); + let local = build_interactor(hanzomon_stops(default_kind), vec![], vec![], vec![]); let local_est = local .estimate_route_arrival_times(2800813, 2800807, &[], None) .await @@ -3137,18 +3125,12 @@ mod tests { } #[test] - fn test_build_route_tree_map_groups_by_line_cd_when_no_line_group() { + #[should_panic(expected = "route stop must belong to a train type group")] + fn test_build_route_tree_map_requires_line_group() { let interactor = create_interactor(); - let stops = vec![ - create_test_station(1, 1, 100, None), - create_test_station(2, 2, 100, None), - create_test_station(3, 3, 200, None), - ]; - let result = interactor.build_route_tree_map(&stops); + let stops = vec![create_test_station(1, 1, 100, None)]; - assert_eq!(result.len(), 2); - assert_eq!(result.get(&100).unwrap().len(), 2); - assert_eq!(result.get(&200).unwrap().len(), 1); + interactor.build_route_tree_map(&stops); } #[test] @@ -4943,9 +4925,6 @@ mod tests { // line_group 300: 発着駅を含まない → 除外 create_route_stop(3105, 5, 33, Some(300)), create_route_stop(3106, 6, 33, Some(300)), - // line_group_cdなし: line_cd(44)でグループ化され種別なし - create_route_stop(4101, 1, 44, None), - create_route_stop(4103, 3, 44, None), ]; let lines = vec![ create_route_line(11, 100), @@ -4959,7 +4938,7 @@ mod tests { // 発着駅を含まないline_group 300は除外され、BTreeMapのキー順に並ぶ let route_ids: Vec = routes.iter().map(|r| r.id).collect(); - assert_eq!(route_ids, vec![44, 100, 200]); + assert_eq!(route_ids, vec![100, 200]); // 路線の取得は経路候補ごとではなく一括1回で、 // 除外されたグループ(300)のIDは要求されない @@ -4991,11 +4970,6 @@ mod tests { let line_ids: Vec = tt.lines.iter().map(|l| l.id).collect(); assert_eq!(line_ids, vec![22]); } - - // line_group_cdなしのグループは種別を持たない - let route44 = routes.iter().find(|r| r.id == 44).unwrap(); - assert_eq!(route44.stops.len(), 2); - assert!(route44.stops.iter().all(|s| s.train_type.is_none())); } #[tokio::test]