feat(ktx-booking): 좌석 탐색 우선순위 개선

KTX 상세 좌석 조회에서 가운데 호차를 먼저 탐색하고,
같은 호차 안에서는 콘센트 좌석과 순방향 좌석을 우선 노출합니다.
#294 요구사항을 우선순위 함수와 command_seats 회귀 테스트로 고정합니다.

Constraint: #294는 기존 seats 공개 인터페이스 유지를 요구함
Rejected: 예약 API에 객차/좌석 번호를 직접 주입 | 현재 helper 예약 경로가 해당 선택 인자를 노출하지 않음
Confidence: high
Scope-risk: narrow
Directive: 좌석 우선순위 변경 시 command_seats 출력 순서 테스트를 함께 갱신할 것
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_ktx_booking
Tested: node --test scripts/skill-docs.test.js
Tested: npm run typecheck
Tested: npm run ci
This commit is contained in:
iamiks 2026-05-28 22:36:07 +09:00
commit 24feb3edca
4 changed files with 176 additions and 4 deletions

View file

@ -85,6 +85,8 @@ python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <t
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
```
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안에서는 콘센트 힌트가 있는 좌석을 먼저, 같은 조건에서는 순방향 좌석을 먼저 반환한다.
특정 호차의 남은 좌석만 확인:
```bash

View file

@ -135,6 +135,8 @@ python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <t
python3 scripts/ktx_booking.py seats 서울 부산 20260328 090000 --train-id <train_id> --available-only
```
특정 호차를 지정하지 않으면 `seats` 는 승강장 이동 거리가 짧은 가운데 호차부터 탐색한다. 각 호차 안의 좌석은 콘센트 힌트가 있는 좌석(`direct`, `adjacent`)을 먼저, 같은 조건에서는 순방향 좌석을 먼저 보여준다.
특정 호차만 확인하려면 `--car-no` 를 쓴다.
```bash

View file

@ -103,7 +103,7 @@ except ModuleNotFoundError as exc:
class _FallbackKorailModule:
EMAIL_REGEX = re.compile(r".+@.+")
PHONE_NUMBER_REGEX = re.compile(r"^\d+$")
PHONE_NUMBER_REGEX = re.compile(r"(\d{3})-(\d{3,4})-(\d{4})")
korail_mod = _FallbackKorailModule()
else:
@ -200,7 +200,7 @@ POWER_OUTLET_ADJACENT_COLUMNS = {"B", "C"}
def is_phone_login_id(korail_id: str) -> bool:
return bool(korail_mod.PHONE_NUMBER_REGEX.match(korail_id) or PHONE_NUMBER_DIGITS_REGEX.match(korail_id))
return bool(korail_mod.PHONE_NUMBER_REGEX.fullmatch(korail_id) or PHONE_NUMBER_DIGITS_REGEX.fullmatch(korail_id))
def ensure_runtime_dependencies() -> None:
@ -799,6 +799,30 @@ def normalize_car(raw_car: dict[str, object]) -> dict[str, object]:
}
def car_center_priority(car: dict[str, object], car_numbers: list[int]) -> tuple[float, int]:
car_no = int(car["car_no"])
if not car_numbers:
return (0.0, car_no)
center = (min(car_numbers) + max(car_numbers)) / 2
return (abs(car_no - center), car_no)
def sort_cars_for_booking(cars: list[dict[str, object]]) -> list[dict[str, object]]:
car_numbers = [int(car["car_no"]) for car in cars]
return sorted(cars, key=lambda car: car_center_priority(car, car_numbers))
def seat_preference_key(seat: dict[str, object]) -> tuple[int, int, int, str]:
power_rank = {"direct": 0, "adjacent": 1, "none": 2}.get(str(seat.get("power_outlet")), 2)
direction_rank = 0 if seat.get("direction") == "순방향" else 1
row, column = parse_seat_label(str(seat.get("seat", "")))
return (power_rank, direction_rank, row if row is not None else 999, column)
def sort_seats_for_booking(seats: list[dict[str, object]]) -> list[dict[str, object]]:
return sorted(seats, key=seat_preference_key)
def mask_identifier(value: object, visible: int = 4) -> str:
text = str(value or "")
if not text:
@ -895,6 +919,8 @@ def command_seats(args: argparse.Namespace) -> None:
cars = [car for car in cars if car["car_no"] == args.car_no]
if not cars:
raise SystemExit(f"car_no {args.car_no} is not available for {args.room}")
else:
cars = sort_cars_for_booking(cars)
car_payloads: list[dict[str, object]] = []
for car in cars:
@ -903,8 +929,8 @@ def command_seats(args: argparse.Namespace) -> None:
if isinstance(raw_seats, dict):
raw_seats = [raw_seats]
all_seats = [normalize_seat(seat) for seat in raw_seats if seat.get("h_con_seat_no") != "0A"]
available_seats = [seat for seat in all_seats if seat["available"]]
seats = all_seats
available_seats = sort_seats_for_booking([seat for seat in all_seats if seat["available"]])
seats = sort_seats_for_booking(all_seats)
if args.available_only:
seats = available_seats
if args.power_only:

View file

@ -321,6 +321,34 @@ class KtxBookingTests(unittest.TestCase):
self.assertEqual(ktx_booking.power_outlet_match("2A"), "none")
self.assertEqual(ktx_booking.power_outlet_match("bad"), "none")
def test_booking_priority_sorts_middle_cars_before_end_cars(self):
cars = [
{"car_no": 1},
{"car_no": 8},
{"car_no": 2},
{"car_no": 7},
{"car_no": 3},
{"car_no": 6},
{"car_no": 4},
{"car_no": 5},
]
sorted_cars = ktx_booking.sort_cars_for_booking(cars)
self.assertEqual([car["car_no"] for car in sorted_cars], [4, 5, 3, 6, 2, 7, 1, 8])
def test_booking_priority_sorts_power_outlet_before_forward_direction(self):
seats = [
{"seat": "2A", "power_outlet": "none", "direction": "순방향"},
{"seat": "1C", "power_outlet": "adjacent", "direction": "역방향"},
{"seat": "1A", "power_outlet": "direct", "direction": "역방향"},
{"seat": "3A", "power_outlet": "direct", "direction": "순방향"},
]
sorted_seats = ktx_booking.sort_seats_for_booking(seats)
self.assertEqual([seat["seat"] for seat in sorted_seats], ["3A", "1A", "1C", "2A"])
def test_is_phone_login_id_accepts_digits_only_mobile_numbers(self):
self.assertTrue(ktx_booking.is_phone_login_id("01012345678"))
self.assertTrue(ktx_booking.is_phone_login_id("0101234567"))
@ -531,6 +559,120 @@ class KtxBookingTests(unittest.TestCase):
self.assertEqual(client.train_car_calls[-1]["room_class"], "1")
self.assertEqual(client.car_seat_calls[-1]["car_no"], "05")
def test_command_seats_explores_middle_cars_first(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
raw_train = {"h_trn_no": "009", "h_dpt_dt": "20260328"}
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, raw_train)],
cars=[
{"h_srcar_no": "01", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
{"h_srcar_no": "08", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
{"h_srcar_no": "04", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "1"},
],
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=False,
power_only=False,
limit=10,
)
output = io.StringIO()
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(output):
ktx_booking.command_seats(args)
result = json.loads(output.getvalue())
self.assertEqual([car["car_no"] for car in result["cars"]], [4, 5, 1, 8])
self.assertEqual([call["car_no"] for call in client.car_seat_calls], ["04", "05", "01", "08"])
def test_command_seats_outputs_available_seats_by_booking_preference(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
raw_train = {"h_trn_no": "009", "h_dpt_dt": "20260328"}
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]
client = FakeClient(
[],
train_details=[(selected, raw_train)],
cars=[{"h_srcar_no": "05", "h_psrm_cl_cd": "1", "h_seat_cnt": "48", "h_rest_seat_cnt": "4"}],
seats_by_car={
"05": [
{
"h_con_seat_no": "2A",
"h_seat_no": "002001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "1C",
"h_seat_no": "001003",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "010",
"h_sigl_win_in_dv": "013",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "1A",
"h_seat_no": "001001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "010",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
{
"h_con_seat_no": "3A",
"h_seat_no": "003001",
"h_sale_psb_flg": "Y",
"h_for_rev_dir_dv": "009",
"h_sigl_win_in_dv": "012",
"h_dmd_seat_att": "015",
},
],
},
)
args = argparse.Namespace(
dep="서울",
arr="부산",
date="20260328",
time="090000",
adults=1,
children=0,
toddlers=0,
seniors=0,
train_id=train_id,
room="general",
train_type="ktx",
car_no=None,
available_only=True,
power_only=False,
limit=10,
)
output = io.StringIO()
with patch.object(ktx_booking, "build_client", return_value=client):
with redirect_stdout(output):
ktx_booking.command_seats(args)
result = json.loads(output.getvalue())
car = result["cars"][0]
self.assertEqual(car["available_seats"], ["3A", "1A", "1C", "2A"])
self.assertEqual([seat["seat"] for seat in car["seats"]], ["3A", "1A", "1C", "2A"])
def test_command_seats_supports_special_room_and_stale_train_error(self):
selected = FakeTrain(train_no="009", dep_time="090000", arr_time="113000", label="selected")
train_id = ktx_booking.normalize_train(selected, index=1)["train_id"]